Compare commits

...

121 Commits
v2.7.0 ... main

Author SHA1 Message Date
Jonatan Heyman
7f237f2e59 Bump version to 2.8.0 2026-02-04 14:35:21 +01:00
Jonatan Heyman
ca5c013ba7 Remove debug log 2026-02-04 14:33:28 +01:00
Jonatan Heyman
573b93332a
Merge pull request #441 from heyman/fix/enter-as-custom-key-binding
Fix so that Enter can be recorded for custom key bindings
2026-02-03 15:57:42 +01:00
Jonatan Heyman
51ee9e3416 Fix so that Enter can be recorded in RecordKeyInput for custom key bindings 2026-02-03 15:34:33 +01:00
Jonatan Heyman
1f7558d54a
Merge pull request #437 from heyman/fix/jetpack-for-heynote-file-protocol
Use jetpack to serve heynote-file protocol responses
2026-01-31 10:09:37 +01:00
Jonatan Heyman
e951af3a52 Use jetpack to serve heynote-file protocol responses
#build
2026-01-29 13:43:47 +01:00
Jonatan Heyman
ba78d8a9e4 Minor copy change 2026-01-27 18:56:02 +01:00
Jonatan Heyman
b9f0171ada Make shadow default to off when drawing 2026-01-27 18:55:55 +01:00
Jonatan Heyman
c2b53c0cc8 Update @codemirror/view patch after package update 2026-01-27 18:25:53 +01:00
Jonatan Heyman
542d4ca15f Update package.json and package-lock.json after upgrading to latest version of npm 2026-01-27 18:25:35 +01:00
Jonatan Heyman
4e974a8550 Bump version to 2.8.0-beta.4 2026-01-27 18:12:09 +01:00
Jonatan Heyman
432573e80d Update changelog 2026-01-27 18:10:46 +01:00
Jonatan Heyman
d995c4be4d
Merge pull request #433 from heyman/feature/draw-on-images
Add feature for drawing on images
2026-01-27 16:46:56 +01:00
Jonatan Heyman
555dc27af6 Skip draw-image tests for webkit, since hovering is flaky in headless mode 2026-01-27 16:34:05 +01:00
Jonatan Heyman
a80b20e5b2 Verify that the image that is drawn on actually changes 2026-01-27 16:03:28 +01:00
Jonatan Heyman
04dc51a4fe Make the draw image test actually draw a line on the image 2026-01-27 15:58:58 +01:00
Jonatan Heyman
86d943e34c Add test for drawing on an image 2026-01-27 15:49:22 +01:00
Jonatan Heyman
19483315af Reset package-lock.json 2026-01-27 00:23:23 +01:00
Jonatan Heyman
236be0ef81 Fix package-lock.json 2026-01-26 23:58:56 +01:00
Jonatan Heyman
8cab62b439 Persist color and shadow settings in drawing dialog 2026-01-26 23:56:27 +01:00
Jonatan Heyman
cc1a0a90c6 Set the cursor immediately after an image when it's saved after drawing 2026-01-26 10:55:41 +01:00
Jonatan Heyman
a090607cd6 Merge branch 'main' into feature/draw-on-images 2026-01-26 10:27:30 +01:00
Jonatan Heyman
f5435e8f57
Merge pull request #435 from heyman/fix/sanitize-system-locale
Sanitize system locale
2026-01-26 10:18:25 +01:00
Jonatan Heyman
ec87ce975c Sanitize system locale
Fixes issue when local returned by Electron's getSystemLocale() contains locale extension syntax
2026-01-26 09:40:06 +01:00
Jonatan Heyman
e3ea5d7b8c Add Ctrl/Cmd+Enter keyboard shortcut in draw dialog to save the drawn image 2026-01-25 21:21:05 +01:00
Jonatan Heyman
52f23964be Add ability to actually save drawn images 2026-01-25 21:17:11 +01:00
Jonatan Heyman
19b7c15ad9 Make DrawImageModal also emit the imageId 2026-01-25 21:00:09 +01:00
Jonatan Heyman
79b44d85a4 Make the the borders and handles for drawn elements in selection mode more visible 2026-01-25 20:53:11 +01:00
Jonatan Heyman
9a36ccc860 Tweak minimum dialog and brush sizes 2026-01-25 20:35:06 +01:00
Jonatan Heyman
5fdced8afa Styling for light theme 2026-01-25 20:30:26 +01:00
Jonatan Heyman
e9d5a50613 Add ability to change brush size 2026-01-25 17:19:15 +01:00
Jonatan Heyman
d744d5f9bf Add shadow toggle
Minor UI tweaks.
2026-01-25 16:39:09 +01:00
Jonatan Heyman
b6c005055a Dynamic sizing of draw modal.
Increase minimum default brush size.
Styling of selected mode buttons.
2026-01-24 22:12:19 +01:00
Jonatan Heyman
7bc9694b2a Styling of DrawImageModal's toolbar buttons 2026-01-24 21:48:18 +01:00
Jonatan Heyman
3ab26057f1 Add select mode where drawn elements can be selected, moved, resized, and removed 2026-01-24 21:24:37 +01:00
Jonatan Heyman
baf60cb55d WIP: Add support for basic drawing on images 2026-01-24 17:06:42 +01:00
Jonatan Heyman
b3229b7af4 Bump version to 2.8.0-beta.3 2026-01-23 16:39:31 +01:00
Jonatan Heyman
f81602b20d
Merge pull request #432 from heyman/downgrade-electron-to-39
Downgrade Electron to 39.3.0
2026-01-23 16:37:57 +01:00
Jonatan Heyman
a804e50dd6 Downgrade Electron to 39.3.0
Performance degradation was reported (https://github.com/heyman/heynote/issues/431) in latest beta version. Testing to see if downgrading electron fixes things.

#build
2026-01-23 15:42:12 +01:00
Jonatan Heyman
3478503c33 Add a check that the lipo built universal rg binary works 2026-01-21 20:42:01 +01:00
Jonatan Heyman
cd10ddb53e Add comment explaining env variable 2026-01-21 20:40:56 +01:00
Jonatan Heyman
14621c7392 Update prebuild script to make universal ripgrep binary (on Mac) 2026-01-21 19:58:45 +01:00
Jonatan Heyman
f4cae94121 Add GITHUB_TOKEN env variable 2026-01-21 19:51:28 +01:00
Jonatan Heyman
7763c2c772 Fix issue building universal Mac binary
Added a pre-build step to create a universal ripgrep (rg), and set mac universal merge rules in electron-builder.json5
2026-01-21 19:46:23 +01:00
Jonatan Heyman
6b91d1b071 Update changelog 2026-01-21 15:17:31 +01:00
Jonatan Heyman
a33f74068b Update to latest version of Electron 2026-01-21 15:16:28 +01:00
Jonatan Heyman
3474419521 Update to latest version of electron-builder 2026-01-21 15:05:11 +01:00
Jonatan Heyman
0446f7b090 Add more info about images to docs 2026-01-21 14:48:30 +01:00
Jonatan Heyman
8de030c3d6 Remove old patch file 2026-01-21 14:37:20 +01:00
Jonatan Heyman
0bca4a8c89 Bump version to 2.8.0-beta.2 2026-01-21 14:36:05 +01:00
Jonatan Heyman
2419ece730
Merge pull request #429 from heyman/feature/images
Add support for images
2026-01-21 14:33:45 +01:00
Jonatan Heyman
e79657de04 Fix anchor links 2026-01-21 14:19:54 +01:00
Jonatan Heyman
a7a2728447 Add info about Image support to docs 2026-01-21 14:16:53 +01:00
Jonatan Heyman
984da05310 Update README 2026-01-21 14:05:16 +01:00
Jonatan Heyman
31e62b1453 Add hash anchors to documentation 2026-01-21 13:57:56 +01:00
Jonatan Heyman
e6d1c74bc5 Fix different image in Changelog based on dark/light theme 2026-01-21 13:38:09 +01:00
Jonatan Heyman
7e33a2de68 Add info about image support to changelog 2026-01-21 13:04:50 +01:00
Jonatan Heyman
d8a3418e74 Fix so ripgrep works in a built version of the app
Add ripgrep to electron-builder's asarUnpack config so that the ripgrep binary gets unpacked from the built *.asar file, and can be spawned.

#build
2026-01-20 21:13:15 +01:00
Jonatan Heyman
0222c805bf Add information about image storage to documentation
#build
2026-01-20 14:40:51 +01:00
Jonatan Heyman
7a2f915fe9 Fix typo 2026-01-20 14:38:04 +01:00
Jonatan Heyman
cedbe0d5b2 Fix so that image pasting and drag&drop does not result in an error in webapp version 2026-01-20 14:17:26 +01:00
Jonatan Heyman
d62d2e6696 Reset RegEx's lastIndex when returning early 2026-01-20 12:44:25 +01:00
Jonatan Heyman
52634ec5a9 Remove unused import 2026-01-20 12:22:02 +01:00
Jonatan Heyman
6b2ed7c1b0 Use already parsed images instead of re-parsing in copyCommand 2026-01-20 12:21:50 +01:00
Jonatan Heyman
55b5630fd7 Replace ":" with "." for image filenames, since ":" is an invalid character for filenames on Windows 2026-01-20 12:18:12 +01:00
Jonatan Heyman
25bd017ab0 state.field() should be used to access state field 2026-01-20 12:17:17 +01:00
Jonatan Heyman
ec1a4c1e22 Ripgrep exist with code 1 (no matches found) should not be treated as an error 2026-01-20 11:53:55 +01:00
Jonatan Heyman
eccfba2d4e Fix typo 2026-01-20 11:48:41 +01:00
Jonatan Heyman
176cafc06d Make electron e2e tests run serially 2026-01-20 00:28:49 +01:00
Jonatan Heyman
830dd6f549 Fix issue when copying image data of image mime types not supported by the Clipboard API 2026-01-20 00:28:28 +01:00
Jonatan Heyman
de86737fb3 Add support for drag & dropping image files into Heynote 2026-01-20 00:19:15 +01:00
Jonatan Heyman
48c1fd139b Increase minimum image height (so that drag handle doesn't overflow) 2026-01-20 00:05:00 +01:00
Jonatan Heyman
e7f7f7f7b7 Copy image data when issuing copy command and the cursor is placed immediately before or after an image 2026-01-19 20:51:24 +01:00
Jonatan Heyman
afb2dbbb77 Add Electron end-to-end test for pasting image data into Heynote 2026-01-19 19:59:17 +01:00
Jonatan Heyman
e1e2163d9f Make e2e tests less flaky 2026-01-19 19:58:37 +01:00
Jonatan Heyman
7f624f8f87 Add image tests
- Add tests for parsing of image tags
- Add tests for ImageWidget
2026-01-19 17:19:41 +01:00
Jonatan Heyman
32fd508628 Add styling of image widget for light theme 2026-01-19 16:49:11 +01:00
Jonatan Heyman
bd4cbad307 Restructure e2e tests. Add test that checks that the buffer is saved to disk. 2026-01-19 14:26:54 +01:00
Jonatan Heyman
cf3aae3c26 Run e2e tests in same github action workflow as other tests 2026-01-19 01:46:36 +01:00
Jonatan Heyman
cfc596e1f7 Add separate playwright tag for e2e tests
Try to get e2e tests to work on CI
2026-01-19 01:43:34 +01:00
Jonatan Heyman
1955750675 Try to get Electron e2e test to work on Github Actions 2026-01-19 01:22:09 +01:00
Jonatan Heyman
1f16fc6efe Add copy button to image widget.
Fix issue with references pointing to the wrong image after recycling of an image widget's DOM elements.
Make highlight border for image widgets green when it has snapped to the ideal dimensions.
2026-01-19 01:12:13 +01:00
Jonatan Heyman
9737723eed Add end-to-end test that spawns the Electron app 2026-01-18 19:38:56 +01:00
Jonatan Heyman
759043c034 Fix indentation 2026-01-18 19:38:14 +01:00
Jonatan Heyman
72ea8e5e6a Move Playwright tests into tests/playwright 2026-01-18 19:04:57 +01:00
Jonatan Heyman
d0e6aa0cd9 Removed unused import 2026-01-18 19:02:40 +01:00
Jonatan Heyman
6b41281c9f Add tests for the main process using Vitest
Add tests for FileLibrary
2026-01-18 18:20:40 +01:00
Jonatan Heyman
2e1b7e9a41 Use modified time instead of creation time 2026-01-18 18:18:34 +01:00
Jonatan Heyman
0559a05c3f Don't delete any images unless at least one of the referenced files is found in .images/. This should make the code more resilient to potential data loss bugs in the future. 2026-01-18 15:29:41 +01:00
Jonatan Heyman
ac8d7c36ca Garbage collection of unreferenced images
Remove image files, that are no longer referenced (and are >24h old), on startup. We use ripgrep to find all <∞img∞> tags in the file library.
2026-01-18 14:33:19 +01:00
Jonatan Heyman
3f6686c46d Update test to reflect changed behaviour of preserving block separators when copying/pasting within heynote 2026-01-18 14:21:33 +01:00
Jonatan Heyman
ba53bc1199 Fix missing "." typo 2026-01-18 14:15:57 +01:00
Jonatan Heyman
c4f716ada7 Store images in heynote's file library, and serialize/deserialize clipboard data on copy/paste/cut
* WIP: Add support for storing images in heynote's file library
* When pasting image data, store it in the file library and add an image tag referencing it through heynote-file://image/
* When copying content, serialize it into three formats: text/plain, text/html and "web text/heynote"
  - For "text/html", <∞img∞> tags are replaced with <img> tags with the image data is a data URL in the src attribute
  - For "web text/heynote" the data is copied as is
* When pasting content of the type "web text/heynote", new UUID IDs are generated for each <∞img∞> tag
2026-01-16 16:54:37 +01:00
Jonatan Heyman
00e35bd4ab * Implement snapping to the ideal image width (based on devicePixerlRatio) when resizing images.
* Tweak colors of selected images
* Fix issue where resize handle would flicker on mouseup
2026-01-16 16:45:06 +01:00
Jonatan Heyman
9f10f4f33b Fix blockLayer positioning calculation 2026-01-16 11:16:24 +01:00
Jonatan Heyman
34c9d679fb Avoid code duplication for copy/cut 2026-01-15 18:02:04 +01:00
Jonatan Heyman
c795ff2bfd Use nwse-resize cursor for whole editor while image is being resized 2026-01-15 17:50:12 +01:00
Jonatan Heyman
d073442b13 Fix broken searchTestFunction which didn't work for matched text on the same row as image tags 2026-01-15 17:35:48 +01:00
Jonatan Heyman
dfa73927ea (WIP) Implement image widget
- Add support for <∞img;...∞> tags that are displayed as image widgets.
- Image widgets can be resized
- Image tags are excluded when searching
- Folding a block with an image on the first row makes sure not to place the fold cutoff point in the middle of the tag
- Visible images (on the first row) of folded blocks gets rendered as miniatures
2026-01-15 16:26:51 +01:00
Jonatan Heyman
bc32aa835f Make line divs have the same padding-right (6px) as padding-left instead of 2px
This makes images with 100% width centered.
2026-01-15 16:08:27 +01:00
Jonatan Heyman
196cb45be4 Change how we calculate the position/size of the blockLayers
Use view.lineBlockAt() instead of view.coordsAtPos(). The previous method ddin't work when there was an inline-block widget (e.g. an image) on the first line of a block. The positioning should be identical, but using lineBlockAt() works with inline-blocks on the first line.
2026-01-15 16:00:28 +01:00
Jonatan Heyman
eee2bfe94d Update to latest version of @codemirror/view 2026-01-14 01:05:05 +01:00
Jonatan Heyman
ef3c4677b8 Tweak colors for light theme
Change syntax highlighting color for Numbers, and background color for a checked todo checkbox
2026-01-13 16:45:18 +01:00
Jonatan Heyman
950f0199e8 Use official version of @codemirror/search
Remove modified version of @codemirror/search that we had vendored, since https://github.com/codemirror/search/pull/18 was merged.

Refactored code to use the same search test function for regular search, regexp search, and selection matching search.
2026-01-13 12:17:56 +01:00
Jonatan Heyman
d0b116696c Bump version to 2.8.0-beta 2026-01-12 10:10:37 +01:00
Jonatan Heyman
e13f5d95de Update to latest version of heynote-lang-mathjs (should fix tests) 2026-01-10 19:28:09 +01:00
Jonatan Heyman
8f16ab3d0c Add syntax highlighting for Match blocks 2026-01-10 19:28:09 +01:00
Jonatan Heyman
fdae1c1709 Tweak syntax highlighting colors 2026-01-10 19:28:09 +01:00
Jonatan Heyman
e5f643a97d Tweak text color for Math results 2026-01-10 19:28:09 +01:00
Jonatan Heyman
12bd808b3d Update to latest version of Math.js 2026-01-10 19:28:09 +01:00
Jonatan Heyman
706bd96943
Merge pull request #407 from LSauce/main
Add Mermaid language support
2026-01-09 15:43:36 +01:00
Jonatan Heyman
97aeb8e41e Add changelog entry 2026-01-09 15:31:59 +01:00
Jonatan Heyman
62ee1dbf69 Merge branch 'main' into LSauce/main 2026-01-09 15:26:32 +01:00
Jonatan Heyman
60cdaf2047 Remove guesslang attribute for Mermaid Language instance, since Mermaid is not supported by guesslang 2026-01-09 15:19:18 +01:00
Jonatan Heyman
b90719ee16 Fix so that new lines in SQL blocks inherits the indentation of the previous line 2026-01-09 14:59:10 +01:00
Jonatan Heyman
af71893ab3 Make new lines in Plaintext and Math blocks and inherit the indentation from the previous line.
- Adds an indentService to opt certain languages into indentation inheritance.
- Introduces inheritIndentation metadata for languages, defaulted for Plain Text and Math.
- Adds tests
2026-01-07 19:52:11 +01:00
Jonatan Heyman
d9f3a4306d Bump version to 2.7.1 2026-01-06 12:56:39 +01:00
Jonatan Heyman
0d3e687582
Merge pull request #423 from heyman/fix-block-unfolding-when-editing-empty-block-below
Fix issue where a folded block gets unfolded when editing an empty block directly below it
2026-01-06 12:51:05 +01:00
Jonatan Heyman
775c455906 Fix typo (missing import) 2026-01-06 12:40:13 +01:00
Jonatan Heyman
92cc99ba1d Fix issue where a folded block gets unfolded when editing an empty block directly below it 2026-01-06 12:17:51 +01:00
LSauce
72955e1c23 Add Mermaid language highlight support(#393) 2025-12-18 11:12:56 +08:00
120 changed files with 10036 additions and 3260 deletions

View File

@ -57,6 +57,8 @@ jobs:
APPLE_API_KEY: ~/private_keys/AuthKey.p8
APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }}
APPLE_API_ISSUER: ${{ secrets.apple_api_key_issuer_id }}
# GITHUB_TOKEN is used by @vscode/ripgrep to increase API limit when downloading ripgrep binaries
GITHUB_TOKEN: ${{ secrets.github_token }}
#- name: Print notarization-error.log
# if: ${{ matrix.os == 'macos-latest' }}

View File

@ -10,12 +10,18 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: latest
- name: Install xvfb
run: sudo apt-get install xvfb
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run test
- name: Run e2e tests
run: xvfb-run --auto-servernum npm run test:e2e
- name: Run Main Process tests
run: npm run test:main
- uses: actions/upload-artifact@v4
if: always()
with:

View File

@ -21,12 +21,13 @@ Available for Mac, Windows, and Linux.
- Persistent text buffer(s)
- Block-based
- Inline images
- Multiple buffers in tabs
- Math/Calculator mode
- Currency conversion
- Syntax highlighting:
C++, C#, Clojure, CSS, Elixir, Erlang, Dart, Go, Groovy, HTML, Java, JavaScript, JSX, Kotlin, TypeScript, TOML, TSX, JSON, Lezer, Markdown, PHP, Python, Ruby, Rust, Scala, Shell, SQL, Swift, Vue, XML, YAML
C++, C#, Clojure, CSS, Elixir, Erlang, Dart, Go, Groovy, HTML, Java, JavaScript, JSX, Kotlin, TypeScript, TOML, TSX, JSON, Lezer, Markdown, Mermaid, PHP, Python, Ruby, Rust, Scala, Shell, SQL, Swift, Vue, XML, YAML
- Language auto-detection
- Auto-formatting

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M173.3,66.5C181.4,62.4 191.2,63.3 198.4,68.8L518.4,308.7C526.7,314.9 530,325.7 526.8,335.5C523.6,345.3 514.4,351.9 504,351.9L351.7,351.9L440.6,529.6C448.5,545.4 442.1,564.6 426.3,572.5C410.5,580.4 391.3,574 383.4,558.2L294.5,380.5L203.2,502.3C197,510.6 186.2,513.9 176.4,510.7C166.6,507.5 160,498.3 160,488L160,88C160,78.9 165.1,70.6 173.3,66.5Z" style="fill:rgb(72,72,72);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 860 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M173.3,66.5C181.4,62.4 191.2,63.3 198.4,68.8L518.4,308.7C526.7,314.9 530,325.7 526.8,335.5C523.6,345.3 514.4,351.9 504,351.9L351.7,351.9L440.6,529.6C448.5,545.4 442.1,564.6 426.3,572.5C410.5,580.4 391.3,574 383.4,558.2L294.5,380.5L203.2,502.3C197,510.6 186.2,513.9 176.4,510.7C166.6,507.5 160,498.3 160,488L160,88C160,78.9 165.1,70.6 173.3,66.5Z" style="fill:white;fill-opacity:0.8;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M480,400L288,400C279.2,400 272,392.8 272,384L272,128C272,119.2 279.2,112 288,112L421.5,112C425.7,112 429.8,113.7 432.8,116.7L491.3,175.2C494.3,178.2 496,182.3 496,186.5L496,384C496,392.8 488.8,400 480,400ZM288,448L480,448C515.3,448 544,419.3 544,384L544,186.5C544,169.5 537.3,153.2 525.3,141.2L466.7,82.7C454.7,70.7 438.5,64 421.5,64L288,64C252.7,64 224,92.7 224,128L224,384C224,419.3 252.7,448 288,448ZM160,192C124.7,192 96,220.7 96,256L96,512C96,547.3 124.7,576 160,576L352,576C387.3,576 416,547.3 416,512L416,496L368,496L368,512C368,520.8 360.8,528 352,528L160,528C151.2,528 144,520.8 144,512L144,256C144,247.2 151.2,240 160,240L176,240L176,192L160,192Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M128,96C110.3,96 96,110.3 96,128L96,224C96,241.7 110.3,256 128,256C145.7,256 160,241.7 160,224L160,160L224,160C241.7,160 256,145.7 256,128C256,110.3 241.7,96 224,96L128,96ZM160,416C160,398.3 145.7,384 128,384C110.3,384 96,398.3 96,416L96,512C96,529.7 110.3,544 128,544L224,544C241.7,544 256,529.7 256,512C256,494.3 241.7,480 224,480L160,480L160,416ZM416,96C398.3,96 384,110.3 384,128C384,145.7 398.3,160 416,160L480,160L480,224C480,241.7 494.3,256 512,256C529.7,256 544,241.7 544,224L544,128C544,110.3 529.7,96 512,96L416,96ZM544,416C544,398.3 529.7,384 512,384C494.3,384 480,398.3 480,416L480,480L416,480C398.3,480 384,494.3 384,512C384,529.7 398.3,544 416,544L512,544C529.7,544 544,529.7 544,512L544,416Z" style="fill:rgb(191,191,191);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M128,96C110.3,96 96,110.3 96,128L96,224C96,241.7 110.3,256 128,256C145.7,256 160,241.7 160,224L160,160L224,160C241.7,160 256,145.7 256,128C256,110.3 241.7,96 224,96L128,96ZM160,416C160,398.3 145.7,384 128,384C110.3,384 96,398.3 96,416L96,512C96,529.7 110.3,544 128,544L224,544C241.7,544 256,529.7 256,512C256,494.3 241.7,480 224,480L160,480L160,416ZM416,96C398.3,96 384,110.3 384,128C384,145.7 398.3,160 416,160L480,160L480,224C480,241.7 494.3,256 512,256C529.7,256 544,241.7 544,224L544,128C544,110.3 529.7,96 512,96L416,96ZM544,416C544,398.3 529.7,384 512,384C494.3,384 480,398.3 480,416L480,480L416,480C398.3,480 384,494.3 384,512C384,529.7 398.3,544 416,544L512,544C529.7,544 544,529.7 544,512L544,416Z" style="fill:rgb(109,109,109);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 800 800" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
<g id="SVGRepo_iconCarrier">
<path d="M200,400L600,400" style="fill:none;fill-rule:nonzero;stroke:rgb(184,184,184);stroke-width:66.67px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 614 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 800 800" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
<g id="SVGRepo_iconCarrier">
<path d="M200,400L600,400" style="fill:none;fill-rule:nonzero;stroke:rgb(72,72,72);stroke-width:66.67px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M512.5,74.3L291.1,222C262,241.4 243.5,272.9 240.5,307.3C302.8,320.1 351.9,369.2 364.8,431.6C399.3,428.6 430.7,410.1 450.1,381L597.7,159.5C604.4,149.4 608,137.6 608,125.4C608,91.5 580.5,64 546.6,64C534.5,64 522.6,67.6 512.5,74.3ZM320,464C320,402.1 269.9,352 208,352C146.1,352 96,402.1 96,464C96,467.9 96.2,471.8 96.6,475.6C98.4,493.1 86.4,512 68.8,512L64,512C46.3,512 32,526.3 32,544C32,561.7 46.3,576 64,576L208,576C269.9,576 320,525.9 320,464Z" style="fill:rgb(72,72,72);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 959 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M512.5,74.3L291.1,222C262,241.4 243.5,272.9 240.5,307.3C302.8,320.1 351.9,369.2 364.8,431.6C399.3,428.6 430.7,410.1 450.1,381L597.7,159.5C604.4,149.4 608,137.6 608,125.4C608,91.5 580.5,64 546.6,64C534.5,64 522.6,67.6 512.5,74.3ZM320,464C320,402.1 269.9,352 208,352C146.1,352 96,402.1 96,464C96,467.9 96.2,471.8 96.6,475.6C98.4,493.1 86.4,512 68.8,512L64,512C46.3,512 32,526.3 32,544C32,561.7 46.3,576 64,576L208,576C269.9,576 320,525.9 320,464Z" style="fill:white;fill-opacity:0.8;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M100.4,417.2C104.5,402.6 112.2,389.3 123,378.5L338.1,163.4L476,301.3L260.9,516.4C250.2,527.1 236.8,534.9 222.2,539L94.4,574.6C86.1,576.9 77.1,574.6 71,568.4C64.9,562.2 62.6,553.3 64.9,545L100.4,417.2ZM156,413.5C151.6,418.2 148.4,423.9 146.7,430.1L122.6,517L209.5,492.9C215.9,491.1 221.7,487.8 226.5,483.2L155.9,413.5L156,413.5ZM510,267.4C493.4,250.8 458.7,216.1 406,163.4L372,129.5C398.5,103 413.4,88.1 416.9,84.6C430.4,71 448.8,63.4 468,63.4C487.2,63.4 505.6,71 519.1,84.6L554.8,120.3C568.4,133.9 576,152.3 576,171.4C576,190.5 568.4,209 554.8,222.5C551.3,226 536.4,240.9 509.9,267.4L510,267.4Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 800 800" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
<g id="SVGRepo_iconCarrier">
<path d="M200,400L600,400M400,200L400,600" style="fill:none;fill-rule:nonzero;stroke:rgb(184,184,184);stroke-width:66.67px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 630 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 800 800" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
<g id="SVGRepo_iconCarrier">
<path d="M200,400L600,400M400,200L400,600" style="fill:none;fill-rule:nonzero;stroke:rgb(72,72,72);stroke-width:66.67px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 627 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M371.8,82.4C359.8,87.4 352,99 352,112L352,192L240,192C142.8,192 64,270.8 64,368C64,481.3 145.5,531.9 164.2,542.1C166.7,543.5 169.5,544 172.3,544C183.2,544 192,535.1 192,524.3C192,516.8 187.7,509.9 182.2,504.8C172.8,496 160,478.4 160,448.1C160,395.1 203,352.1 256,352.1L352,352.1L352,432.1C352,445 359.8,456.7 371.8,461.7C383.8,466.7 397.5,463.9 406.7,454.8L566.7,294.8C579.2,282.3 579.2,262 566.7,249.5L406.7,89.5C397.5,80.3 383.8,77.6 371.8,82.6L371.8,82.4Z" style="fill:rgb(72,72,72);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M371.8,82.4C359.8,87.4 352,99 352,112L352,192L240,192C142.8,192 64,270.8 64,368C64,481.3 145.5,531.9 164.2,542.1C166.7,543.5 169.5,544 172.3,544C183.2,544 192,535.1 192,524.3C192,516.8 187.7,509.9 182.2,504.8C172.8,496 160,478.4 160,448.1C160,395.1 203,352.1 256,352.1L352,352.1L352,432.1C352,445 359.8,456.7 371.8,461.7C383.8,466.7 397.5,463.9 406.7,454.8L566.7,294.8C579.2,282.3 579.2,262 566.7,249.5L406.7,89.5C397.5,80.3 383.8,77.6 371.8,82.6L371.8,82.4Z" style="fill:white;fill-opacity:0.8;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="dark">
<g transform="matrix(0.979324,0,0,0.979324,99.1782,133.656)">
<clipPath id="_clip1">
<path d="M329.78,4.645L541.263,4.645L541.263,468.355L45.573,468.355L45.573,329.706C72.631,341.069 102.347,347.35 133.518,347.35C259.106,347.35 361.068,245.389 361.068,119.8C361.068,77.791 349.659,38.425 329.78,4.645Z"/>
</clipPath>
<g clip-path="url(#_clip1)">
<circle cx="309.5" cy="236.5" r="184.5" style="fill:rgb(136,136,136);"/>
</g>
</g>
<g transform="matrix(0.979324,0,0,0.979324,-73.1656,19.3695)">
<circle cx="309.5" cy="236.5" r="184.5" style="fill:rgb(230,230,230);"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="light">
<g transform="matrix(0.979324,0,0,0.979324,99.1782,133.656)">
<clipPath id="_clip1">
<path d="M329.78,4.645L541.263,4.645L541.263,468.355L45.573,468.355L45.573,329.706C72.631,341.069 102.347,347.35 133.518,347.35C259.106,347.35 361.068,245.389 361.068,119.8C361.068,77.791 349.659,38.425 329.78,4.645Z"/>
</clipPath>
<g clip-path="url(#_clip1)">
<circle cx="309.5" cy="236.5" r="184.5" style="fill:rgb(109,109,109);"/>
</g>
</g>
<g transform="matrix(0.979324,0,0,0.979324,-73.1656,19.3695)">
<circle cx="309.5" cy="236.5" r="184.5" style="fill:rgb(43,43,43);"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M268.2,82.4C280.2,87.4 288,99 288,112L288,192L400,192C497.2,192 576,270.8 576,368C576,481.3 494.5,531.9 475.8,542.1C473.3,543.5 470.5,544 467.7,544C456.8,544 448,535.1 448,524.3C448,516.8 452.3,509.9 457.8,504.8C467.2,496 480,478.4 480,448.1C480,395.1 437,352.1 384,352.1L288,352.1L288,432.1C288,445 280.2,456.7 268.2,461.7C256.2,466.7 242.5,463.9 233.3,454.8L73.3,294.8C60.8,282.3 60.8,262 73.3,249.5L233.3,89.5C242.5,80.3 256.2,77.6 268.2,82.6L268.2,82.4Z" style="fill:rgb(72,72,72);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 972 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 640 640" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M268.2,82.4C280.2,87.4 288,99 288,112L288,192L400,192C497.2,192 576,270.8 576,368C576,481.3 494.5,531.9 475.8,542.1C473.3,543.5 470.5,544 467.7,544C456.8,544 448,535.1 448,524.3C448,516.8 452.3,509.9 457.8,504.8C467.2,496 480,478.4 480,448.1C480,395.1 437,352.1 384,352.1L288,352.1L288,432.1C288,445 280.2,456.7 268.2,461.7C256.2,466.7 242.5,463.9 233.3,454.8L73.3,294.8C60.8,282.3 60.8,262 73.3,249.5L233.3,89.5C242.5,80.3 256.2,77.6 268.2,82.6L268.2,82.4Z" style="fill:white;fill-opacity:0.8;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 981 B

View File

@ -2,6 +2,41 @@
Here are the most notable changes in each release. For a more detailed list of changes, see the [Github Releases page](https://github.com/heyman/heynote/releases).
## 2.8.0
### Images
Heynote now supports inline images. You can paste images from the clipboard or drag and drop image files.
Images can be selected and resized directly in the editor, and it's quick and easy to put an image back
on the system clipboard.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://heynote.com/img/dark/images.png">
<img src="https://heynote.com/img/light/images.png" width="400" alt="New image feature">
</picture>
### Drawing on images
Heynote now includes a lightweight drawing tool for adding quick, freehand annotations on top of images.
### Other changes
- Add syntax highlighting for Math blocks
- New lines in a Plaintext, Math and SQL blocks now inherits the indentation from the previous line
(other languages should already have this behaviour for new lines)
- Add support for [Mermaid](https://mermaid.js.org/) blocks
- Fix issue not being able to record the Enter key when creating custom key bindings
## v2.7.2
- Fix issue causing broken status bar on some system locales (#434)
## 2.7.1
- Fix issue where a folded block would get unfolded when editing an empty block directly below it.
## 2.7.0

View File

@ -12,6 +12,7 @@ Available for Mac, Windows, and Linux.
- Persistent text buffer(s)
- Block-based
- Inline images
- Multiple buffers in tabs
- Math/Calculator mode
- Currency conversion
@ -28,7 +29,7 @@ Available for Mac, Windows, and Linux.
- Spellchecking
## Default Key Bindings
## Default Key Bindings<a id="default-key-bindings"></a>
<!-- keyboard_shortcuts -->
@ -82,7 +83,7 @@ You can see all the key bindings in the command palette and in Settings under Ke
## Custom Key Bindings
## Custom Key Bindings<a id="custom-key-bindings"></a>
Heynote supports custom key bindings which you can configure in the settings. The key bindings are evaluated from top to bottom, so a binding that comes before another one will take precedence. Most commands will stop the event from propagating, but some commands only applies in certain contexts and might not stop the event from propagating to a later key binding.
@ -98,7 +99,7 @@ If installing Heynote on Linux in ChromeOS, see the [notes](#user-content-linux-
On macOS, [Homebrew](https://brew.sh) users can utilize an unofficial [Homebrew Cask](https://formulae.brew.sh/cask/heynote#default): `brew install --cask heynote`
## Math Blocks
## Math Blocks<a id="math-blocks"></a>
Heynote's Math blocks are powered by [Math.js expressions](https://mathjs.org/docs/expressions). Checkout their [documentation](https://mathjs.org/docs/) to see what [syntax](https://mathjs.org/docs/expressions/syntax.html), [functions](https://mathjs.org/docs/reference/functions.html), and [constants](https://mathjs.org/docs/reference/constants.html) are available.
@ -129,8 +130,17 @@ format(x) = x.toLocaleString('en-GB');
See the [Math.js format()](https://mathjs.org/docs/reference/functions/format.html) function for more info on what's supported.
## Images
## The notes library
Pasting image data from the clipboard will insert an inline image into the buffer. Internally, the image data is saved to a file
within [the notes library's](#user-content-the-notes-library) `.images` directory. Drag and dropping an image file will also insert the image
into the editor (a copy of the image will be placed in the `.images` directory).
Images can be resized for display, but the underlying image data keeps its original dimensions. Issuing a copy command (`Ctrl/Cmd-C` with the
default key bindings) with the cursor next to an image will populate the system clipboard with the image data.
## The notes library<a id="the-notes-library"></a>
The notes library is a directory (with sub dirs) on the disk with a `.txt` file for each buffer. It's created the first time you start Heynote, with the default buffer file `scratch.txt` in it. The default location for the library is:
@ -140,6 +150,10 @@ The notes library is a directory (with sub dirs) on the disk with a `.txt` file
You can change the path of the notes library in the settings. Heynote expects reasonably fast disk access to the notes library, so it's not recommended to use a network drive, though file syncing services like Dropbox, OneDrive, etc. should work (see below).
### Image storage
Images are stored alongside your notes in a hidden `.images` folder inside the notes library directory. Each image is referenced from the buffer file, and the app uses those references to clean up older, unreferenced images over time. Cleanup runs on startup and only removes unreferenced images older than 24 hours (and only if there are any referenced images, as a safety check).
### Synchronizing the notes library
Heynote is built to support synchronizing the notes library (or buffer file in the case of Heynote 1.x) through file-syncing services like Dropbox, OneDrive, etc. However, note that the synchronization logic is quite simple, so editing the same buffer on two different machines at the same time might lead to conflicts and unexpected results.
@ -149,14 +163,14 @@ When using a file synching service that support "offloading" of files in the clo
As always, backup things that are important.
### Spellchecking
## Spellchecking
Spellchecking can be toggled on or off by clicking the spellchecking icon in the status bar. Right-clicking the icon on Windows and Linux allows you to select the active dictionaries (on Mac, the default OS dictionary is used).
## Linux
## Linux<a id="linux"></a>
### Linux on ChromeOS
### Linux on ChromeOS<a id="linux-on-chromeos"></a>
It's been reported [(#48)](https://github.com/heyman/heynote/issues/48) that ChromeOS's Debian VM need the following packages installed to run the Heynote AppImage:
@ -192,4 +206,3 @@ registerShortcut('toggleHeynote', 'Toggle Heynote', 'Ctrl+Shift+H', toggleHeynot
See the [KWin scripting tutorial](https://develop.kde.org/docs/plasma/kwin/) for instructions on how to install the script.
Remember to enable the script in the KDE System Settings. It may also be necessary to go into the KDE System Settings and bind the "Toggle Heynote" key manually.

View File

@ -4,6 +4,9 @@
{
"appId": "com.heynote.app",
"asar": true,
"asarUnpack": [
"**/node_modules/@vscode/ripgrep/bin/**"
],
//"icon": "public/favicon.ico",
"directories": {
"output": "release/${version}",
@ -27,6 +30,8 @@
],
"publish": ["github"],
"category": "public.app-category.productivity",
"singleArchFiles": "**/node_modules/@vscode/ripgrep/bin/**",
"x64ArchFiles": "**/node_modules/@vscode/ripgrep/bin/rg",
},
"win": {
"target": [

View File

@ -1,6 +1,13 @@
import { app } from "electron"
import Store from "electron-store"
import { isMac } from "./detect-platform"
// the process.type === "browser" check is needed because both the main and renderer process
// imports this file, and app is not available in the renderer process
if (process.env.HEYNOTE_TEST_USER_DATA_DIR && process.type === "browser") {
app.setPath("userData", process.env.HEYNOTE_TEST_USER_DATA_DIR)
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const schema = {
@ -57,6 +64,13 @@ const schema = {
"spellcheckEnabled": {type: "boolean", default:false},
"showWhitespace": {type:"boolean", default:false},
"cursorBlinkRate": {type: "integer", default: 1000},
"drawSettings": {
type: "object",
properties: {
color: {type: "string"},
shadowEnabled: {type: "boolean"},
},
},
// when default font settings are used, fontFamily and fontSize is not specified in the
// settings file, so that it's possible for us to change the default settings in the

View File

@ -4,16 +4,19 @@ import { join, basename } from "path"
import * as jetpack from "fs-jetpack";
import { app, ipcMain, dialog } from "electron"
import * as mimetypes from "mime-types"
import CONFIG from "../config"
import { SCRATCH_FILE_NAME } from "../../src/common/constants"
import { SCRATCH_FILE_NAME, IMAGE_MIME_TYPES } from "../../src/common/constants"
import { NoteFormat } from "../../src/common/note-format"
import { isDev } from '../detect-platform';
import { initialContent, initialDevContent } from '../initial-content'
import { getImgReferences } from "./ripgrep.js"
export const NOTES_DIR_NAME = isDev ? "notes-dev" : "notes"
/**@type {FileLibrary}*/
let library
const untildify = (pathWithTilde) => {
@ -48,6 +51,7 @@ export class FileLibrary {
throw new Error(`Path directory does not exist: ${basePath}`)
}
this.basePath = fs.realpathSync(basePath)
this.imagesBasePath = join(this.basePath, ".images")
this.jetpack = jetpack.cwd(this.basePath)
this.files = {};
this.watcher = null;
@ -59,6 +63,9 @@ export class FileLibrary {
if (!this.jetpack.exists(SCRATCH_FILE_NAME)) {
this.jetpack.write(SCRATCH_FILE_NAME, isDev ? initialDevContent : initialContent)
}
// garbage collect stale images
this.removeUnreferencedImages()
}
async exists(path) {
@ -196,8 +203,76 @@ export class FileLibrary {
this._onWindowFocus = null
}
}
}
async saveImage({mime, data}) {
if (!IMAGE_MIME_TYPES.includes(mime)) {
return
}
const fileExtension = mimetypes.extension(mime)
const filename = (new Date()).toISOString().replace(/:/g, ".") + "." + fileExtension
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data)
const buf = Buffer.from(u8)
//console.log("saveImage", filename, mime, buf.length)
await this.jetpack.writeAsync(join(this.imagesBasePath, filename), buf)
return filename
}
removeImage() {
}
listImages() {
}
async removeUnreferencedImages() {
if (!jetpack.exists(this.imagesBasePath)) {
console.log(`${this.imagesBasePath} does not exist, so no cleanup needed`)
return
}
let referencedImages = []
try {
referencedImages = await getImgReferences(this.basePath)
} catch (err) {
console.error(err)
}
const jp = jetpack.cwd(this.imagesBasePath)
const files = await jp.findAsync("", {
matching: "*",
recursive: false,
})
let referencedImageFound = false
const filesToDelete = []
for (const filename of files) {
if (referencedImages.includes(filename)) {
//console.log("File is referenced, skipping:", filename)
referencedImageFound = true
continue
}
const fileInfo = await jp.inspectAsync(filename, {times: true})
if (!fileInfo || !fileInfo.modifyTime) {
continue
}
if ((new Date() - fileInfo.modifyTime) > 1000 * 3600 * 24) {
//console.log("deleting file:", filename)
filesToDelete.push(filename)
}
}
if (!referencedImageFound) {
console.log(`No referenced images found, so as a precaution, we won't do any removal of unreferenced images`)
return
}
for (const filename of filesToDelete) {
await jp.removeAsync(filename)
}
console.log(`Removed ${filesToDelete.length} unreferenced image files`)
}
}
export class NoteBuffer {
@ -317,6 +392,10 @@ export function setupFileLibraryEventHandlers() {
const filePath = result.filePaths[0]
return filePath
})
ipcMain.handle("library:saveImage", async (event, blob) => {
return await library.saveImage(blob)
})
}

View File

@ -21,6 +21,7 @@ import {
migrateBufferFileToLibrary,
NOTES_DIR_NAME
} from './file-library';
import { registerProtocol, registerProtocolBeforeAppReady } from "./protocol.js"
// The built directory structure
@ -401,9 +402,14 @@ function registerAlwaysOnTop() {
}
}
// register heynote-file:// protocol
registerProtocolBeforeAppReady()
app.whenReady().then(createWindow).then(async () => {
initFileLibrary(win).then(() => {
setupFileLibraryEventHandlers()
// set up handlers for heynote-file:// protocol
registerProtocol(fileLibrary)
})
initializeAutoUpdate(win)
registerGlobalHotkey()

34
electron/main/protocol.js Normal file
View File

@ -0,0 +1,34 @@
import { protocol } from "electron"
import * as path from "node:path"
import * as jetpack from "fs-jetpack"
import * as mimetypes from "mime-types"
export function registerProtocolBeforeAppReady() {
protocol.registerSchemesAsPrivileged([{
scheme: "heynote-file",
privileges: { standard: true, secure: true, supportFetchAPI: true },
}])
//console.log("registering heynote-file:// as privileged")
}
export function registerProtocol(fileLibrary) {
//console.log("registering handler for heynote-file://")
protocol.handle("heynote-file", async (request) => {
//console.log("got request:", request)
// request.url like: heynote-file://image/2026-01-15T21%3A46%3A39.824Z.png
const url = new URL(request.url);
const encodedPath = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname
const filename = decodeURIComponent(encodedPath)
const filePath = path.join(fileLibrary.imagesBasePath, filename);
const data = await jetpack.readAsync(filePath, "buffer")
if (!data) {
return new Response("Not found", { status: 404 })
}
const contentType = mimetypes.lookup(filePath) || "application/octet-stream"
return new Response(data, { headers: { "Content-Type": contentType } })
})
}

99
electron/main/ripgrep.js Normal file
View File

@ -0,0 +1,99 @@
import { rgPath } from "@vscode/ripgrep"
import { spawn } from "node:child_process"
import { once } from "node:events"
import readline from "node:readline"
import { IMAGE_REGEX_RIPGREP, IMAGE_REGEX } from "@/src/common/constants.js"
import { parseImagesFromString } from "@/src/editor/image/image-parsing.js"
// If @vscode/ripgrep is in an .asar file, then the binary is unpacked.
const rgDiskPath = rgPath.replace(/app\.asar/, "app.asar.unpacked")
export async function runRipgrep(args, cwd, onLine) {
//console.log("cwd:", process.cwd(), "args:", args)
let stdout = ""
let stderr = ""
const rg = spawn(rgDiskPath, args, {stdio: ["ignore", "pipe", "pipe"], cwd:cwd})
rg.stdout.setEncoding("utf8")
rg.stderr.setEncoding("utf8")
const rl = readline.createInterface({ input: rg.stdout })
rl.on("line", (line) => {
if (!line) return
//const obj = JSON.parse(line)
//console.log("line:", line)
stdout += line + "\n"
if (onLine) {
onLine(line)
}
})
//rg.stdout.on("data", (d) => {
// console.log("data:", d)
// stdout += d
//})
rg.stderr.on("data", (d) => {
stderr += d
})
const [code, signal] = await once(rg, "close")
// ripgrep exits with code 1 if there were not hits
if (code !== 0 && code !== 1) {
const err = new Error(`Command failed: ${rgDiskPath}}`)
err.code = code
err.signal = signal
err.stdout = stdout
err.stderr = stderr
throw err
}
return { code, signal, stdout, stderr }
}
/*
export async function searchLibrary(library, query) {
if (!library.basePath) {
throw Error("library.basePath not set, is library initialized?")
}
const cmdResult = await runRipgrep(["--byte-offset", query], library.basePath)
const results = []
for (let line of cmdResult.stdout.split("\n")) {
if (line === "") {
continue
}
if (!line.startsWith(library.basePath + "/" )) {
console.error("Unexpected outout from ripgrep:" + line)
}
line = line.substr(library.basePath.length + 1)
const idx = line.indexOf(":")
results.push([line.substr(0, idx), line.substr(idx + 1)])
}
return results
}*/
export async function getImgReferences(basePath) {
if (!basePath) {
throw Error("basePath not set, is library initialized?")
}
const imageFiles = []
const onLine = (line) => {
const data = JSON.parse(line)
switch (data.type) {
case "match":
const matchedLine = data.data.lines.text
for (const image of parseImagesFromString(matchedLine)) {
const file = decodeURIComponent(image.file)
if (!file.startsWith("heynote-file://image/")) {
console.error("Unknown image path: " + image.file)
continue
}
imageFiles.push(file.substring("heynote-file://image/".length))
}
}
}
const result = await runRipgrep(["--json", IMAGE_REGEX_RIPGREP], basePath, onLine)
return imageFiles
}

View File

@ -104,6 +104,10 @@ contextBridge.exposeInMainWorld("heynote", {
return await ipcRenderer.invoke("buffer:close", path)
},
async saveImage(blob) {
return await ipcRenderer.invoke("library:saveImage", blob)
},
_onChangeCallbacks: {},
addOnChangeCallback(path, callback) {
// register a callback to be called when the buffer content changes for a specific file

7593
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "Heynote",
"version": "2.7.0",
"version": "2.8.0",
"main": "dist-electron/main/index.js",
"description": "A dedicated scratch pad",
"author": "Jonatan Heyman (https://heyman.info)",
@ -21,14 +21,16 @@
},
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && electron-builder -c electron-builder.json5",
"prebuild": "vue-tsc --noEmit && vite build",
"build": "vue-tsc --noEmit && vite build && node scripts/electron/prepare-rg-universal.js && electron-builder -c electron-builder.json5",
"prebuild": "vue-tsc --noEmit && vite build && node scripts/electron/prepare-rg-universal.js",
"preview": "vite preview",
"build_grammar": "lezer-generator src/editor/lang-heynote/heynote.grammar -o src/editor/lang-heynote/parser.js",
"webapp:dev": "vite webapp",
"webapp:build": "vite build webapp",
"test": "playwright test",
"test": "playwright test --grep-invert @e2e",
"test:e2e": "playwright test --grep @e2e",
"test:ui": "playwright test --ui",
"test:main": "vitest",
"postinstall": "patch-package"
},
"devDependencies": {
@ -50,9 +52,9 @@
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.5.11",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.8",
"@codemirror/view": "^6.39.10",
"@electron/asar": "^3.2.2",
"@lezer/generator": "^1.5.1",
"@lezer/markdown": "^1.4.2",
@ -62,12 +64,15 @@
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^5.2.3",
"codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-mermaid": "^0.5.0",
"debounce": "^1.2.1",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"electron": "^39.3.0",
"electron-builder": "^26.4.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.6.2",
"fabric": "^7.1.0",
"fs-jetpack": "^5.1.0",
"heynote-lang-mathjs": "^1.0.1",
"lezer-elixir": "^1.1.2",
"patch-package": "^8.0.1",
"prettier": "^3.3.2",
@ -78,14 +83,18 @@
"vite": "^6.3.5",
"vite-plugin-electron": "^0.11.1",
"vite-plugin-electron-renderer": "^0.11.4",
"vitest": "^2.1.9",
"vue": "^3.5.13",
"vue-tsc": "^2.2.10",
"vuedraggable": "^4.1.0"
},
"dependencies": {
"@sindresorhus/slugify": "^2.2.1",
"@vscode/ripgrep": "^1.17.0",
"electron-log": "^5.0.1",
"fuzzysort": "^3.0.2",
"mime-types": "^3.0.2",
"npm": "^11.8.0",
"pinia": "^2.1.7",
"pinyin-pro": "^3.27.0",
"semver": "^7.6.3"

View File

@ -1,8 +1,8 @@
diff --git a/node_modules/@codemirror/view/dist/index.js b/node_modules/@codemirror/view/dist/index.js
index 5ddd730..9254811 100644
index 9d78d57..9b397ff 100644
--- a/node_modules/@codemirror/view/dist/index.js
+++ b/node_modules/@codemirror/view/dist/index.js
@@ -8918,7 +8918,7 @@ function runHandlers(map, event, view, scope) {
@@ -8944,7 +8944,7 @@ function runHandlers(map, event, view, scope) {
// Ctrl-Alt may be used for AltGr on Windows
!(browser.windows && event.ctrlKey && event.altKey) &&
// Alt-combinations on macOS tend to be typed characters

View File

@ -12,77 +12,77 @@ process.env["HEYNOTE_TESTS"] = "1"
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */
//workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? undefined : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? [['github'], ['html']] : 'list',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
testDir: './tests/playwright',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */
//workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? undefined : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? [['github'], ['html']] : 'list',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
locale: 'en-GB',
},
locale: 'en-GB',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
contextOptions: {
permissions: ['clipboard-read','clipboard-write'],
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
contextOptions: {
permissions: ['clipboard-read', 'clipboard-write'],
},
},
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npx vite --port=3000 webapp',
url: 'http://localhost:3000',
timeout: 10 * 1000,
reuseExistingServer: !process.env.CI,
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npx vite --port=3000 webapp',
url: 'http://localhost:3000',
timeout: 10 * 1000,
reuseExistingServer: !process.env.CI,
},
});

View File

@ -40,7 +40,7 @@ onmessage = (event) => {
//let startTime = performance.now()
guessLang.runModel(content).then((result) => {
//const duration = performance.now() - startTime
console.log("Guessing language done:", result, result[0]?.languageId, result[0]?.confidence)
//console.log("Guessing language done:", result, result[0]?.languageId, result[0]?.confidence)
//console.log("Guessing language took", duration, "ms")
if (result.length > 0) {

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,65 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const rgModuleRoot = path.dirname(
require.resolve("@vscode/ripgrep/package.json")
);
const postinstallPath = path.join(rgModuleRoot, "lib", "postinstall.js");
const downloadPath = path.join(rgModuleRoot, "lib", "download.js");
const postinstallSource = fs.readFileSync(postinstallPath, "utf8");
const versionMatch = postinstallSource.match(/const VERSION = '([^']+)'/);
if (!versionMatch) {
throw new Error("prepare-rg-universal: failed to read ripgrep version");
}
const rgVersion = versionMatch[1];
const download = require(downloadPath);
async function downloadRg(target) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "heynote-rg-"));
await download({
version: rgVersion,
target,
destDir: tempDir,
force: true,
token: process.env.GITHUB_TOKEN,
});
return path.join(tempDir, "rg");
}
async function main() {
if (process.platform !== "darwin") {
return;
}
const x64Path = await downloadRg("x86_64-apple-darwin");
const arm64Path = await downloadRg("aarch64-apple-darwin");
const destPath = path.join(rgModuleRoot, "bin", "rg");
const lipoResult = spawnSync(
"lipo",
["-create", "-output", destPath, x64Path, arm64Path],
{ stdio: "inherit" }
);
if (lipoResult.status !== 0) {
throw new Error("prepare-rg-universal: lipo failed");
}
fs.chmodSync(destPath, 0o755);
const rgVersionResult = spawnSync(destPath, ["--version"], {
stdio: "inherit",
});
if (rgVersionResult.status !== 0) {
throw new Error("prepare-rg-universal: rg --version failed");
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -33,3 +33,18 @@ export const TITLE_BAR_BG_LIGHT = "#f3f2f2"
export const TITLE_BAR_BG_LIGHT_BLURRED = "#e7e7e7"
export const TITLE_BAR_BG_DARK = "#1b1c1d"
export const TITLE_BAR_BG_DARK_BLURRED = "#121313"
export const IMAGE_MIME_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"image/apng",
"image/avif",
"image/bmp",
"image/tiff",
]
export const IMAGE_REGEX = /<∞img;([^∞>]*)∞>/g
export const IMAGE_REGEX_RIPGREP = "<∞img;([^∞>]*)∞>"

View File

@ -1,4 +1,5 @@
<script>
import { toRaw } from 'vue'
import { mapState, mapActions } from 'pinia'
import { mapWritableState, mapStores } from 'pinia'
@ -8,6 +9,7 @@
import { useEditorCacheStore } from '../stores/editor-cache'
import { OPEN_SETTINGS_EVENT, MOVE_BLOCK_EVENT, CHANGE_BUFFER_EVENT } from '@/src/common/constants'
import { setImageFile } from "@/src/editor/image/image-parsing.js"
import StatusBar from './StatusBar.vue'
import Editor from './Editor.vue'
@ -18,6 +20,7 @@
import NewBuffer from './NewBuffer.vue'
import EditBuffer from './EditBuffer.vue'
import TabBar from './tabs/TabBar.vue'
import DrawImageModal from './draw/DrawImageModal.vue'
export default {
components: {
@ -30,6 +33,7 @@
NewBuffer,
EditBuffer,
TabBar,
DrawImageModal,
},
data() {
@ -91,6 +95,7 @@
showEditBuffer(value) { this.dialogWatcher(value) },
showMoveToBufferSelector(value) { this.dialogWatcher(value) },
showCommandPalette(value) { this.dialogWatcher(value) },
showDrawImageModal(value) { this.dialogWatcher(value) },
currentBufferPath() {
this.focusEditor()
@ -112,6 +117,9 @@
"showEditBuffer",
"showMoveToBufferSelector",
"showCommandPalette",
"showDrawImageModal",
"drawImageUrl",
"drawImageId",
"isFullscreen",
]),
...mapState(useSettingsStore, [
@ -119,7 +127,7 @@
]),
dialogVisible() {
return this.showLanguageSelector || this.showBufferSelector || this.showCreateBuffer || this.showEditBuffer || this.showMoveToBufferSelector || this.showCommandPalette || this.showSettings
return this.showLanguageSelector || this.showBufferSelector || this.showCreateBuffer || this.showEditBuffer || this.showMoveToBufferSelector || this.showCommandPalette || this.showDrawImageModal || this.showSettings
},
editorInert() {
@ -145,6 +153,7 @@
"closeBufferSelector",
"openBuffer",
"closeMoveToBufferSelector",
"closeDrawImageModal",
"deleteBuffer",
"focusEditor",
]),
@ -194,6 +203,32 @@
this.editorCacheStore.moveCurrentBlockToOtherEditor(path)
this.closeMoveToBufferSelector()
},
async onSaveDrawImage(imageId, imageDataUrl) {
try {
const editor = toRaw(this.heynoteStore.currentEditor)
if (!editor?.view) {
console.error("No active editor available to update image")
return
}
const response = await fetch(imageDataUrl)
const blob = await response.blob()
const filename = await window.heynote.buffer.saveImage({
data: new Uint8Array(await blob.arrayBuffer()),
mime: blob.type,
})
if (!filename) {
console.error("Failed to save image data")
return
}
const imageUrl = "heynote-file://image/" + encodeURIComponent(filename)
setImageFile(editor.view, imageId, imageUrl)
} catch (error) {
console.error("Failed to save drawn image", error)
} finally {
this.closeDrawImageModal()
}
},
},
}
@ -262,6 +297,13 @@
v-if="showEditBuffer"
@close="closeDialog"
/>
<DrawImageModal
v-if="showDrawImageModal"
:imageUrl="drawImageUrl"
:imageId="drawImageId"
@close="closeDrawImageModal"
@save="onSaveDrawImage"
/>
<ErrorMessages />
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -97,7 +97,7 @@
ref="keys"
/>
</div>
<div class="field">
<div class="field command-field">
<label>Command</label>
<AutoComplete
dropdown

View File

@ -43,9 +43,7 @@
event.preventDefault()
//console.log("event", event, event.code, keyName(event))
if (event.key === "Enter") {
this.$emit("enter")
} else if (event.key === "Escape") {
if (event.key === "Escape") {
if (this.keys.length > 0) {
this.keys = []
} else {

View File

@ -2,3 +2,4 @@
@use "font"
@use "base"
@use "autocomplete"
@use "image"

View File

@ -14,6 +14,7 @@
--tab-active-bg: #fff
--tab-active-bg-blurred: #f4f4f4
--window-border-color: #6b6b6b
--draw-element-handle-color: #007bff
:root[theme='dark']
--status-bar-background: #0e1217
@ -25,6 +26,7 @@
--tab-active-bg: #213644
--tab-active-bg-blurred: #1b2b36
--window-border-color: #000
--draw-element-handle-color: #007bff
html
margin: 0
@ -58,6 +60,8 @@ input, button
height: 100%
.cm-editor
height: 100%
&.resizing-image
cursor: nwse-resize
#syntaxTree
height: 20%

154
src/css/image.sass Normal file
View File

@ -0,0 +1,154 @@
@use "include" as *
.heynote-image
--snapped-outline-color: var(--highlight-color)
--outline-color: #2482ce
--handle-color: #ccc
--image-border-color: #c9c9c9
+dark-mode
//--outline-color: var(--highlight-color)
//--outline-color: #b3bfcc
//--outline-color: #17242b
--outline-color: #0060c7
--snapped-outline-color: #39a363
//--outline-color: #549a70
--handle-color: #192736
--image-border-color: #252525
padding: 6px 2px
display: inline-block
position: relative
vertical-align: middle
&.folded
padding: 0
//vertical-align: middle
&.selected
padding: 0
.resize-handle
display: none
.inner
position: relative
border: 1px solid var(--image-border-color)
&:hover
//border: 1px solid var(--outline-color)
.buttons-container
opacity: 1
img
display: block
max-width: 100%
min-height: 16px
min-width: 16px
.highlight-border
display: none
position: absolute
top: -2px
bottom: -2px
left: -2px
right: -2px
border: 3px solid var(--outline-color)
.buttons-container
position: absolute
top: 0
left: 0
bottom: 0
right: 0
display: flex
padding: 7px
align-items: flex-start
justify-content: left
opacity: 0
//transition: opacity 100ms ease-in-out
overflow: hidden
container-type: inline-size
button
height: 24px
font-size: 12px
background-color: #646e71
color: #fff
opacity: 0.5
transition: opacity 200ms
//background-color: #51595c
background-image: url("@/assets/icons/copy-dark.svg")
background-size: 13px
background-position: 6px center
background-repeat: no-repeat
padding: 3px 7px 3px 22px
border: none
border-radius: 3px
cursor: pointer
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.3)
min-width: 0
white-space: nowrap
overflow: hidden
margin-right: 4px
&:hover
background-color: #51595c
opacity: 1.0
@container (max-width: 100px)
padding: 3px 12px
span
display: none
@container (max-width: 48px)
display: none
&.draw
background-image: url("@/assets/icons/pencil-white.svg")
&.selected
--handle-color: var(--outline-color)
+dark-mode
--handle-color: var(--outline-color)
.inner
//border: 2px solid var(--outline-color)
.highlight-border
display: block
.resize-handle
opacity: 1.0
.icon
background-image: url("@/assets/icons/resize-handle-se-dark.png")
&:hover
.resize-handle
opacity: 1.0
&:hover
.resize-handle
opacity: 0.5
&:hover
opacity: 1.0
.resize-handle
opacity: 0
transition: opacity 200ms
width: 0
height: 0
position: absolute
right: 2px
bottom: 6px
border-bottom: 10px solid var(--handle-color)
border-right: 10px solid var(--handle-color)
border-left: 10px solid transparent
border-top: 10px solid transparent
cursor: nwse-resize
z-index: 10
.icon
position: absolute
top: -4px
left: -4px
background-image: url("@/assets/icons/resize-handle-se-light.png")
background-size: 100%
width: 12px
height: 12px
+dark-mode
background-image: url("@/assets/icons/resize-handle-se-dark.png")
&.resizing
.inner .buttons-container
display: none
.resize-handle
opacity: 1
transition: none
&.snapped
--outline-color: var(--snapped-outline-color)

View File

@ -1,4 +1,5 @@
import { Annotation } from "@codemirror/state"
import { foldEffect, unfoldEffect } from "@codemirror/language"
export const heynoteEvent = Annotation.define()
export const LANGUAGE_CHANGE = "heynote-change"
@ -12,6 +13,7 @@ export const APPEND_BLOCK = "heynote-append-block"
export const SET_FONT = "heynote-set-font"
export const SEARCH_SETTINGS_UPDATED = "heynote-search-settings-updates"
export const UPDATE_CREATED = "heynote-update-created"
export const IMAGE_RESIZE = "heynote-image-resize"
// This function checks if any of the transactions has the given Heynote annotation
@ -26,3 +28,7 @@ export function transactionsHasAnnotationsAny(transactions, annotations) {
export function transactionsHasHistoryEvent(transactions) {
return transactions.some(tr => tr.isUserEvent("undo") || tr.isUserEvent("redo"))
}
export function transactionHasFoldEffect(transaction) {
return transaction?.effects.some(e => e.is(foldEffect) || e.is(unfoldEffect))
}

View File

@ -44,23 +44,23 @@ export const blockState = StateField.define({
export function getActiveNoteBlock(state) {
// find which block the cursor is in
const range = state.selection.asSingle().ranges[0]
return state.facet(blockState).find(block => block.range.from <= range.head && block.range.to >= range.head)
return state.field(blockState).find(block => block.range.from <= range.head && block.range.to >= range.head)
}
export function getFirstNoteBlock(state) {
return state.facet(blockState)[0]
return state.field(blockState)[0]
}
export function getLastNoteBlock(state) {
return state.facet(blockState)[state.facet(blockState).length - 1]
return state.field(blockState)[state.field(blockState).length - 1]
}
export function getNoteBlockFromPos(state, pos) {
return state.facet(blockState).find(block => block.range.from <= pos && block.range.to >= pos)
return state.field(blockState).find(block => block.range.from <= pos && block.range.to >= pos)
}
export function getNoteBlocksBetween(state, from, to) {
return state.facet(blockState).filter(block => block.range.from < to && block.range.to >= from)
return state.field(blockState).filter(block => block.range.from < to && block.range.to >= from)
}
export function getNoteBlocksFromRangeSet(state, ranges) {
@ -98,7 +98,7 @@ const noteBlockWidget = () => {
const decorate = (state) => {
const widgets = [];
state.facet(blockState).forEach(block => {
state.field(blockState).forEach(block => {
let delimiter = block.delimiter
let deco = Decoration.replace({
widget: new NoteBlockStart(delimiter.from === 0 ? true : false),
@ -143,7 +143,7 @@ const noteBlockWidget = () => {
function atomicRanges(view) {
let builder = new RangeSetBuilder()
view.state.facet(blockState).forEach(block => {
view.state.field(blockState).forEach(block => {
builder.add(
block.delimiter.from,
block.delimiter.to,
@ -182,31 +182,33 @@ const blockLayer = layer({
function rangesOverlaps(range1, range2) {
return range1.from <= range2.to && range2.from <= range1.to
}
const blocks = view.state.facet(blockState)
const blocks = view.state.field(blockState)
blocks.forEach(block => {
// make sure the block is visible
if (!view.visibleRanges.some(range => rangesOverlaps(block.content, range))) {
idx++;
return
}
// view.coordsAtPos returns null if the editor is not visible
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom
// Use line box geometry so inline widgets (like images) expand the block height.
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from)
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to)
const fromCoordsTop = view.lineBlockAt(fromPos)?.top
const toLine = view.state.doc.lineAt(toPos)
const toLinePos = toLine.length === 0 ? toLine.from : Math.max(fromPos, Math.min(toPos, block.content.to))
let toCoordsBottom = view.lineBlockAt(toLinePos)?.bottom
if (idx === blocks.length - 1) {
// Calculate how much extra height we need to add to the last block
let extraHeight = view.viewState.editorHeight - (
view.defaultLineHeight + // when scrolling furthest down, one line is still shown at the top
view.documentPadding.top +
8
11
)
toCoordsBottom += extraHeight
}
markers.push(new RectangleMarker(
idx++ % 2 == 0 ? "block-even" : "block-odd",
0,
// Change "- 0 - 6" to "+ 1 - 6" on the following line, and "+ 1 + 13" to "+2 + 13" on the line below,
// in order to make the block backgrounds to have no gap between them
fromCoordsTop - (view.documentTop - view.documentPadding.top) - 1 - 6,
fromCoordsTop - 2,
null, // width is set to 100% in CSS
(toCoordsBottom - fromCoordsTop) + 15,
))
@ -230,7 +232,7 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr) => {
}
// if the transaction is a search and replace, we want to protect all block delimiters
if (tr.annotations.some(a => a.value === "input.replace" || a.value === "input.replace.all")) {
const blocks = tr.startState.facet(blockState)
const blocks = tr.startState.field(blockState)
blocks.forEach(block => {
protect.push(block.delimiter.from, block.delimiter.to)
})
@ -264,7 +266,7 @@ const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr) =
export function getBlockLineFromPos(state, pos) {
const line = state.doc.lineAt(pos)
const block = state.facet(blockState).find(block => block.content.from <= line.from && block.content.to >= line.from)
const block = state.field(blockState).find(block => block.content.from <= line.from && block.content.to >= line.from)
if (block) {
const firstBlockLine = state.doc.lineAt(block.content.from).number
return {
@ -298,7 +300,7 @@ export const blockLineNumbers = lineNumbers({
function getSelectionSize(state, sel) {
let count = 0
let numBlocks = 0
for (const block of state.facet(blockState)) {
for (const block of state.field(blockState)) {
if (sel.from <= block.range.to && sel.to > block.range.from) {
count += Math.min(sel.to, block.content.to) - Math.max(sel.from, block.content.from)
numBlocks++
@ -369,7 +371,7 @@ const updateCreatedOnEmptyBlock = () => {
}
const changes = []
const now = new Date()
const startBlocks = tr.startState.facet(blockState)
const startBlocks = tr.startState.field(blockState)
const emptyBlocks = []
// snapshot empty blocks from the start state; we only update timestamps on first insert.

View File

@ -0,0 +1,281 @@
import { EditorSelection } from "@codemirror/state"
import { EditorView } from "@codemirror/view"
import { IMAGE_MIME_TYPES } from "../../common/constants.js"
import { createImageTag } from "../image/image-parsing.js"
import { imageIsSelected, imageState } from "../image/image.js"
import { serializeToText, serializeToHeynote, serializeToHtml, unserializeFromHeynote } from "./serialize.js"
function copiedRange(state) {
let content = [], ranges = []
for (let range of state.selection.ranges) {
if (!range.empty) {
content.push(state.sliceDoc(range.from, range.to))
ranges.push(range)
}
}
if (ranges.length == 0) {
// if all ranges are empty, we want to copy each whole (unique) line for each selection
const copiedLines = []
for (let range of state.selection.ranges) {
if (range.empty) {
const line = state.doc.lineAt(range.head)
const lineContent = state.sliceDoc(line.from, line.to)
if (!copiedLines.includes(line.from)) {
content.push(lineContent)
ranges.push(range)
copiedLines.push(line.from)
}
}
}
}
return { text: content.join(state.lineBreak), ranges }
}
/**
* Set up event handlers for the browser's copy & cut events, that will replace block separators with newlines
*/
export const heynoteCopyCut = (editor) => {
let copy, cut
copy = cut = async (event, view) => {
event.preventDefault()
await copyCut(editor.view, event.type == "cut", editor)
}
return EditorView.domEventHandlers({
copy,
cut,
})
}
const toBlob = (text, type) => new Blob([text], {type:type})
const copyCut = async (view, cut, editor) => {
let { text, ranges } = copiedRange(view.state)
//text = text.replaceAll(BLOCK_DELIMITER_REGEX, "\n\n")
const formats = {
"text/plain": toBlob(serializeToText(text), "text/plain"),
"text/html": toBlob(await serializeToHtml(text), "text/html"),
}
if (ClipboardItem.supports("web text/heynote")) {
formats["web text/heynote"] = toBlob(serializeToHeynote(text), "web text/heynote")
}
await navigator.clipboard.write([
new ClipboardItem(formats)
])
if (cut && !view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
})
}
// if we're in Emacs mode, we want to exit mark mode in case we're in it
editor.selectionMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && !cut) {
const newSelection = EditorSelection.create(
view.state.selection.ranges.map(r => EditorSelection.cursor(r.head)),
view.state.selection.mainIndex,
)
view.dispatch(view.state.update({
selection: newSelection,
}))
}
return true
}
function doPaste(view, input) {
let { state } = view, changes, i = 1, text = state.toText(input)
let byLine = text.lines == state.selection.ranges.length
if (byLine) {
changes = state.changeByRange(range => {
let line = text.line(i++)
return {
changes: { from: range.from, to: range.to, insert: line.text },
range: EditorSelection.cursor(range.from + line.length)
}
})
} else {
changes = state.replaceSelection(text)
}
view.dispatch(changes, {
userEvent: "input.paste",
scrollIntoView: true
})
}
/**
* @param editor Editor instance
* @returns CodeMirror command that copies the current selection to the clipboard
*/
export function copyCommand(editor) {
return (view) => {
for (const image of view.state.field(imageState)) {
if (imageIsSelected(image, view.state.selection.main)) {
copyImage(image.file)
return true
}
}
return copyCut(view, false, editor)
}
}
/**
* @param editor Editor instance
* @returns CodeMirror command that cuts the current selection to the clipboard
*/
export function cutCommand(editor) {
return (view) => copyCut(view, true, editor)
}
/**
* CodeMirror command that pastes the plain text clipboard content into the editor
*/
export async function pasteAsTextCommand(view) {
return doPaste(view, await navigator.clipboard.readText())
}
/**
* CodeMirror command that pastes the clipboard content into the editor
*/
export async function pasteCommand(/** @type {EditorView} */view) {
const { dispatch, state } = view
const items = await navigator.clipboard.read()
const canSaveImages = typeof window?.heynote?.buffer?.saveImage === "function"
for (const item of items) {
//console.log("item:", item, item.types)
if (item.types.includes("web text/heynote")) {
const blob = await item.getType("web text/heynote")
doPaste(view, unserializeFromHeynote(await blob.text()))
return
//} else if (itemType == "text/html") {
// const blob = await item.getType("text/html")
// console.log("raw html:", await blob.text())
} else {
for (const itemType of item.types) {
//console.log(itemType, ":", await item.getType(itemType))
// handle images data
if (IMAGE_MIME_TYPES.includes(itemType)) {
if (!canSaveImages) {
continue
}
const blob = await item.getType(itemType)
//console.log("image data:", blob.arrayBuffer())
// get image dimensions
const img = new Image();
const url = URL.createObjectURL(blob);
await new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = url;
});
URL.revokeObjectURL(url);
let width = img.naturalWidth
let height = img.naturalHeight
const aspect = width / height
const filename = await window.heynote.buffer.saveImage({
data: new Uint8Array(await blob.arrayBuffer()),
mime: blob.type,
})
//console.log("saved:", filename)
if (filename) {
const image = {
id: crypto.randomUUID(),
file: "heynote-file://image/" + encodeURIComponent(filename),
width: width,
height: height,
}
if ((height / window.devicePixelRatio) > 200) {
image.displayHeight = 200
image.displayWidth = 200 * aspect
}
let imageTag = createImageTag(image)
// if we're not on an empty line, insert on a new line after the current
let insertAt = state.selection.main
//const line = state.doc.lineAt(state.selection.main.head)
//if (line.to > line.from) {
// imageTag = "\n" + imageTag
// insertAt = {from:line.to, to: line.to}
//}
dispatch(state.update({
changes: {
from: insertAt.from,
to: insertAt.to,
insert: imageTag,
},
selection: EditorSelection.cursor(insertAt.from + imageTag.length),
}, {
scrollIntoView: true,
userEvent: "input",
}))
return
}
}
}
}
}
return doPaste(view, await navigator.clipboard.readText())
}
export async function copyImage(url) {
const res = await fetch(url, { mode: "cors" })
if (!res.ok) {
throw new Error(`Fetch failed: ${res.status}`)
}
const blob = await res.blob()
if (!blob.type.startsWith("image/")) {
throw new Error(`Not an image content type. Got: ${blob.type}`)
}
if (ClipboardItem.supports(blob.type)) {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
} else {
// convert image to PNG through a canvas
const img = new Image()
const blobUrl = URL.createObjectURL(blob)
const pngBlob = await new Promise((resolve, reject) => {
img.onload = () => {
const canvas = document.createElement("canvas")
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext("2d")
ctx.drawImage(img, 0, 0)
canvas.toBlob((result) => {
if (result) {
resolve(result)
} else {
reject(new Error("Failed to convert image to PNG"))
}
}, "image/png")
}
img.onerror = () => reject(new Error("Failed to decode image"))
img.src = blobUrl
}).finally(() => {
URL.revokeObjectURL(blobUrl)
})
await navigator.clipboard.write([new ClipboardItem({ "image/png": pngBlob })])
}
}

View File

@ -0,0 +1,105 @@
import { EditorView } from "@codemirror/view"
import { EditorSelection } from "@codemirror/state"
import { createImageTag } from "../image/image-parsing.js"
const MAX_DISPLAY_HEIGHT = 200
const buildImageTagFromFile = async (file) => {
if (!file.type.startsWith("image/")) {
return null
}
if (typeof window?.heynote?.buffer?.saveImage !== "function") {
return null
}
const img = new Image()
const url = URL.createObjectURL(file)
await new Promise((resolve, reject) => {
img.onload = () => resolve()
img.onerror = reject
img.src = url
})
URL.revokeObjectURL(url)
const width = img.naturalWidth
const height = img.naturalHeight
const aspect = width / height
const filename = await window.heynote.buffer.saveImage({
data: new Uint8Array(await file.arrayBuffer()),
mime: file.type,
})
if (!filename) {
return null
}
const image = {
id: crypto.randomUUID(),
file: "heynote-file://image/" + encodeURIComponent(filename),
width,
height,
}
if ((height / window.devicePixelRatio) > MAX_DISPLAY_HEIGHT) {
image.displayHeight = MAX_DISPLAY_HEIGHT
image.displayWidth = MAX_DISPLAY_HEIGHT * aspect
}
return createImageTag(image)
}
const insertImageTags = (view, pos, tags) => {
const insert = tags.join("")
view.dispatch(view.state.update({
changes: { from: pos, to: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
scrollIntoView: true,
userEvent: "input.drop",
}))
}
export const heynoteDropPaste = () => {
const handleDrop = async (event, view) => {
const files = Array.from(event.dataTransfer?.files || [])
if (!files.length || view.state.readOnly) {
return false
}
event.preventDefault()
event.stopPropagation()
view.focus()
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
?? view.state.selection.main.head
const tags = []
for (const file of files) {
const tag = await buildImageTagFromFile(file)
if (tag) {
tags.push(tag)
}
}
if (!tags.length) {
return false
}
insertImageTags(view, pos, tags)
return true
}
const handleDragOver = (event) => {
const files = Array.from(event.dataTransfer?.files || [])
if (!files.length) {
return false
}
event.preventDefault()
return true
}
return EditorView.domEventHandlers({
dragover: handleDragOver,
drop: handleDrop,
})
}

View File

@ -0,0 +1,76 @@
import { BLOCK_DELIMITER_REGEX } from "../block/block-parsing"
import { WIDGET_TAG_REGEX } from "../image/image-parsing"
import { parseImagesFromString, createImageTag } from "../image/image-parsing"
export function serializeToText(text) {
return text.replaceAll(BLOCK_DELIMITER_REGEX, "\n\n").replaceAll(WIDGET_TAG_REGEX, "")
}
export async function serializeToHtml(text) {
const images = parseImagesFromString(text)
let newText = ""
let pos = 0
for (const image of images) {
newText += text.substring(pos, image.from)
const imageData = await getImageData(image)
const width = image.displayWidth ? image.displayWidth : image.width / window.devicePixelRatio
const height = image.displayHeight ? image.displayHeight : image.height / window.devicePixelRatio
newText += `<img src="${imageData}" width="${width}" height="${height}">`
pos = image.to
}
newText += text.substring(pos)
newText = newText.replaceAll(BLOCK_DELIMITER_REGEX, "\n\n").replaceAll("\n", "<br>")
return newText
}
export function serializeToHeynote(text) {
return text
}
/**
* Both block separators and images are preserved, but new UUIDs are generated
* for the image tags
*/
export function unserializeFromHeynote(text) {
// generate new UUIDs for all image tags, other
const images = parseImagesFromString(text)
let newText = ""
let pos = 0
for (const image of images) {
newText += text.substring(pos, image.from)
image.id = crypto.randomUUID()
newText += createImageTag(image)
pos = image.to
}
newText += text.substring(pos)
return newText
}
async function imageUrlToDataUrl(url) {
const response = await fetch(url)
const blob = await response.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error(`Failed to read image data from ${url}`))
reader.onload = () => resolve(String(reader.result))
reader.readAsDataURL(blob)
})
}
async function getImageData(image) {
const escapeHtmlAttr = (value) => value
.replaceAll("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
try {
return escapeHtmlAttr(await imageUrlToDataUrl(image.file))
} catch (err) {
console.error(err)
return escapeHtmlAttr(image.file)
}
}

View File

@ -1,32 +0,0 @@
@searchKeymap
@search
### Commands
@findNext
@findPrevious
@selectMatches
@selectSelectionMatches
@selectNextOccurrence
@replaceNext
@replaceAll
@openSearchPanel
@closeSearchPanel
@gotoLine
### Search Query
@SearchQuery
@getSearchQuery
@setSearchQuery
@searchPanelOpen
### Cursor
@SearchCursor
@RegExpCursor
### Selection matching
@highlightSelectionMatches

View File

@ -1,123 +0,0 @@
import {Text, TextIterator, codePointAt, codePointSize, fromCodePoint} from "@codemirror/state"
const basicNormalize: (string: string) => string = typeof String.prototype.normalize == "function"
? x => x.normalize("NFKD") : x => x
/// A search cursor provides an iterator over text matches in a
/// document.
export class SearchCursor implements Iterator<{from: number, to: number}>{
private iter: TextIterator
/// The current match (only holds a meaningful value after
/// [`next`](#search.SearchCursor.next) has been called and when
/// `done` is false).
value = {from: 0, to: 0}
/// Whether the end of the iterated region has been reached.
done = false
private matches: number[] = []
private buffer = ""
private bufferPos = 0
private bufferStart: number
private normalize: (string: string) => string
private query: string
/// Create a text cursor. The query is the search string, `from` to
/// `to` provides the region to search.
///
/// When `normalize` is given, it will be called, on both the query
/// string and the content it is matched against, before comparing.
/// You can, for example, create a case-insensitive search by
/// passing `s => s.toLowerCase()`.
///
/// Text is always normalized with
/// [`.normalize("NFKD")`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize)
/// (when supported).
constructor(text: Text, query: string,
from: number = 0, to: number = text.length,
normalize?: (string: string) => string,
private test?: (from: number, to: number, buffer: string, bufferPos: number) => boolean) {
this.iter = text.iterRange(from, to)
this.bufferStart = from
this.normalize = normalize ? x => normalize(basicNormalize(x)) : basicNormalize
this.query = this.normalize(query)
}
private peek() {
if (this.bufferPos == this.buffer.length) {
this.bufferStart += this.buffer.length
this.iter.next()
if (this.iter.done) return -1
this.bufferPos = 0
this.buffer = this.iter.value
}
return codePointAt(this.buffer, this.bufferPos)
}
/// Look for the next match. Updates the iterator's
/// [`value`](#search.SearchCursor.value) and
/// [`done`](#search.SearchCursor.done) properties. Should be called
/// at least once before using the cursor.
next() {
while (this.matches.length) this.matches.pop()
return this.nextOverlapping()
}
/// The `next` method will ignore matches that partially overlap a
/// previous match. This method behaves like `next`, but includes
/// such matches.
nextOverlapping() {
for (;;) {
let next = this.peek()
if (next < 0) {
this.done = true
return this
}
let str = fromCodePoint(next), start = this.bufferStart + this.bufferPos
this.bufferPos += codePointSize(next)
let norm = this.normalize(str)
if (norm.length) for (let i = 0, pos = start;; i++) {
let code = norm.charCodeAt(i)
let match = this.match(code, pos, this.bufferPos + this.bufferStart)
if (i == norm.length - 1) {
if (match) {
this.value = match
return this
}
break
}
if (pos == start && i < str.length && str.charCodeAt(i) == code) pos++
}
}
}
private match(code: number, pos: number, end: number) {
let match: null | {from: number, to: number} = null
for (let i = 0; i < this.matches.length; i += 2) {
let index = this.matches[i], keep = false
if (this.query.charCodeAt(index) == code) {
if (index == this.query.length - 1) {
match = {from: this.matches[i + 1], to: end}
} else {
this.matches[i]++
keep = true
}
}
if (!keep) {
this.matches.splice(i, 2)
i -= 2
}
}
if (this.query.charCodeAt(0) == code) {
if (this.query.length == 1)
match = {from: pos, to: end}
else
this.matches.push(1, pos)
}
if (match && this.test && !this.test(match.from, match.to, this.buffer, this.bufferStart)) match = null
return match
}
declare [Symbol.iterator]: () => Iterator<{from: number, to: number}>
}
if (typeof Symbol != "undefined")
SearchCursor.prototype[Symbol.iterator] = function(this: SearchCursor) { return this }

View File

@ -1,45 +0,0 @@
import {EditorSelection} from "@codemirror/state"
import {EditorView, Command, showDialog} from "@codemirror/view"
/// Command that shows a dialog asking the user for a line number, and
/// when a valid position is provided, moves the cursor to that line.
///
/// Supports line numbers, relative line offsets prefixed with `+` or
/// `-`, document percentages suffixed with `%`, and an optional
/// column position by adding `:` and a second number after the line
/// number.
export const gotoLine: Command = view => {
let {state} = view
let line = String(state.doc.lineAt(view.state.selection.main.head).number)
let {close, result} = showDialog(view, {
label: state.phrase("Go to line"),
input: {type: "text", name: "line", value: line},
focus: true,
submitLabel: state.phrase("go"),
})
result.then(form => {
let match = form && /^([+-])?(\d+)?(:\d+)?(%)?$/.exec((form.elements as any)["line"].value)
if (!match) {
view.dispatch({effects: close})
return
}
let startLine = state.doc.lineAt(state.selection.main.head)
let [, sign, ln, cl, percent] = match
let col = cl ? +cl.slice(1) : 0
let line = ln ? +ln : startLine.number
if (ln && percent) {
let pc = line / 100
if (sign) pc = pc * (sign == "-" ? -1 : 1) + (startLine.number / state.doc.lines)
line = Math.round(state.doc.lines * pc)
} else if (ln && sign) {
line = line * (sign == "-" ? -1 : 1) + startLine.number
}
let docLine = state.doc.line(Math.max(1, Math.min(state.doc.lines, line)))
let selection = EditorSelection.cursor(docLine.from + Math.max(0, Math.min(col, docLine.length)))
view.dispatch({
effects: [close, EditorView.scrollIntoView(selection.from, {y: 'center'})],
selection,
})
})
return true
}

View File

@ -1,193 +0,0 @@
import {Text, TextIterator} from "@codemirror/state"
const empty = {from: -1, to: -1, match: /.*/.exec("")!}
const baseFlags = "gm" + (/x/.unicode == null ? "" : "u")
export interface RegExpCursorOptions {
ignoreCase?: boolean
test?: (from: number, to: number, match: RegExpExecArray) => boolean
}
/// This class is similar to [`SearchCursor`](#search.SearchCursor)
/// but searches for a regular expression pattern instead of a plain
/// string.
export class RegExpCursor implements Iterator<{from: number, to: number, match: RegExpExecArray}> {
declare private iter: TextIterator
declare private re: RegExp
private test?: (from: number, to: number, match: RegExpExecArray) => boolean
private curLine = ""
declare private curLineStart: number
declare private matchPos: number
/// Set to `true` when the cursor has reached the end of the search
/// range.
done = false
/// Will contain an object with the extent of the match and the
/// match object when [`next`](#search.RegExpCursor.next)
/// sucessfully finds a match.
value = empty
/// Create a cursor that will search the given range in the given
/// document. `query` should be the raw pattern (as you'd pass it to
/// `new RegExp`).
constructor(private text: Text, query: string, options?: RegExpCursorOptions,
from: number = 0, private to: number = text.length) {
if (/\\[sWDnr]|\n|\r|\[\^/.test(query)) return new MultilineRegExpCursor(text, query, options, from, to) as any
this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : ""))
this.test = options?.test
this.iter = text.iter()
let startLine = text.lineAt(from)
this.curLineStart = startLine.from
this.matchPos = toCharEnd(text, from)
this.getLine(this.curLineStart)
}
private getLine(skip: number) {
this.iter.next(skip)
if (this.iter.lineBreak) {
this.curLine = ""
} else {
this.curLine = this.iter.value
if (this.curLineStart + this.curLine.length > this.to)
this.curLine = this.curLine.slice(0, this.to - this.curLineStart)
this.iter.next()
}
}
private nextLine() {
this.curLineStart = this.curLineStart + this.curLine.length + 1
if (this.curLineStart > this.to) this.curLine = ""
else this.getLine(0)
}
/// Move to the next match, if there is one.
next() {
for (let off = this.matchPos - this.curLineStart;;) {
this.re.lastIndex = off
let match = this.matchPos <= this.to && this.re.exec(this.curLine)
if (match) {
let from = this.curLineStart + match.index, to = from + match[0].length
this.matchPos = toCharEnd(this.text, to + (from == to ? 1 : 0))
if (from == this.curLineStart + this.curLine.length) this.nextLine()
if ((from < to || from > this.value.to) && (!this.test || this.test(from, to, match))) {
this.value = {from, to, match}
return this
}
off = this.matchPos - this.curLineStart
} else if (this.curLineStart + this.curLine.length < this.to) {
this.nextLine()
off = 0
} else {
this.done = true
return this
}
}
}
declare [Symbol.iterator]: () => Iterator<{from: number, to: number, match: RegExpExecArray}>
}
const flattened = new WeakMap<Text, FlattenedDoc>()
// Reusable (partially) flattened document strings
class FlattenedDoc {
constructor(readonly from: number,
readonly text: string) {}
get to() { return this.from + this.text.length }
static get(doc: Text, from: number, to: number) {
let cached = flattened.get(doc)
if (!cached || cached.from >= to || cached.to <= from) {
let flat = new FlattenedDoc(from, doc.sliceString(from, to))
flattened.set(doc, flat)
return flat
}
if (cached.from == from && cached.to == to) return cached
let {text, from: cachedFrom} = cached
if (cachedFrom > from) {
text = doc.sliceString(from, cachedFrom) + text
cachedFrom = from
}
if (cached.to < to)
text += doc.sliceString(cached.to, to)
flattened.set(doc, new FlattenedDoc(cachedFrom, text))
return new FlattenedDoc(from, text.slice(from - cachedFrom, to - cachedFrom))
}
}
const enum Chunk { Base = 5000 }
class MultilineRegExpCursor implements Iterator<{from: number, to: number, match: RegExpExecArray}> {
private flat: FlattenedDoc
private matchPos
private re: RegExp
private test?: (from: number, to: number, match: RegExpExecArray) => boolean
done = false
value = empty
constructor(private text: Text, query: string, options: RegExpCursorOptions | undefined, from: number, private to: number) {
this.matchPos = toCharEnd(text, from)
this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : ""))
this.test = options?.test
this.flat = FlattenedDoc.get(text, from, this.chunkEnd(from + Chunk.Base))
}
private chunkEnd(pos: number) {
return pos >= this.to ? this.to : this.text.lineAt(pos).to
}
next() {
for (;;) {
let off = this.re.lastIndex = this.matchPos - this.flat.from
let match = this.re.exec(this.flat.text)
// Skip empty matches directly after the last match
if (match && !match[0] && match.index == off) {
this.re.lastIndex = off + 1
match = this.re.exec(this.flat.text)
}
if (match) {
let from = this.flat.from + match.index, to = from + match[0].length
// If a match goes almost to the end of a noncomplete chunk, try
// again, since it'll likely be able to match more
if ((this.flat.to >= this.to || match.index + match[0].length <= this.flat.text.length - 10) &&
(!this.test || this.test(from, to, match))) {
this.value = {from, to, match}
this.matchPos = toCharEnd(this.text, to + (from == to ? 1 : 0))
return this
}
}
if (this.flat.to == this.to) {
this.done = true
return this
}
// Grow the flattened doc
this.flat = FlattenedDoc.get(this.text, this.flat.from, this.chunkEnd(this.flat.from + this.flat.text.length * 2))
}
}
declare [Symbol.iterator]: () => Iterator<{from: number, to: number, match: RegExpExecArray}>
}
if (typeof Symbol != "undefined") {
RegExpCursor.prototype[Symbol.iterator] = MultilineRegExpCursor.prototype[Symbol.iterator] =
function(this: RegExpCursor) { return this }
}
export function validRegExp(source: string) {
try {
new RegExp(source, baseFlags)
return true
} catch {
return false
}
}
function toCharEnd(text: Text, pos: number) {
if (pos >= text.length) return pos
let line = text.lineAt(pos), next
while (pos < line.to && (next = line.text.charCodeAt(pos - line.from)) >= 0xDC00 && next < 0xE000) pos++
return pos
}

View File

@ -1,815 +0,0 @@
import {EditorView, ViewPlugin, ViewUpdate, Command, Decoration, DecorationSet,
runScopeHandlers, KeyBinding,
PanelConstructor, showPanel, Panel, getPanel} from "@codemirror/view"
import {EditorState, StateField, StateEffect, EditorSelection, SelectionRange, StateCommand, Prec,
Facet, Extension, RangeSetBuilder, Text, CharCategory, findClusterBreak,
combineConfig} from "@codemirror/state"
import elt from "crelt"
import {SearchCursor} from "./cursor"
import {RegExpCursor, validRegExp} from "./regexp"
import {gotoLine} from "./goto-line"
import {selectNextOccurrence} from "./selection-match"
export {highlightSelectionMatches} from "./selection-match"
export {SearchCursor, RegExpCursor, gotoLine, selectNextOccurrence}
interface SearchConfig {
/// Whether to position the search panel at the top of the editor
/// (the default is at the bottom).
top?: boolean
/// Whether to enable case sensitivity by default when the search
/// panel is activated (defaults to false).
caseSensitive?: boolean
/// Whether to treat string searches literally by default (defaults to false).
literal?: boolean
/// Controls whether the default query has by-word matching enabled.
/// Defaults to false.
wholeWord?: boolean
/// Used to turn on regular expression search in the default query.
/// Defaults to false.
regexp?: boolean
/// Can be used to override the way the search panel is implemented.
/// Should create a [Panel](#view.Panel) that contains a form
/// which lets the user:
///
/// - See the [current](#search.getSearchQuery) search query.
/// - Manipulate the [query](#search.SearchQuery) and
/// [update](#search.setSearchQuery) the search state with a new
/// query.
/// - Notice external changes to the query by reacting to the
/// appropriate [state effect](#search.setSearchQuery).
/// - Run some of the search commands.
///
/// The field that should be focused when opening the panel must be
/// tagged with a `main-field=true` DOM attribute.
createPanel?: (view: EditorView) => Panel,
/// By default, matches are scrolled into view using the default
/// behavior of
/// [`EditorView.scrollIntoView`](#view.EditorView^scrollIntoView).
/// This option allows you to pass a custom function to produce the
/// scroll effect.
scrollToMatch?: (range: SelectionRange, view: EditorView) => StateEffect<unknown>
}
const searchConfigFacet: Facet<SearchConfig, Required<SearchConfig>> = Facet.define({
combine(configs) {
return combineConfig(configs, {
top: false,
caseSensitive: false,
literal: false,
regexp: false,
wholeWord: false,
createPanel: view => new SearchPanel(view),
scrollToMatch: range => EditorView.scrollIntoView(range)
})
}
})
/// Add search state to the editor configuration, and optionally
/// configure the search extension.
/// ([`openSearchPanel`](#search.openSearchPanel) will automatically
/// enable this if it isn't already on).
export function search(config?: SearchConfig): Extension {
return config ? [searchConfigFacet.of(config), searchExtensions] : searchExtensions
}
/// A search query. Part of the editor's search state.
export class SearchQuery {
/// The search string (or regular expression).
readonly search: string
/// Indicates whether the search is case-sensitive.
readonly caseSensitive: boolean
/// By default, string search will replace `\n`, `\r`, and `\t` in
/// the query with newline, return, and tab characters. When this
/// is set to true, that behavior is disabled.
readonly literal: boolean
/// When true, the search string is interpreted as a regular
/// expression.
readonly regexp: boolean
/// The replace text, or the empty string if no replace text has
/// been given.
readonly replace: string
/// Whether this query is non-empty and, in case of a regular
/// expression search, syntactically valid.
readonly valid: boolean
/// When true, matches that contain words are ignored when there are
/// further word characters around them.
readonly wholeWord: boolean
/// @internal
readonly unquoted: string
readonly test: ((from: number, to: number, buffer: string, bufferPos: number) => boolean) | undefined
readonly regexpTest: ((from: number, to: number, match: RegExpExecArray) => boolean) | undefined
/// Create a query object.
constructor(config: {
/// The search string.
search: string,
/// Controls whether the search should be case-sensitive.
caseSensitive?: boolean,
/// By default, string search will replace `\n`, `\r`, and `\t` in
/// the query with newline, return, and tab characters. When this
/// is set to true, that behavior is disabled.
literal?: boolean,
/// When true, interpret the search string as a regular expression.
regexp?: boolean,
/// The replace text.
replace?: string,
/// Enable whole-word matching.
wholeWord?: boolean
test?: (from: number, to: number, buffer: string, bufferPos: number) => boolean
regexpTest?: (from: number, to: number, match: RegExpExecArray) => boolean
}) {
this.search = config.search
this.caseSensitive = !!config.caseSensitive
this.literal = !!config.literal
this.regexp = !!config.regexp
this.replace = config.replace || ""
this.valid = !!this.search && (!this.regexp || validRegExp(this.search))
this.unquoted = this.unquote(this.search)
this.wholeWord = !!config.wholeWord
this.test = config.test
this.regexpTest = config.regexpTest
}
/// @internal
unquote(text: string) {
return this.literal ? text :
text.replace(/\\([nrt\\])/g, (_, ch) => ch == "n" ? "\n" : ch == "r" ? "\r" : ch == "t" ? "\t" : "\\")
}
/// Compare this query to another query.
eq(other: SearchQuery) {
return this.search == other.search && this.replace == other.replace &&
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
this.wholeWord == other.wholeWord
}
/// @internal
create(): QueryType {
return this.regexp ? new RegExpQuery(this) : new StringQuery(this)
}
/// Get a search cursor for this query, searching through the given
/// range in the given state.
getCursor(state: EditorState | Text, from: number = 0, to?: number): Iterator<{from: number, to: number}> {
let st = (state as any).doc ? state as EditorState : EditorState.create({doc: state as Text})
if (to == null) to = st.doc.length
return this.regexp ? regexpCursor(this, st, from, to) : stringCursor(this, st, from, to)
}
}
type SearchResult = typeof SearchCursor.prototype.value
abstract class QueryType<Result extends SearchResult = SearchResult> {
constructor(readonly spec: SearchQuery) {}
abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null
abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null
abstract getReplacement(result: Result): string
abstract matchAll(state: EditorState, limit: number): readonly Result[] | null
abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void
}
const enum FindPrev { ChunkSize = 10000 }
function stringCursor(spec: SearchQuery, state: EditorState, from: number, to: number) {
const test = (from: number, to: number, buffer: string, bufferPos: number) => {
return (
(spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head))(from, to, buffer, bufferPos) : true) &&
(spec.test ? spec.test(from, to, buffer, bufferPos) : true)
)
}
return new SearchCursor(
state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(),
spec.wholeWord || spec.test ? test : undefined)
}
function stringWordTest(doc: Text, categorizer: (ch: string) => CharCategory) {
return (from: number, to: number, buf: string, bufPos: number) => {
if (bufPos > from || bufPos + buf.length < to) {
bufPos = Math.max(0, from - 2)
buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2))
}
return (categorizer(charBefore(buf, from - bufPos)) != CharCategory.Word ||
categorizer(charAfter(buf, from - bufPos)) != CharCategory.Word) &&
(categorizer(charAfter(buf, to - bufPos)) != CharCategory.Word ||
categorizer(charBefore(buf, to - bufPos)) != CharCategory.Word)
}
}
class StringQuery extends QueryType<SearchResult> {
constructor(spec: SearchQuery) {
super(spec)
}
nextMatch(state: EditorState, curFrom: number, curTo: number) {
let cursor = stringCursor(this.spec, state, curTo, state.doc.length).nextOverlapping()
if (cursor.done) {
let end = Math.min(state.doc.length, curFrom + this.spec.unquoted.length)
cursor = stringCursor(this.spec, state, 0, end).nextOverlapping()
}
return cursor.done || cursor.value.from == curFrom && cursor.value.to == curTo ? null : cursor.value
}
// Searching in reverse is, rather than implementing an inverted search
// cursor, done by scanning chunk after chunk forward.
private prevMatchInRange(state: EditorState, from: number, to: number) {
for (let pos = to;;) {
let start = Math.max(from, pos - FindPrev.ChunkSize - this.spec.unquoted.length)
let cursor = stringCursor(this.spec, state, start, pos), range: SearchResult | null = null
while (!cursor.nextOverlapping().done) range = cursor.value
if (range) return range
if (start == from) return null
pos -= FindPrev.ChunkSize
}
}
prevMatch(state: EditorState, curFrom: number, curTo: number) {
let found = this.prevMatchInRange(state, 0, curFrom)
if (!found)
found = this.prevMatchInRange(state, Math.max(0, curTo - this.spec.unquoted.length), state.doc.length)
return found && (found.from != curFrom || found.to != curTo) ? found : null
}
getReplacement(_result: SearchResult) { return this.spec.unquote(this.spec.replace) }
matchAll(state: EditorState, limit: number) {
let cursor = stringCursor(this.spec, state, 0, state.doc.length), ranges = []
while (!cursor.next().done) {
if (ranges.length >= limit) return null
ranges.push(cursor.value)
}
return ranges
}
highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void) {
let cursor = stringCursor(this.spec, state, Math.max(0, from - this.spec.unquoted.length),
Math.min(to + this.spec.unquoted.length, state.doc.length))
while (!cursor.next().done) add(cursor.value.from, cursor.value.to)
}
}
const enum RegExp { HighlightMargin = 250 }
type RegExpResult = typeof RegExpCursor.prototype.value
function regexpCursor(spec: SearchQuery, state: EditorState, from: number, to: number) {
const test = (from: number, to: number, match: RegExpExecArray) => {
return (
(spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head))(from, to, match) : true) &&
(spec.regexpTest ? spec.regexpTest(from, to, match) : true)
)
}
return new RegExpCursor(state.doc, spec.search, {
ignoreCase: !spec.caseSensitive,
test: spec.wholeWord || spec.regexpTest ? test : undefined
}, from, to)
}
function charBefore(str: string, index: number) {
return str.slice(findClusterBreak(str, index, false), index)
}
function charAfter(str: string, index: number) {
return str.slice(index, findClusterBreak(str, index))
}
function regexpWordTest(categorizer: (ch: string) => CharCategory) {
return (_from: number, _to: number, match: RegExpExecArray) =>
!match[0].length ||
(categorizer(charBefore(match.input, match.index)) != CharCategory.Word ||
categorizer(charAfter(match.input, match.index)) != CharCategory.Word) &&
(categorizer(charAfter(match.input, match.index + match[0].length)) != CharCategory.Word ||
categorizer(charBefore(match.input, match.index + match[0].length)) != CharCategory.Word)
}
class RegExpQuery extends QueryType<RegExpResult> {
nextMatch(state: EditorState, curFrom: number, curTo: number) {
let cursor = regexpCursor(this.spec, state, curTo, state.doc.length).next()
if (cursor.done) cursor = regexpCursor(this.spec, state, 0, curFrom).next()
return cursor.done ? null : cursor.value
}
private prevMatchInRange(state: EditorState, from: number, to: number) {
for (let size = 1;; size++) {
let start = Math.max(from, to - size * FindPrev.ChunkSize)
let cursor = regexpCursor(this.spec, state, start, to), range: RegExpResult | null = null
while (!cursor.next().done) range = cursor.value
if (range && (start == from || range.from > start + 10)) return range
if (start == from) return null
}
}
prevMatch(state: EditorState, curFrom: number, curTo: number) {
return this.prevMatchInRange(state, 0, curFrom) ||
this.prevMatchInRange(state, curTo, state.doc.length)
}
getReplacement(result: RegExpResult) {
return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
if (i == "&") return result.match[0]
if (i == "$") return "$"
for (let l = i.length; l > 0; l--) {
let n = +i.slice(0, l)
if (n > 0 && n < result.match.length) return result.match[n] + i.slice(l)
}
return m
})
}
matchAll(state: EditorState, limit: number) {
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = []
while (!cursor.next().done) {
if (ranges.length >= limit) return null
ranges.push(cursor.value)
}
return ranges
}
highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void) {
let cursor = regexpCursor(this.spec, state, Math.max(0, from - RegExp.HighlightMargin),
Math.min(to + RegExp.HighlightMargin, state.doc.length))
while (!cursor.next().done) add(cursor.value.from, cursor.value.to)
}
}
/// A state effect that updates the current search query. Note that
/// this only has an effect if the search state has been initialized
/// (by including [`search`](#search.search) in your configuration or
/// by running [`openSearchPanel`](#search.openSearchPanel) at least
/// once).
export const setSearchQuery = StateEffect.define<SearchQuery>()
const togglePanel = StateEffect.define<boolean>()
const searchState: StateField<SearchState> = StateField.define<SearchState>({
create(state) {
return new SearchState(defaultQuery(state).create(), null)
},
update(value, tr) {
for (let effect of tr.effects) {
if (effect.is(setSearchQuery)) value = new SearchState(effect.value.create(), value.panel)
else if (effect.is(togglePanel)) value = new SearchState(value.query, effect.value ? createSearchPanel : null)
}
return value
},
provide: f => showPanel.from(f, val => val.panel)
})
/// Get the current search query from an editor state.
export function getSearchQuery(state: EditorState) {
let curState = state.field(searchState, false)
return curState ? curState.query.spec : defaultQuery(state)
}
/// Query whether the search panel is open in the given editor state.
export function searchPanelOpen(state: EditorState) {
return state.field(searchState, false)?.panel != null
}
class SearchState {
constructor(readonly query: QueryType, readonly panel: PanelConstructor | null) {}
}
const matchMark = Decoration.mark({class: "cm-searchMatch"}),
selectedMatchMark = Decoration.mark({class: "cm-searchMatch cm-searchMatch-selected"})
const searchHighlighter = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(readonly view: EditorView) {
this.decorations = this.highlight(view.state.field(searchState))
}
update(update: ViewUpdate) {
let state = update.state.field(searchState)
if (state != update.startState.field(searchState) || update.docChanged || update.selectionSet || update.viewportChanged)
this.decorations = this.highlight(state)
}
highlight({query, panel}: SearchState) {
if (!panel || !query.spec.valid) return Decoration.none
let {view} = this
let builder = new RangeSetBuilder<Decoration>()
for (let i = 0, ranges = view.visibleRanges, l = ranges.length; i < l; i++) {
let {from, to} = ranges[i]
while (i < l - 1 && to > ranges[i + 1].from - 2 * RegExp.HighlightMargin) to = ranges[++i].to
query.highlight(view.state, from, to, (from, to) => {
let selected = view.state.selection.ranges.some(r => r.from == from && r.to == to)
builder.add(from, to, selected ? selectedMatchMark : matchMark)
})
}
return builder.finish()
}
}, {
decorations: v => v.decorations
})
function searchCommand(f: (view: EditorView, state: SearchState) => boolean): Command {
return view => {
let state = view.state.field(searchState, false)
return state && state.query.spec.valid ? f(view, state) : openSearchPanel(view)
}
}
/// Open the search panel if it isn't already open, and move the
/// selection to the first match after the current main selection.
/// Will wrap around to the start of the document when it reaches the
/// end.
export const findNext = searchCommand((view, {query}) => {
let {to} = view.state.selection.main
let next = query.nextMatch(view.state, to, to)
if (!next) return false
let selection = EditorSelection.single(next.from, next.to)
let config = view.state.facet(searchConfigFacet)
view.dispatch({
selection,
effects: [announceMatch(view, next), config.scrollToMatch(selection.main, view)],
userEvent: "select.search"
})
selectSearchInput(view)
return true
})
/// Move the selection to the previous instance of the search query,
/// before the current main selection. Will wrap past the start
/// of the document to start searching at the end again.
export const findPrevious = searchCommand((view, {query}) => {
let {state} = view, {from} = state.selection.main
let prev = query.prevMatch(state, from, from)
if (!prev) return false
let selection = EditorSelection.single(prev.from, prev.to)
let config = view.state.facet(searchConfigFacet)
view.dispatch({
selection,
effects: [announceMatch(view, prev), config.scrollToMatch(selection.main, view)],
userEvent: "select.search"
})
selectSearchInput(view)
return true
})
/// Select all instances of the search query.
export const selectMatches = searchCommand((view, {query}) => {
let ranges = query.matchAll(view.state, 1000)
if (!ranges || !ranges.length) return false
view.dispatch({
selection: EditorSelection.create(ranges.map(r => EditorSelection.range(r.from, r.to))),
userEvent: "select.search.matches"
})
return true
})
/// Select all instances of the currently selected text.
export const selectSelectionMatches: StateCommand = ({state, dispatch}) => {
let sel = state.selection
if (sel.ranges.length > 1 || sel.main.empty) return false
let {from, to} = sel.main
let ranges = [], main = 0
for (let cur = new SearchCursor(state.doc, state.sliceDoc(from, to)); !cur.next().done;) {
if (ranges.length > 1000) return false
if (cur.value.from == from) main = ranges.length
ranges.push(EditorSelection.range(cur.value.from, cur.value.to))
}
dispatch(state.update({
selection: EditorSelection.create(ranges, main),
userEvent: "select.search.matches"
}))
return true
}
/// Replace the current match of the search query.
export const replaceNext = searchCommand((view, {query}) => {
let {state} = view, {from, to} = state.selection.main
if (state.readOnly) return false
let match = query.nextMatch(state, from, from)
if (!match) return false
let next: SearchResult | null = match
let changes = [], selection: EditorSelection | undefined, replacement: Text | undefined
let effects: StateEffect<unknown>[] = []
if (next.from == from && next.to == to) {
replacement = state.toText(query.getReplacement(next))
changes.push({from: next.from, to: next.to, insert: replacement})
next = query.nextMatch(state, next.from, next.to)
effects.push(EditorView.announce.of(
state.phrase("replaced match on line $", state.doc.lineAt(from).number) + "."))
}
let changeSet = view.state.changes(changes)
if (next) {
selection = EditorSelection.single(next.from, next.to).map(changeSet)
effects.push(announceMatch(view, next))
effects.push(state.facet(searchConfigFacet).scrollToMatch(selection.main, view))
}
view.dispatch({
changes: changeSet,
selection,
effects,
userEvent: "input.replace"
})
return true
})
/// Replace all instances of the search query with the given
/// replacement.
export const replaceAll = searchCommand((view, {query}) => {
if (view.state.readOnly) return false
let changes = query.matchAll(view.state, 1e9)!.map(match => {
let {from, to} = match
return {from, to, insert: query.getReplacement(match)}
})
if (!changes.length) return false
let announceText = view.state.phrase("replaced $ matches", changes.length) + "."
view.dispatch({
changes,
effects: EditorView.announce.of(announceText),
userEvent: "input.replace.all"
})
return true
})
function createSearchPanel(view: EditorView) {
return view.state.facet(searchConfigFacet).createPanel(view)
}
function defaultQuery(state: EditorState, fallback?: SearchQuery) {
let sel = state.selection.main
let selText = sel.empty || sel.to > sel.from + 100 ? "" : state.sliceDoc(sel.from, sel.to)
if (fallback && !selText) return fallback
let config = state.facet(searchConfigFacet)
return new SearchQuery({
search: (fallback?.literal ?? config.literal) ? selText : selText.replace(/\n/g, "\\n"),
caseSensitive: fallback?.caseSensitive ?? config.caseSensitive,
literal: fallback?.literal ?? config.literal,
regexp: fallback?.regexp ?? config.regexp,
wholeWord: fallback?.wholeWord ?? config.wholeWord
})
}
function getSearchInput(view: EditorView) {
let panel = getPanel(view, createSearchPanel)
return panel && panel.dom.querySelector("[main-field]") as HTMLInputElement | null
}
function selectSearchInput(view: EditorView) {
let input = getSearchInput(view)
if (input && input == view.root.activeElement)
input.select()
}
/// Make sure the search panel is open and focused.
export const openSearchPanel: Command = view => {
let state = view.state.field(searchState, false)
if (state && state.panel) {
let searchInput = getSearchInput(view)
if (searchInput && searchInput != view.root.activeElement) {
let query = defaultQuery(view.state, state.query.spec)
if (query.valid) view.dispatch({effects: setSearchQuery.of(query)})
searchInput.focus()
searchInput.select()
}
} else {
view.dispatch({effects: [
togglePanel.of(true),
state ? setSearchQuery.of(defaultQuery(view.state, state.query.spec)) : StateEffect.appendConfig.of(searchExtensions)
]})
}
return true
}
/// Close the search panel.
export const closeSearchPanel: Command = view => {
let state = view.state.field(searchState, false)
if (!state || !state.panel) return false
let panel = getPanel(view, createSearchPanel)
if (panel && panel.dom.contains(view.root.activeElement)) view.focus()
view.dispatch({effects: togglePanel.of(false)})
return true
}
/// Default search-related key bindings.
///
/// - Mod-f: [`openSearchPanel`](#search.openSearchPanel)
/// - F3, Mod-g: [`findNext`](#search.findNext)
/// - Shift-F3, Shift-Mod-g: [`findPrevious`](#search.findPrevious)
/// - Mod-Alt-g: [`gotoLine`](#search.gotoLine)
/// - Mod-d: [`selectNextOccurrence`](#search.selectNextOccurrence)
export const searchKeymap: readonly KeyBinding[] = [
{key: "Mod-f", run: openSearchPanel, scope: "editor search-panel"},
{key: "F3", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true},
{key: "Mod-g", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true},
{key: "Escape", run: closeSearchPanel, scope: "editor search-panel"},
{key: "Mod-Shift-l", run: selectSelectionMatches},
{key: "Mod-Alt-g", run: gotoLine},
{key: "Mod-d", run: selectNextOccurrence, preventDefault: true},
]
class SearchPanel implements Panel {
searchField: HTMLInputElement
replaceField: HTMLInputElement
caseField: HTMLInputElement
reField: HTMLInputElement
wordField: HTMLInputElement
dom: HTMLElement
query: SearchQuery
constructor(readonly view: EditorView) {
let query = this.query = view.state.field(searchState).query.spec
this.commit = this.commit.bind(this)
this.searchField = elt("input", {
value: query.search,
placeholder: phrase(view, "Find"),
"aria-label": phrase(view, "Find"),
class: "cm-textfield",
name: "search",
form: "",
"main-field": "true",
onchange: this.commit,
onkeyup: this.commit
}) as HTMLInputElement
this.replaceField = elt("input", {
value: query.replace,
placeholder: phrase(view, "Replace"),
"aria-label": phrase(view, "Replace"),
class: "cm-textfield",
name: "replace",
form: "",
onchange: this.commit,
onkeyup: this.commit
}) as HTMLInputElement
this.caseField = elt("input", {
type: "checkbox",
name: "case",
form: "",
checked: query.caseSensitive,
onchange: this.commit
}) as HTMLInputElement
this.reField = elt("input", {
type: "checkbox",
name: "re",
form: "",
checked: query.regexp,
onchange: this.commit
}) as HTMLInputElement
this.wordField = elt("input", {
type: "checkbox",
name: "word",
form: "",
checked: query.wholeWord,
onchange: this.commit
}) as HTMLInputElement
function button(name: string, onclick: () => void, content: (Node | string)[]) {
return elt("button", {class: "cm-button", name, onclick, type: "button"}, content)
}
this.dom = elt("div", {onkeydown: (e: KeyboardEvent) => this.keydown(e), class: "cm-search"}, [
this.searchField,
button("next", () => findNext(view), [phrase(view, "next")]),
button("prev", () => findPrevious(view), [phrase(view, "previous")]),
button("select", () => selectMatches(view), [phrase(view, "all")]),
elt("label", null, [this.caseField, phrase(view, "match case")]),
elt("label", null, [this.reField, phrase(view, "regexp")]),
elt("label", null, [this.wordField, phrase(view, "by word")]),
...view.state.readOnly ? [] : [
elt("br"),
this.replaceField,
button("replace", () => replaceNext(view), [phrase(view, "replace")]),
button("replaceAll", () => replaceAll(view), [phrase(view, "replace all")])
],
elt("button", {
name: "close",
onclick: () => closeSearchPanel(view),
"aria-label": phrase(view, "close"),
type: "button"
}, ["×"])
])
}
commit() {
let query = new SearchQuery({
search: this.searchField.value,
caseSensitive: this.caseField.checked,
regexp: this.reField.checked,
wholeWord: this.wordField.checked,
replace: this.replaceField.value,
})
if (!query.eq(this.query)) {
this.query = query
this.view.dispatch({effects: setSearchQuery.of(query)})
}
}
keydown(e: KeyboardEvent) {
if (runScopeHandlers(this.view, e, "search-panel")) {
e.preventDefault()
} else if (e.keyCode == 13 && e.target == this.searchField) {
e.preventDefault()
;(e.shiftKey ? findPrevious : findNext)(this.view)
} else if (e.keyCode == 13 && e.target == this.replaceField) {
e.preventDefault()
replaceNext(this.view)
}
}
update(update: ViewUpdate) {
for (let tr of update.transactions) for (let effect of tr.effects) {
if (effect.is(setSearchQuery) && !effect.value.eq(this.query)) this.setQuery(effect.value)
}
}
setQuery(query: SearchQuery) {
this.query = query
this.searchField.value = query.search
this.replaceField.value = query.replace
this.caseField.checked = query.caseSensitive
this.reField.checked = query.regexp
this.wordField.checked = query.wholeWord
}
mount() {
this.searchField.select()
}
get pos() { return 80 }
get top() { return this.view.state.facet(searchConfigFacet).top }
}
function phrase(view: EditorView, phrase: string) { return view.state.phrase(phrase) }
const AnnounceMargin = 30
const Break = /[\s\.,:;?!]/
function announceMatch(view: EditorView, {from, to}: {from: number, to: number}) {
let line = view.state.doc.lineAt(from), lineEnd = view.state.doc.lineAt(to).to
let start = Math.max(line.from, from - AnnounceMargin), end = Math.min(lineEnd, to + AnnounceMargin)
let text = view.state.sliceDoc(start, end)
if (start != line.from) {
for (let i = 0; i < AnnounceMargin; i++) if (!Break.test(text[i + 1]) && Break.test(text[i])) {
text = text.slice(i)
break
}
}
if (end != lineEnd) {
for (let i = text.length - 1; i > text.length - AnnounceMargin; i--) if (!Break.test(text[i - 1]) && Break.test(text[i])) {
text = text.slice(0, i)
break
}
}
return EditorView.announce.of(
`${view.state.phrase("current match")}. ${text} ${view.state.phrase("on line")} ${line.number}.`)
}
const baseTheme = EditorView.baseTheme({
".cm-panel.cm-search": {
padding: "2px 6px 4px",
position: "relative",
"& [name=close]": {
position: "absolute",
top: "0",
right: "4px",
backgroundColor: "inherit",
border: "none",
font: "inherit",
padding: 0,
margin: 0
},
"& input, & button, & label": {
margin: ".2em .6em .2em 0"
},
"& input[type=checkbox]": {
marginRight: ".2em"
},
"& label": {
fontSize: "80%",
whiteSpace: "pre"
}
},
"&light .cm-searchMatch": { backgroundColor: "#ffff0054" },
"&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" },
"&light .cm-searchMatch-selected": { backgroundColor: "#ff6a0054" },
"&dark .cm-searchMatch-selected": { backgroundColor: "#ff00ff8a" }
})
const searchExtensions = [
searchState,
Prec.low(searchHighlighter),
baseTheme
]

View File

@ -1,174 +0,0 @@
import {EditorView, ViewPlugin, Decoration, DecorationSet, ViewUpdate} from "@codemirror/view"
import {Facet, combineConfig, Extension, CharCategory, EditorSelection,
EditorState, StateCommand} from "@codemirror/state"
import {SearchCursor} from "./cursor"
type HighlightOptions = {
/// Determines whether, when nothing is selected, the word around
/// the cursor is matched instead. Defaults to false.
highlightWordAroundCursor?: boolean,
/// The minimum length of the selection before it is highlighted.
/// Defaults to 1 (always highlight non-cursor selections).
minSelectionLength?: number,
/// The amount of matches (in the viewport) at which to disable
/// highlighting. Defaults to 100.
maxMatches?: number
/// Whether to only highlight whole words.
wholeWords?: boolean
}
const defaultHighlightOptions = {
highlightWordAroundCursor: false,
minSelectionLength: 1,
maxMatches: 100,
wholeWords: false
}
const highlightConfig = Facet.define<HighlightOptions, Required<HighlightOptions>>({
combine(options: readonly HighlightOptions[]) {
return combineConfig(options, defaultHighlightOptions, {
highlightWordAroundCursor: (a, b) => a || b,
minSelectionLength: Math.min,
maxMatches: Math.min
})
}
})
/// This extension highlights text that matches the selection. It uses
/// the `"cm-selectionMatch"` class for the highlighting. When
/// `highlightWordAroundCursor` is enabled, the word at the cursor
/// itself will be highlighted with `"cm-selectionMatch-main"`.
export function highlightSelectionMatches(options?: HighlightOptions): Extension {
let ext = [defaultTheme, matchHighlighter]
if (options) ext.push(highlightConfig.of(options))
return ext
}
const matchDeco = Decoration.mark({class: "cm-selectionMatch"})
const mainMatchDeco = Decoration.mark({class: "cm-selectionMatch cm-selectionMatch-main"})
// Whether the characters directly outside the given positions are non-word characters
function insideWordBoundaries (check: (char: string) => CharCategory, state: EditorState, from: number, to: number): boolean {
return (from == 0 || check(state.sliceDoc(from - 1, from)) != CharCategory.Word) &&
(to == state.doc.length || check(state.sliceDoc(to, to + 1)) != CharCategory.Word)
}
// Whether the characters directly at the given positions are word characters
function insideWord (check: (char: string) => CharCategory, state: EditorState, from: number, to: number): boolean {
return check(state.sliceDoc(from, from + 1)) == CharCategory.Word
&& check(state.sliceDoc(to - 1, to)) == CharCategory.Word
}
const matchHighlighter = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.getDeco(view)
}
update(update: ViewUpdate) {
if (update.selectionSet || update.docChanged || update.viewportChanged) this.decorations = this.getDeco(update.view)
}
getDeco(view: EditorView) {
let conf = view.state.facet(highlightConfig)
let {state} = view, sel = state.selection
if (sel.ranges.length > 1) return Decoration.none
let range = sel.main, query, check = null
if (range.empty) {
if (!conf.highlightWordAroundCursor) return Decoration.none
let word = state.wordAt(range.head)
if (!word) return Decoration.none
check = state.charCategorizer(range.head)
query = state.sliceDoc(word.from, word.to)
} else {
let len = range.to - range.from
if (len < conf.minSelectionLength || len > 200) return Decoration.none
if (conf.wholeWords) {
query = state.sliceDoc(range.from, range.to) // TODO: allow and include leading/trailing space?
check = state.charCategorizer(range.head)
if (!(insideWordBoundaries(check, state, range.from, range.to) &&
insideWord(check, state, range.from, range.to))) return Decoration.none
} else {
query = state.sliceDoc(range.from, range.to)
if (!query) return Decoration.none
}
}
let deco = []
for (let part of view.visibleRanges) {
let cursor = new SearchCursor(state.doc, query, part.from, part.to)
while (!cursor.next().done) {
let {from, to} = cursor.value
if (!check || insideWordBoundaries(check, state, from, to)) {
if (range.empty && from <= range.from && to >= range.to)
deco.push(mainMatchDeco.range(from, to))
else if (from >= range.to || to <= range.from)
deco.push(matchDeco.range(from, to))
if (deco.length > conf.maxMatches) return Decoration.none
}
}
}
return Decoration.set(deco)
}
}, {
decorations: v => v.decorations
})
const defaultTheme = EditorView.baseTheme({
".cm-selectionMatch": { backgroundColor: "#99ff7780" },
".cm-searchMatch .cm-selectionMatch": {backgroundColor: "transparent"}
})
// Select the words around the cursors.
const selectWord: StateCommand = ({state, dispatch}) => {
let {selection} = state
let newSel = EditorSelection.create(selection.ranges.map(
range => state.wordAt(range.head) || EditorSelection.cursor(range.head)
), selection.mainIndex)
if (newSel.eq(selection)) return false
dispatch(state.update({selection: newSel}))
return true
}
// Find next occurrence of query relative to last cursor. Wrap around
// the document if there are no more matches.
function findNextOccurrence(state: EditorState, query: string) {
let {main, ranges} = state.selection
let word = state.wordAt(main.head), fullWord = word && word.from == main.from && word.to == main.to
for (let cycled = false, cursor = new SearchCursor(state.doc, query, ranges[ranges.length - 1].to);;) {
cursor.next()
if (cursor.done) {
if (cycled) return null
cursor = new SearchCursor(state.doc, query, 0, Math.max(0, ranges[ranges.length - 1].from - 1))
cycled = true
} else {
if (cycled && ranges.some(r => r.from == cursor.value.from))
continue
if (fullWord) {
let word = state.wordAt(cursor.value.from)
if (!word || word.from != cursor.value.from || word.to != cursor.value.to) continue
}
return cursor.value
}
}
}
/// Select next occurrence of the current selection. Expand selection
/// to the surrounding word when the selection is empty.
export const selectNextOccurrence: StateCommand = ({state, dispatch}) => {
let {ranges} = state.selection
if (ranges.some(sel => sel.from === sel.to)) return selectWord({state, dispatch})
let searchedText = state.sliceDoc(ranges[0].from, ranges[0].to)
if (state.selection.ranges.some(r => state.sliceDoc(r.from, r.to) != searchedText))
return false
let range = findNextOccurrence(state, searchedText)
if (!range) return false
dispatch(state.update({
selection: state.selection.addRange(EditorSelection.range(range.from, range.to), false),
effects: EditorView.scrollIntoView(range.to)
}))
return true
}

View File

@ -16,7 +16,7 @@ import { foldCode, unfoldCode, toggleFold } from "@codemirror/language"
import {
openSearchPanel, closeSearchPanel, findNext, findPrevious,
selectMatches, replaceNext, replaceAll,
} from "./codemirror-search/search.js"
} from "@codemirror/search"
import { selectNextOccurrence, selectSelectionMatches } from "./search/selection-match.js"
import { insertNewlineContinueMarkup } from "@codemirror/lang-markdown"
@ -35,7 +35,7 @@ import { deleteLine } from "./block/delete-line.js"
import { formatBlockContent } from "./block/format-code.js"
import { transposeChars } from "./block/transpose-chars.js"
import { cutCommand, copyCommand, pasteCommand } from "./copy-paste.js"
import { cutCommand, copyCommand, pasteCommand } from "./clipboard/copy-paste.js"
import { markModeMoveCommand, toggleSelectionMarkMode, selectionMarkModeCancel } from "./mark-mode.js"
import { insertDateAndTime } from "./date-time.js"

View File

@ -1,152 +0,0 @@
import { EditorState, EditorSelection } from "@codemirror/state"
import { EditorView } from "@codemirror/view"
import { LANGUAGES } from './languages.js'
import { BLOCK_DELIMITER_REGEX } from './block/block-parsing.js'
function copiedRange(state) {
let content = [], ranges = []
for (let range of state.selection.ranges) {
if (!range.empty) {
content.push(state.sliceDoc(range.from, range.to))
ranges.push(range)
}
}
if (ranges.length == 0) {
// if all ranges are empty, we want to copy each whole (unique) line for each selection
const copiedLines = []
for (let range of state.selection.ranges) {
if (range.empty) {
const line = state.doc.lineAt(range.head)
const lineContent = state.sliceDoc(line.from, line.to)
if (!copiedLines.includes(line.from)) {
content.push(lineContent)
ranges.push(range)
copiedLines.push(line.from)
}
}
}
}
return { text: content.join(state.lineBreak), ranges }
}
/**
* Set up event handlers for the browser's copy & cut events, that will replace block separators with newlines
*/
export const heynoteCopyCut = (editor) => {
let copy, cut
copy = cut = (event, view) => {
let { text, ranges } = copiedRange(view.state)
text = text.replaceAll(BLOCK_DELIMITER_REGEX, "\n\n")
let data = event.clipboardData
if (data) {
event.preventDefault()
data.clearData()
data.setData("text/plain", text)
}
if (event.type == "cut" && !view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
})
}
// if we're in Emacs mode, we want to exit mark mode in case we're in it
editor.selectionMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && event.type == "copy") {
const newSelection = EditorSelection.create(
view.state.selection.ranges.map(r => EditorSelection.cursor(r.head)),
view.state.selection.mainIndex,
)
view.dispatch(view.state.update({
selection: newSelection,
}))
}
}
return EditorView.domEventHandlers({
copy,
cut,
})
}
const copyCut = (view, cut, editor) => {
let { text, ranges } = copiedRange(view.state)
text = text.replaceAll(BLOCK_DELIMITER_REGEX, "\n\n")
navigator.clipboard.writeText(text)
if (cut && !view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
})
}
// if we're in Emacs mode, we want to exit mark mode in case we're in it
editor.selectionMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && !cut) {
const newSelection = EditorSelection.create(
view.state.selection.ranges.map(r => EditorSelection.cursor(r.head)),
view.state.selection.mainIndex,
)
view.dispatch(view.state.update({
selection: newSelection,
}))
}
return true
}
function doPaste(view, input) {
let { state } = view, changes, i = 1, text = state.toText(input)
let byLine = text.lines == state.selection.ranges.length
if (byLine) {
changes = state.changeByRange(range => {
let line = text.line(i++)
return {
changes: { from: range.from, to: range.to, insert: line.text },
range: EditorSelection.cursor(range.from + line.length)
}
})
} else {
changes = state.replaceSelection(text)
}
view.dispatch(changes, {
userEvent: "input.paste",
scrollIntoView: true
})
}
/**
* @param editor Editor instance
* @returns CodeMirror command that copies the current selection to the clipboard
*/
export function copyCommand(editor) {
return (view) => copyCut(view, false, editor)
}
/**
* @param editor Editor instance
* @returns CodeMirror command that cuts the current selection to the clipboard
*/
export function cutCommand(editor) {
return (view) => copyCut(view, true, editor)
}
/**
* CodeMirror command that pastes the clipboard content into the editor
*/
export async function pasteCommand(view) {
return doPaste(view, await navigator.clipboard.readText())
}

View File

@ -17,9 +17,11 @@ import { getBlockDelimiter } from "./block/block-parsing.js"
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded, deleteBlock, selectAll } from "./block/commands.js"
import { formatBlockContent } from "./block/format-code.js"
import { getKeymapExtensions } from "./keymap.js"
import { heynoteCopyCut } from "./copy-paste"
import { heynoteCopyCut } from "./clipboard/copy-paste.js"
import { heynoteDropPaste } from "./clipboard/drag-drop.js"
import { languageDetection } from "./language-detection/autodetect.js"
import { autoSaveContent } from "./save.js"
import { imageExtension } from "./image/image.js"
import { todoCheckboxPlugin} from "./todo-checkbox.ts"
import { links } from "./links.js"
import { indentation } from "./indentation.js"
@ -93,6 +95,7 @@ export class HeynoteEditor {
extensions: [
this.keymapCompartment.of(getKeymapExtensions(this, keymap, keyBindings)),
heynoteCopyCut(this),
heynoteDropPaste(),
//minimalSetup,
this.lineNumberCompartment.of(showLineNumberGutter ? blockLineNumbers : []),
@ -121,6 +124,8 @@ export class HeynoteEditor {
autoSaveContent(this, AUTO_SAVE_INTERVAL),
imageExtension(),
// Markdown extensions, we need to add markdownKeymap manually with the highest precedence
// so that it takes precedence over the default keymap
todoCheckboxPlugin,

View File

@ -4,7 +4,8 @@ import { EditorView } from "@codemirror/view"
import { FOLD_LABEL_LENGTH } from "@/src/common/constants.js"
import { formatDate, formatFullDate } from "@/src/common/format-date.js"
import { getNoteBlockFromPos, getNoteBlocksFromRangeSet, delimiterRegexWithoutNewline } from "./block/block.js"
import { transactionsHasAnnotationsAny, ADD_NEW_BLOCK, LANGUAGE_CHANGE, transactionsHasHistoryEvent } from "./annotation.js"
import { WIDGET_TAG_REGEX_NON_GLOBAL } from "./image/image-parsing.js"
import { transactionsHasAnnotationsAny, ADD_NEW_BLOCK, LANGUAGE_CHANGE, UPDATE_CREATED, transactionsHasHistoryEvent } from "./annotation.js"
import { useHeynoteStore } from "@/src/stores/heynote-store.js"
@ -28,7 +29,7 @@ const autoUnfoldOnEdit = () => {
}
// we don't want to unfold a block/range if the user adds a new block, or changes language of the block
if (transactionsHasAnnotationsAny(update.transactions, [ADD_NEW_BLOCK, LANGUAGE_CHANGE])) {
if (transactionsHasAnnotationsAny(update.transactions, [ADD_NEW_BLOCK, LANGUAGE_CHANGE, UPDATE_CREATED])) {
return
}
// an undo/redo action should never be able to get characters into a folded line but if we don't have
@ -151,6 +152,30 @@ export function foldGutterExtension() {
}
/**
* Returns a range that is to be folded, when folding a block. If the block can't
* be folded (e.i. it's a single line with few characters) it returns undefined
*/
function getFoldBlockRange(block, state) {
const firstLine = state.doc.lineAt(block.content.from)
let from
// if the fold cutoff point would end up in the middle of an image, we set the cutoff to be immediately after the image instead
const match = firstLine.text.match(WIDGET_TAG_REGEX_NON_GLOBAL)
if (match && match.index < FOLD_LABEL_LENGTH) {
from = firstLine.from + match.index + match[0].length
} else {
from = Math.min(firstLine.to, block.content.from + FOLD_LABEL_LENGTH)
}
const to = block.content.to
if (from < to) {
// skip empty ranges
return {from, to}
}
return undefined
}
export const toggleBlockFold = (editor) => (view) => {
const state = view.state
const folds = foldedRanges(state)
@ -173,14 +198,10 @@ export const toggleBlockFold = (editor) => (view) => {
unfoldEffects.push(...blockFolds.map(range => unfoldEffect.of(range)))
numFolded++
} else {
const lastLine = state.doc.lineAt(block.content.to)
// skip single-line blocks, since they are not folded
if (firstLine.from !== lastLine.from) {
const range = {from: Math.min(firstLine.to, block.content.from + FOLD_LABEL_LENGTH), to: block.content.to}
if (range.to > range.from) {
foldEffects.push(foldEffect.of(range))
}
const range = getFoldBlockRange(block, state)
if (range) {
numUnfolded++
foldEffects.push(foldEffect.of(range))
}
}
}
@ -200,13 +221,9 @@ export const foldBlock = (editor) => (view) => {
const blockRanges = []
for (const block of getNoteBlocksFromRangeSet(state, state.selection.ranges)) {
const line = state.doc.lineAt(block.content.from)
// fold the block content, but only the first line
const from = Math.min(line.to, block.content.from + FOLD_LABEL_LENGTH)
const to = block.content.to
if (from < to) {
// skip empty ranges
blockRanges.push({from, to})
const range = getFoldBlockRange(block, state)
if (range) {
blockRanges.push(range)
}
}
if (blockRanges.length > 0) {

View File

@ -0,0 +1,129 @@
import { EditorSelection } from "@codemirror/state"
import { heynoteEvent, IMAGE_RESIZE } from "../annotation.js"
import { IMAGE_REGEX } from "@/src/common/constants.js"
export const WIDGET_TAG_REGEX = /<∞.*?∞>/g
export const WIDGET_TAG_REGEX_NON_GLOBAL = /<∞.*?∞>/
const requiredParams = ["id", "file", "w", "h"]
export function parseImages(state) {
const content = state.doc.sliceString(0, state.doc.length)
return parseImagesFromString(content)
}
/**
* Parse img tags in the following format:
*
* <img;id=2d45d5f5-f912-4a8b-817f-ab14a349e585;file=https://heynote.com/img/share.png;w=1200;h=630;dw=324;dh=170∞>
*/
export function parseImagesFromString(content) {
let match
const images = []
while ((match = IMAGE_REGEX.exec(content)) !== null) {
try {
const rawParams = match[1]
const params = {}
// split on ; but ignore empty parts
for (const part of rawParams.split(";")) {
if (!part) {
continue
}
const eqIdx = part.indexOf("=");
if (eqIdx === -1) {
// support flags like ;hidden; etc
params[part] = true
continue
}
const key = part.slice(0, eqIdx)
const value = part.slice(eqIdx + 1)
params[key] = value
}
if (requiredParams.every(p => Object.hasOwn(params, p))) {
images.push({
from: match.index,
to: match.index + match[0].length,
id: params.id,
file: params.file,
width: Number(params.w),
height: Number(params.h),
displayWidth: params.dw ? Number(params.dw) : undefined,
displayHeight: params.dh ? Number(params.dh) : undefined,
})
}
} catch (err) {
const message = err?.message ?? String(err)
console.error(`Bad <∞img> tag at index ${match.index}: ${message}. Tag: ${match[0]}`)
}
}
return images
}
export function createImageTag(image) {
const params = [
"id=" + image.id,
"file=" + image.file,
"w=" + image.width,
"h=" + image.height,
]
if (image.displayWidth)
params.push("dw=" + image.displayWidth)
if (image.displayHeight)
params.push("dh=" + image.displayHeight)
return "<∞img;" + params.join(";") + "∞>"
}
/**
* Update display width/height parameters (dw/dh) of an img tag
*
* @param {EditorView} view CodeMirror view
* @param {*} from Start of img tag
* @param {*} to End of img tag
* @param {*} width The new display width
* @param {*} height The new display height
*/
export function setImageDisplayDimensions(view, id, width, height) {
const images = Object.fromEntries(parseImages(view.state).map(img => [img.id, img]))
if (Object.hasOwn(images, id)) {
const image = images[id]
image.displayWidth = width
image.displayHeight = height
view.dispatch(view.state.update({
changes: {
from: image.from,
to: image.to,
insert: createImageTag(image),
},
annotations: [heynoteEvent.of(IMAGE_RESIZE)],
}))
}
}
export function setImageFile(view, id, file) {
const images = Object.fromEntries(parseImages(view.state).map(img => [img.id, img]))
if (Object.hasOwn(images, id)) {
const image = images[id]
image.file = file
view.dispatch(view.state.update({
changes: {
from: image.from,
to: image.to,
insert: createImageTag(image),
},
selection: EditorSelection.cursor(image.to, -1)
}, {scrollIntoView: true}))
} else {
console.error(`Image with id ${id} not found`)
}
}

166
src/editor/image/image.js Normal file
View File

@ -0,0 +1,166 @@
import { EditorView, Decoration } from "@codemirror/view"
import { ViewPlugin } from "@codemirror/view"
import { EditorState, EditorSelection, SelectionRange, StateField, RangeSet, RangeSetBuilder, Compartment } from "@codemirror/state"
import { foldState } from "@codemirror/language"
import { transactionHasFoldEffect } from "../annotation.js"
import { ImageWidget } from "./widget.js"
import { parseImages } from "./image-parsing.js"
export const imageState = StateField.define({
create(state) {
return parseImages(state);
},
update(images, transaction) {
// if blocks are empty it likely means we didn't get a parsed syntax tree, and then we want to update
// the blocks on all updates (and not just document changes)
if (transaction.docChanged) {
return parseImages(transaction.state);
}
return images
},
})
function atomicRanges(view) {
let builder = new RangeSetBuilder()
view.state.field(imageState).forEach(image => {
builder.add(
image.from-0,
image.to+0,
{},
)
})
return builder.finish()
}
const atomicImages = ViewPlugin.fromClass(
class {
constructor(view) {
this.atomicRanges = atomicRanges(view)
}
update(update) {
if (update.docChanged) {
this.atomicRanges = atomicRanges(update.view)
}
}
},
{
provide: plugin => EditorView.atomicRanges.of(view => {
return view.plugin(plugin)?.atomicRanges || []
})
}
)
export const imageExtension = () => {
const domEventCompartment = new Compartment
const decorate = (state) => {
const widgets = []
const selection = state.selection.main
// Construct a Set of all the positions where a fold starts
const foldStarts = new Set()
state.field(foldState, false).between(0, state.doc.length, (from, _to, _value) => {
foldStarts.add(from)
})
let foundSelectedImage = false
state.field(imageState).forEach(image => {
// If a fold starts immediately after an image, it means the image is on the first line of a folded block
// and in this case we want to render a folded image
let isFolded = foldStarts.has(image.to)
let isSelected
// only allow one image to be selected (if the cursor is placed directly between two images)
if (!foundSelectedImage && imageIsSelected(image, selection)) {
isSelected = true
foundSelectedImage = true
}
//let delimiter = image.delimiter
let deco = Decoration.replace({
widget: new ImageWidget({
id: image.id,
path: image.file,
width: image.width,
height: image.height,
displayWidth: image.displayWidth,
displayHeight: image.displayHeight,
selected: isSelected,
isFolded,
domEventCompartment,
}),
inclusive: false,
block: false,
side: 0,
});
//console.log("deco range:", delimiter.from === 0 ? delimiter.from : delimiter.from+1,delimiter.to-1)
widgets.push(deco.range(
image.from,
image.to,
));
});
return widgets.length > 0 ? RangeSet.of(widgets) : Decoration.none;
}
const imagesField = StateField.define({
create(state) {
return decorate(state);
},
update(widgets, transaction) {
// if widgets are empty it likely means we didn't get a parsed syntax tree, and then we want to update
// the decorations on all updates (and not just document changes)
if (transaction.docChanged || transaction.selection || transactionHasFoldEffect(transaction)) {
//console.log("updating imagesField")
return decorate(transaction.state);
}
//return widgets.map(transaction.changes);
return widgets
},
provide(field) {
return EditorView.decorations.from(field);
}
});
/*function widgetRangeAt(view, pos) {
const images = view.state.field(imagesField)
let found
// Scan a tiny window around pos (replace widgets can map oddly at boundaries)
const a = Math.max(0, pos - 1)
const b = Math.min(view.state.doc.length, pos + 1)
images.between(a, b, (from, to) => {
// Ideally: ensure it's *your* widget (optional)
found = { from, to }
})
return found
}*/
return [
imageState,
imagesField,
atomicImages,
domEventCompartment.of([])
]
}
export const imageIsSelected = (image, selection) => {
//return selection.from === image.from && selection.to === image.to
return selection.from === selection.to && (selection.from === image.from || selection.from === image.to)
//return selection.from === selection.to && selection.from === image.to
//return selection.from === selection.to && ((selection.assoc === 1 && selection.from === image.from) || (selection.assoc <= 0 && selection.from === image.to))
//if (selection.main.assoc === 1) {
// return selection.from >= image.from && selection.to < image.to
//} else if (selection.main.assoc{
// return selection.from > image.from && selection.to <= image.to
}

236
src/editor/image/widget.js Normal file
View File

@ -0,0 +1,236 @@
import { EditorView } from "@codemirror/view"
import { WidgetType } from "@codemirror/view"
import { copyImage } from "../clipboard/copy-paste.js"
import { setImageDisplayDimensions } from "./image-parsing.js"
import { useHeynoteStore } from "../../stores/heynote-store.js"
const FOLDED_HEIGHT = 16
export class ImageWidget extends WidgetType {
constructor({id, path, width, height, displayHeight, displayWidth, selected, isFolded, domEventCompartment}) {
super()
this.id = id
this.path = path
this.selected = selected
this.width = width
this.height = height
this.displayWidth = displayWidth
this.displayHeight = displayHeight
this.isFolded = isFolded
this.domEventCompartment = domEventCompartment
this.idealWidth = this.width / window.devicePixelRatio
this.idealHeight = this.height / window.devicePixelRatio
}
eq(other) {
return other.path === this.path &&
this.width === other.width &&
this.height === other.height &&
this.displayWidth === other.displayWidth &&
this.displayHeight === other.displayHeight &&
this.selected === other.selected &&
this.isFolded === other.isFolded &&
this.id === other.id
}
getClassName() {
return "heynote-image" + (this.selected ? " selected" : "") + (this.isFolded ? " folded" : "")
}
getHeight(img) {
let height
if (this.isFolded) {
height = FOLDED_HEIGHT
} else if (this.displayHeight) {
height = this.displayHeight
} else {
height = this.idealHeight
}
return height + "px"
}
getWidth(img) {
let width
if (this.isFolded) {
width = FOLDED_HEIGHT * (this.width / this.height)
} else if (this.displayWidth) {
width = this.displayWidth
} else {
width = this.idealWidth
}
return width ? width + "px" : ""
}
toDOM(view) {
//console.log("toDOM", this.selected, this.height)
const heynoteStore = useHeynoteStore()
let wrap = document.createElement("div")
wrap.dataset.id = this.id
wrap.dataset.idealWidth = this.idealWidth
wrap.dataset.idealHeight = this.idealHeight
//wrap.setAttribute("aria-hidden", "true")
wrap.className = this.getClassName()
const resizeHandle = document.createElement("div")
resizeHandle.className = "resize-handle"
resizeHandle.innerHTML = "<div class='icon'></div>"
wrap.appendChild(resizeHandle)
let inner = document.createElement("div")
inner.className = "inner"
wrap.appendChild(inner)
//wrap.addEventListener("click", (e) => {
// console.log("click", e)
// wrap.focus()
//})
const highlightBorder = document.createElement("div")
highlightBorder.className = "highlight-border"
inner.appendChild(highlightBorder)
const buttonsContainer = document.createElement("div")
buttonsContainer.className = "buttons-container"
inner.appendChild(buttonsContainer)
const copyButton = document.createElement("button")
copyButton.innerHTML = "<span>Copy</span>"
buttonsContainer.appendChild(copyButton)
copyButton.addEventListener("mousedown", (event) => {
event.preventDefault()
})
copyButton.addEventListener("click", async (event) => {
event.preventDefault()
await copyImage(img.src)
copyButton.innerText = "Copied!"
setTimeout(() => {
copyButton.innerText = "Copy"
}, 2000)
})
let img = document.createElement("img")
img.src = this.path
img.style.height = this.getHeight(img)
img.style.width = this.getWidth(img)
inner.appendChild(img)
const drawButton = document.createElement("button")
drawButton.className = "draw"
drawButton.innerHTML = "<span>Draw</span>"
buttonsContainer.appendChild(drawButton)
drawButton.addEventListener("mousedown", (event) => {
event.preventDefault()
})
drawButton.addEventListener("click", (event) => {
event.preventDefault()
heynoteStore.openDrawImageModal(img.src, wrap.dataset.id)
})
let initialWidth, initialHeight, initialX, initialY
let shouldSnap = true
const onMousemove = (e) => {
//console.log("mousemove", e)
const idealWidth = parseFloat(wrap.dataset.idealWidth)
const idealHeight = parseFloat(wrap.dataset.idealHeight)
const aspect = idealWidth / idealHeight
let width = initialWidth + (e.pageX - initialX)
let height = initialHeight + (e.pageY - initialY)
const heightFromWidth = width / aspect
const widthFromHeight = height * aspect
if (heightFromWidth <= height) {
height = heightFromWidth
} else {
width = widthFromHeight
}
// snap to ideal dimensions
const SNAP_TOLERANCE = 10
if (shouldSnap) {
if (Math.abs(width - idealWidth) <= SNAP_TOLERANCE || (Math.abs(height - idealHeight) <= 10)) {
height = idealHeight
width = idealWidth
wrap.classList.add("snapped")
} else if (wrap.classList.contains("snapped")) {
wrap.classList.remove("snapped")
}
} else {
// even if snapping is turned off from the beginning (because we start at the ideal dimensions)
// we want to enable snapping once we've passed the snap tolerance
if (Math.abs(width - idealWidth) > SNAP_TOLERANCE && Math.abs(height - idealHeight) > SNAP_TOLERANCE) {
shouldSnap = true
}
}
// clamp dimensions
width = Math.max(width, 16)
height = width / aspect
if (height < 17) {
height = 17
width = height * aspect
}
img.style.width = width + "px"
img.style.height = height + "px"
}
const endResize = () => {
view.dispatch({effects: [this.domEventCompartment.reconfigure([])]})
setImageDisplayDimensions(view, wrap.dataset.id, img.width, img.height)
setTimeout(() => {
wrap.classList.remove("resizing")
}, 200)
}
resizeHandle.addEventListener("mousedown", (e) => {
//console.log("mousedown", e)
e.preventDefault()
initialWidth = img.getBoundingClientRect().width
initialHeight = img.getBoundingClientRect().height
initialX = e.pageX
initialY = e.pageY
// if we start the resize at ideal dimensions snapping should be turned off (initially)
shouldSnap = initialWidth !== this.idealWidth
//console.log("should snap?", shouldSnap, initialWidth, this.idealWidth)
wrap.classList.add("resizing")
view.dispatch({effects: [this.domEventCompartment.reconfigure([
EditorView.domEventObservers({
mousemove: (e) => {
onMousemove(e)
},
mouseup: (e) => {
//console.log("mouseup")
endResize()
},
mouseleave: (e) => {
//console.log("mouseleave")
endResize()
},
}),
EditorView.editorAttributes.of({class: "resizing-image"}),
])]})
})
return wrap
}
updateDOM(dom, view) {
dom.dataset.id = this.id
dom.dataset.idealWidth = this.idealWidth
dom.dataset.idealHeight = this.idealHeight
//console.log("updateDOM:", dom, this.selected, this.height)
dom.className = this.getClassName()
const img = dom.querySelector("img")
img.src = this.path
img.style.height = this.getHeight(img)
img.style.width = this.getWidth(img)
return true
}
ignoreEvent(e) {
return false
}
}

View File

@ -1,7 +1,10 @@
import { EditorSelection, EditorState, countColumn } from "@codemirror/state"
import { indentUnit } from "@codemirror/language"
import { indentUnit, indentService } from "@codemirror/language"
import { indentMore } from "@codemirror/commands"
import { getNoteBlockFromPos } from "./block/block"
import { getLanguage } from "./languages"
export function indentation(indentType, tabSize) {
let unit
@ -12,7 +15,17 @@ export function indentation(indentType, tabSize) {
} else {
throw new Error("Invalid indent type")
}
return [unit, EditorState.tabSize.of(tabSize)]
return [
unit,
EditorState.tabSize.of(tabSize),
indentService.of((context, pos) => {
const block = getNoteBlockFromPos(context.state, pos)
if (block && getLanguage(block.language.name)?.inheritIndentation) {
return null
}
return undefined
}),
]
}

View File

@ -30,7 +30,7 @@ MetadataEntry {
"xml" | "cpp" | "rust" | "csharp" | "ruby" | "shell" | "yaml" |
"golang" | "clojure" | "elixir" | "erlang" | "lezer" | "toml" |
"swift" | "kotlin" | "groovy" | "diff" | "powershell" | "vue" |
"dart" | "scala" | "lua"
"dart" | "scala" | "lua"| "mermaid"
}
Auto { "-a" }

View File

@ -10,6 +10,7 @@ import { javaLanguage } from "@codemirror/lang-java"
import { lezerLanguage } from "@codemirror/lang-lezer"
import { phpLanguage } from "@codemirror/lang-php"
import { elixirLanguage } from "codemirror-lang-elixir"
import { mermaidLanguage } from 'codemirror-lang-mermaid'
import { NoteContent, NoteLanguage } from "./parser.terms.js"
import { LANGUAGES } from "../languages.js"

File diff suppressed because one or more lines are too long

View File

@ -41,7 +41,7 @@ detectionWorker.onmessage = (event) => {
const block = getActiveNoteBlock(state)
const newLang = GUESSLANG_TO_TOKEN[event.data.guesslang.language]
if (block.language.auto === true && block.language.name !== newLang) {
console.log("New auto detected language:", newLang, "Confidence:", event.data.guesslang.confidence)
//console.log("New auto detected language:", newLang, "Confidence:", event.data.guesslang.confidence)
let content = state.doc.sliceString(block.content.from, block.content.to)
const threshold = content.length * 0.1
if (levenshtein_distance(content, event.data.content) <= threshold) {

View File

@ -13,6 +13,7 @@ import { xmlLanguage } from "@codemirror/lang-xml"
import { rustLanguage } from "@codemirror/lang-rust"
import { csharpLanguage } from "@replit/codemirror-lang-csharp"
import { vueLanguage } from "@codemirror/lang-vue";
import { mermaidLanguage } from 'codemirror-lang-mermaid';
import { StreamLanguage } from "@codemirror/language"
import { ruby } from "@codemirror/legacy-modes/mode/ruby"
@ -38,6 +39,8 @@ import markdownPrettierPlugin from "prettier/plugins/markdown"
import yamlPrettierPlugin from "prettier/plugins/yaml"
import * as prettierPluginEstree from "prettier/plugins/estree";
import { mathjsLanguage } from "heynote-lang-mathjs"
class Language {
/**
@ -46,13 +49,16 @@ class Language {
* @param parser: The Lezer parser used to parse the language
* @param guesslang: The name of the language as used by the guesslang library
* @param prettier: The prettier configuration for the language (if any)
* @param inheritIndentation: If true, the indentService will return null for blocks of this type, which will
* result in the line inheriting the indentation of the one above it
*/
constructor({ token, name, parser, guesslang, prettier }) {
constructor({ token, name, parser, guesslang, prettier, inheritIndentation }) {
this.token = token
this.name = name
this.parser = parser
this.guesslang = guesslang
this.prettier = prettier
this.inheritIndentation = inheritIndentation
}
get supportsFormat() {
@ -66,12 +72,14 @@ export const LANGUAGES = [
name: "Plain Text",
parser: null,
guesslang: null,
inheritIndentation: true,
}),
new Language({
token: "math",
name: "Math",
parser: null,
parser: mathjsLanguage.parser,
guesslang: null,
inheritIndentation: true,
}),
new Language({
token: "json",
@ -98,6 +106,7 @@ export const LANGUAGES = [
name: "SQL",
parser: StandardSQL.language.parser,
guesslang: "sql",
inheritIndentation: true,
}),
new Language({
token: "markdown",
@ -285,7 +294,13 @@ export const LANGUAGES = [
name: "Scala",
parser: StreamLanguage.define(scala).parser,
guesslang: "scala",
}),
}),
new Language({
token: "mermaid",
name: "Mermaid",
parser: mermaidLanguage.parser ,
guesslang: null,
}),
]

View File

@ -10,13 +10,14 @@
SearchQuery,
selectMatches,
setSearchQuery,
} from "../codemirror-search/search.ts"
} from "@codemirror/search"
import { mapStores } from 'pinia'
import { useSettingsStore } from "@/src/stores/settings-store.js"
import { getActiveNoteBlock } from "../block/block.js"
import InputToggle from "./InputToggle.vue"
import { delimiterRegexWithoutNewline } from "../block/block.js"
import { heynoteEvent, SEARCH_SETTINGS_UPDATED } from "../annotation.js"
import { searchTestFunction } from "./search-match-filter.js"
export default {
name: "SearchPanel",
@ -86,16 +87,9 @@
regexp: this.regexp,
wholeWord: this.wholeWord,
literal: true,
test: (from, to, buffer, bufferPos) => {
//console.log("test()", from, to, buffer, bufferPos);
return !delimiterRegexWithoutNewline.test(buffer) && (
this.onlyCurrentBlock ? from >= this.currentBlock.content.from && to <= this.currentBlock.content.to : true
)
},
regexpTest: (from, to, match) => {
return !delimiterRegexWithoutNewline.test(match.input) && (
this.onlyCurrentBlock ? from >= this.currentBlock.content.from && to <= this.currentBlock.content.to : true
)
test: (matchedStr, state, from, to) => {
const line = state.doc.lineAt(from)
return searchTestFunction(this.onlyCurrentBlock, this.currentBlock)(from, to, line.text, line.from)
},
replace: this.replaceStr,
});

View File

@ -0,0 +1,29 @@
import { delimiterRegexWithoutNewline } from "../block/block.js"
import { WIDGET_TAG_REGEX } from "../image/image-parsing.js"
export function searchTestFunction(onlyCurrentBlock, currentBlock) {
return (from, to, buffer, bufferPos) => {
//console.log("buffer:", buffer, bufferPos, "from:", from, "to:", to)
let localFrom = from - bufferPos, localTo = to - bufferPos
const imageMatches = buffer.matchAll(WIDGET_TAG_REGEX)
for (let match of imageMatches) {
//console.log("match:", match, "localFrom:", localFrom, "localTo:", localTo, "index:", match.index, "length:", match[0].length)
// if bufferPos is undefined, it means that the searchTestFunction is used as argument for regexpTest and then
// we don't have the
const tagFrom = match.index
const tagTo = match.index + match[0].length
if (localFrom < tagTo && tagFrom < localTo) {
WIDGET_TAG_REGEX.lastIndex = 0
return false
}
}
return !delimiterRegexWithoutNewline.test(buffer) && (
onlyCurrentBlock ? from >= currentBlock.content.from && to <= currentBlock.content.to : true
)
}
}

View File

@ -1,4 +1,4 @@
import { search } from "../codemirror-search/search.ts"
import { search } from "@codemirror/search"
import { createApp } from "vue"

View File

@ -2,12 +2,13 @@
import { EditorView, ViewPlugin, Decoration } from "@codemirror/view"
import { EditorSelection, Facet, combineConfig, CharCategory } from "@codemirror/state"
import { SearchCursor } from "../codemirror-search/search.ts"
import { SearchCursor } from "@codemirror/search"
import { useSettingsStore } from "@/src/stores/settings-store.js"
import { getActiveNoteBlock } from "../block/block.js"
import { delimiterRegexWithoutNewline } from "../block/block.js"
import { transactionsHasAnnotation, SEARCH_SETTINGS_UPDATED } from "../annotation.js"
import { searchTestFunction } from "./search-match-filter.js"
@ -152,11 +153,8 @@ function currentBlockTestFilter(state) {
const settingsStore = useSettingsStore()
const currentBlock = getActiveNoteBlock(state)
const onlyCurrentBlock = settingsStore.settings.searchSettings === undefined ? true : settingsStore.settings.searchSettings.onlyCurrentBlock
return (from, to, buffer, bufferPos) => {
return !delimiterRegexWithoutNewline.test(buffer) && (
onlyCurrentBlock ? from >= currentBlock.content.from && to <= currentBlock.content.to : true
)
}
return searchTestFunction(onlyCurrentBlock, currentBlock)
}

View File

@ -4,7 +4,6 @@ import { EditorState } from '@codemirror/state';
import { foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language';
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
//import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { highlightSelectionMatches, searchKeymap } from './codemirror-search/search.ts';
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';

View File

@ -2,6 +2,9 @@ import { EditorView } from '@codemirror/view';
export const heynoteBase = EditorView.theme({
".cm-line": {
padding: "0 6px",
},
".cm-panels": {
fontSize: "12px",
},
@ -114,6 +117,7 @@ export const heynoteBase = EditorView.theme({
},
'.heynote-math-result .inner': {
background: '#48b57e',
//background: '#4892b5',
color: '#fff',
padding: '0px 4px',
borderRadius: '2px',

View File

@ -10,7 +10,7 @@ base01 = '#3b4252', // dark grey
base02 = '#434c5e', base03 = '#4c566a'; // grey
// Snow Storm
const base04 = '#d8dee9', // grey
base05 = '#e5e9f0', // off white
base05 = '#c7cad0', // off white
base06 = '#eceff4'; // white
// Frost
const base07 = '#8fbcbb', // moss green
@ -22,7 +22,9 @@ const base0b = '#bf616a', // red
base0C = '#d08770', // orange
base0D = '#ebcb8b', // yellow
base0E = '#a3be8c', // green
base0F = '#b48ead'; // purple
base0F = '#b48ead', // purple
base10 = '#c6c097'; // light yellow
const invalid = '#d30102', darkBackground = '#252a33', background = '#1e222a', tooltipBackground = base01, cursor = '#fff';
const highlightBackground = 'rgba(255,255,255,0.04)';
@ -143,7 +145,7 @@ const darkTheme = EditorView.theme({
},
".heynote-math-result .inner": {
background: "#0e1217",
color: "#a0e7c7",
color: "#96cbb4",
boxShadow: '0 0 3px rgba(0,0,0, 0.3)',
},
'.heynote-math-result-copied': {
@ -168,7 +170,7 @@ const darkHighlightStyle = HighlightStyle.define([
color: base08
},
{ tag: [tags.variableName], color: base07 },
{ tag: [tags.function(tags.variableName)], color: base07 },
{ tag: [tags.function(tags.variableName)], color: base10 },
{ tag: [tags.labelName], color: base09 },
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
@ -190,7 +192,7 @@ const darkHighlightStyle = HighlightStyle.define([
},
{
tag: [tags.operator, tags.operatorKeyword],
color: base0E
color: base05,
},
{
tag: [tags.tagName],

View File

@ -66,6 +66,10 @@ const lightTheme = EditorView.theme({
background: "#f4f8f4",
borderTop: "1px solid #dfdfdf",
},
".cm-taskmarker-toggle input[type=checkbox]": {
accentColor: "#1f8deb",
},
})
const highlightStyle = HighlightStyle.define([
@ -73,6 +77,10 @@ const highlightStyle = HighlightStyle.define([
// override heading style, in order to remove the ugly underline
{ tag: tags.heading, fontWeight: 'bold'},
//{ tag: [tags.function(tags.variableName)], color: "#00f" },
{ tag: [tags.function(tags.variableName)], color: "#906c00" },
{ tag: [tags.number], color: "#1a557e" },
])
const heynoteLight = [

View File

@ -1,6 +1,7 @@
import { toRaw, nextTick, watch } from 'vue';
import { defineStore } from "pinia"
import { NoteFormat } from "../common/note-format"
import { toSafeBrowserLocale } from "../util/locale.js"
import { useEditorCacheStore } from "./editor-cache"
import {
SCRATCH_FILE_NAME, WINDOW_FULLSCREEN_STATE, WINDOW_FOCUS_STATE,
@ -35,6 +36,9 @@ export const useHeynoteStore = defineStore("heynote", {
showEditBuffer: false,
showMoveToBufferSelector: false,
showCommandPalette: false,
showDrawImageModal: false,
drawImageUrl: null,
drawImageId: null,
isFullscreen: false,
isFocused: true,
@ -198,6 +202,17 @@ export const useHeynoteStore = defineStore("heynote", {
}
this.showCreateBuffer = true
},
openDrawImageModal(imageUrl, imageId) {
this.closeDialog()
this.drawImageUrl = imageUrl
this.drawImageId = imageId
this.showDrawImageModal = true
},
closeDrawImageModal() {
this.showDrawImageModal = false
this.drawImageUrl = null
this.drawImageId = null
},
closeDialog() {
this.showCreateBuffer = false
this.showBufferSelector = false
@ -205,6 +220,9 @@ export const useHeynoteStore = defineStore("heynote", {
this.showEditBuffer = false
this.showMoveToBufferSelector = false
this.showCommandPalette = false
this.showDrawImageModal = false
this.drawImageUrl = null
this.drawImageId = null
},
closeBufferSelector() {
@ -380,5 +398,5 @@ export async function initHeynoteStore() {
watch(() => heynoteStore.currentBufferPath, () => heynoteStore.saveTabsState())
watch(() => heynoteStore.openTabs, () => heynoteStore.saveTabsState())
heynoteStore.systemLocale = await window.heynote.getSystemLocale()
heynoteStore.systemLocale = toSafeBrowserLocale(await window.heynote.getSystemLocale())
}

23
src/util/locale.js Normal file
View File

@ -0,0 +1,23 @@
export function toSafeBrowserLocale(locale) {
// first attempt: maybe it's already fine
try {
return Intl.getCanonicalLocales(locale)[0];
} catch { }
// underscores -> hyphens (en_US -> en-US)
locale = locale.replace(/_/g, "-")
// drop ".UTF-8", ".utf8", etc.
locale = locale.replace(/\.[A-Za-z0-9_-]+$/, "")
// If there's an ICU/POSIX modifier like @calendar=... or @euro, we must drop it
// (Intl doesnt understand "@...").
locale = locale.split("@", 1)[0];
// try again after normalization
try {
return Intl.getCanonicalLocales(locale)[0]
} catch { }
// last resort
return navigator.language
}

View File

@ -0,0 +1,188 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { SCRATCH_FILE_NAME } from "../../src/common/constants.js"
vi.mock("electron", () => ({
app: { quit: vi.fn(), getPath: vi.fn() },
ipcMain: { handle: vi.fn() },
dialog: { showOpenDialog: vi.fn() },
}))
vi.mock("../../electron/config.js", () => ({
default: { get: vi.fn(() => "") },
}))
const getImgReferencesMock = vi.fn(async () => [])
const loadFileLibrary = async ({ mockRipgrep = false } = {}) => {
vi.resetModules()
if (mockRipgrep) {
vi.doMock("../../electron/main/ripgrep.js", () => ({
searchLibrary: vi.fn(),
getImgReferences: (...args) => getImgReferencesMock(...args),
}))
} else {
vi.unmock("../../electron/main/ripgrep.js")
}
return await import("../../electron/main/file-library.js")
}
const makeTempDir = () =>
fs.mkdtempSync(path.join(os.tmpdir(), "heynote-file-library-"))
describe("FileLibrary", () => {
let tmpDir = ""
let library = null
beforeEach(() => {
tmpDir = makeTempDir()
getImgReferencesMock.mockClear()
})
afterEach(() => {
if (library) {
library.close()
library = null
}
fs.rmSync(tmpDir, { recursive: true, force: true })
})
it("creates the scratch file if missing", async () => {
const { FileLibrary } = await loadFileLibrary()
library = new FileLibrary(tmpDir, {})
const scratchPath = path.join(tmpDir, SCRATCH_FILE_NAME)
expect(fs.existsSync(scratchPath)).toBe(true)
})
it("creates, loads, and saves notes", async () => {
const { FileLibrary } = await loadFileLibrary()
library = new FileLibrary(tmpDir, {})
await library.create("note.txt", "hello")
const content = await library.load("note.txt")
expect(content).toBe("hello")
await library.save("note.txt", "updated")
const updated = fs.readFileSync(path.join(tmpDir, "note.txt"), "utf8")
expect(updated).toBe("updated")
})
it("moves and deletes notes", async () => {
const { FileLibrary } = await loadFileLibrary()
library = new FileLibrary(tmpDir, {})
await library.create("note.txt", "content")
await library.move("note.txt", "moved.txt")
expect(fs.existsSync(path.join(tmpDir, "note.txt"))).toBe(false)
expect(fs.existsSync(path.join(tmpDir, "moved.txt"))).toBe(true)
await library.delete("moved.txt")
expect(fs.existsSync(path.join(tmpDir, "moved.txt"))).toBe(false)
})
it("rejects deleting the scratch file", async () => {
const { FileLibrary } = await loadFileLibrary()
library = new FileLibrary(tmpDir, {})
await expect(library.delete(SCRATCH_FILE_NAME)).rejects.toThrow(
"Can't delete scratch file"
)
})
it("returns metadata from getList", async () => {
const { FileLibrary } = await loadFileLibrary()
library = new FileLibrary(tmpDir, {})
const metadata = { name: "Test Note", tags: ["a", "b"] }
const note = `${JSON.stringify(metadata)}\n∞∞∞text\nbody`
await fs.promises.writeFile(path.join(tmpDir, "meta.txt"), note, "utf8")
await fs.promises.writeFile(
path.join(tmpDir, "plain.txt"),
"no metadata",
"utf8"
)
const list = await library.getList()
expect(list["meta.txt"]).toEqual(metadata)
expect(list["plain.txt"]).toBeNull()
})
it("detects changes with NoteBuffer.loadIfChanged", async () => {
const { FileLibrary, NoteBuffer } = await loadFileLibrary()
library = new FileLibrary(tmpDir, {})
const fullPath = path.join(tmpDir, "change.txt")
await fs.promises.writeFile(fullPath, "first", "utf8")
const buffer = new NoteBuffer({ fullPath, library })
await buffer.load()
expect(await buffer.loadIfChanged()).toBeNull()
await fs.promises.writeFile(fullPath, "second", "utf8")
expect(await buffer.loadIfChanged()).toBe("second")
})
it("removes unreferenced images older than a day when references exist", async () => {
const { FileLibrary } = await loadFileLibrary()
const imagesDir = path.join(tmpDir, ".images")
await fs.promises.mkdir(imagesDir)
const referencedImage = "keep.png"
const orphanImage = "delete.png"
await fs.promises.writeFile(path.join(imagesDir, referencedImage), "")
await fs.promises.writeFile(path.join(imagesDir, orphanImage), "")
const noteContent = `<∞img;id=1;file=heynote-file://image/${referencedImage};w=1;h=1∞>`
await fs.promises.writeFile(path.join(tmpDir, "note.txt"), noteContent)
library = new FileLibrary(tmpDir, {})
const oldTime = new Date(Date.now() - 1000 * 60 * 60 * 25)
await fs.promises.utimes(
path.join(imagesDir, orphanImage),
oldTime,
oldTime
)
await library.removeUnreferencedImages()
expect(fs.existsSync(path.join(imagesDir, referencedImage))).toBe(true)
expect(fs.existsSync(path.join(imagesDir, orphanImage))).toBe(false)
})
it("keeps unreferenced images when no references exist", async () => {
const { FileLibrary } = await loadFileLibrary()
const imagesDir = path.join(tmpDir, ".images")
await fs.promises.mkdir(imagesDir)
const orphanImage = "keep.png"
await fs.promises.writeFile(path.join(imagesDir, orphanImage), "")
const oldTime = new Date(Date.now() - 1000 * 60 * 60 * 25)
await fs.promises.utimes(
path.join(imagesDir, orphanImage),
oldTime,
oldTime
)
library = new FileLibrary(tmpDir, {})
await library.removeUnreferencedImages()
expect(fs.existsSync(path.join(imagesDir, orphanImage))).toBe(true)
})
it("keeps images when ripgrep fails", async () => {
const { FileLibrary } = await loadFileLibrary({ mockRipgrep: true })
const imagesDir = path.join(tmpDir, ".images")
await fs.promises.mkdir(imagesDir)
const orphanImage = "keep.png"
await fs.promises.writeFile(path.join(imagesDir, orphanImage), "")
getImgReferencesMock.mockRejectedValueOnce(new Error("rg failed"))
library = new FileLibrary(tmpDir, {})
await library.removeUnreferencedImages()
expect(fs.existsSync(path.join(imagesDir, orphanImage))).toBe(true)
})
})

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest"
import {
createImageTag,
parseImagesFromString,
} from "../../src/editor/image/image-parsing.js"
describe("image parsing", () => {
it("parses image tags with required and optional params", () => {
const tag = "<∞img;id=abc;file=heynote-file://image/test.png;w=1200;h=630;dw=300;dh=200;hidden∞>"
const content = `before ${tag} after`
const images = parseImagesFromString(content)
expect(images).toHaveLength(1)
expect(images[0]).toEqual({
from: content.indexOf(tag),
to: content.indexOf(tag) + tag.length,
id: "abc",
file: "heynote-file://image/test.png",
width: 1200,
height: 630,
displayWidth: 300,
displayHeight: 200,
})
})
it("ignores tags missing required params", () => {
const tag1 = "<∞img;id=1;file=a.png;w=10;h=20∞>"
const invalid = "<∞img;id=2;file=b.png;w=10∞>"
const tag2 = "<∞img;id=3;file=c.png;w=30;h=40;dw=15∞>"
const content = `${tag1} middle ${invalid} tail ${tag2}`
const images = parseImagesFromString(content)
expect(images).toHaveLength(2)
expect(images[0].id).toBe("1")
expect(images[1].id).toBe("3")
expect(images[1].displayWidth).toBe(15)
expect(images[1].displayHeight).toBeUndefined()
})
it("does not leak regex state between calls", () => {
const tag = "<∞img;id=1;file=a.png;w=10;h=20∞>"
const first = parseImagesFromString(tag)
const second = parseImagesFromString(`prefix ${tag}`)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].from).toBe("prefix ".length)
})
})
describe("image tag creation", () => {
it("creates tag with display dimensions", () => {
const image = {
id: "1",
file: "heynote-file://image/test.png",
width: 100,
height: 200,
displayWidth: 50,
displayHeight: 60,
}
expect(createImageTag(image)).toBe(
"<∞img;id=1;file=heynote-file://image/test.png;w=100;h=200;dw=50;dh=60∞>"
)
})
it("omits display dimensions when missing", () => {
const image = {
id: "1",
file: "heynote-file://image/test.png",
width: 100,
height: 200,
}
expect(createImageTag(image)).toBe(
"<∞img;id=1;file=heynote-file://image/test.png;w=100;h=200∞>"
)
})
})

View File

@ -56,3 +56,19 @@ test("press tab", async ({ page }) => {
await page.locator("body").press("Tab")
expect(await heynotePage.getBlockContent(0)).toBe("H ello\n ")
})
test("indentation is preserved on enter in plain text block", async ({ page }) => {
await page.locator("body").pressSequentially(" Indented line")
await page.locator("body").press("Enter")
await page.locator("body").pressSequentially("Next line")
expect(await heynotePage.getBlockContent(0)).toBe(" Indented line\n Next line")
})
test("python indentation increases after colon on enter", async ({ page }) => {
await heynotePage.setContent(`
python
def func():`)
await heynotePage.setCursorPosition((await heynotePage.getContent()).length)
await page.locator("body").press("Enter")
expect(await heynotePage.getBlockContent(0)).toBe("def func():\n ")
})

View File

@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test"
import { EditorState } from "@codemirror/state"
import { heynoteLang } from "../src/editor/lang-heynote/heynote.js"
import { getBlocksFromSyntaxTree, getBlocksFromString } from "../src/editor/block/block-parsing.js"
import { heynoteLang } from "@/src/editor/lang-heynote/heynote.js"
import { getBlocksFromSyntaxTree, getBlocksFromString } from "@/src/editor/block/block-parsing.js"
test("parse blocks from both syntax tree and string contents", async ({page}) => {
const contents = `

View File

@ -1,8 +1,8 @@
import {expect, test} from "@playwright/test";
import {HeynotePage} from "./test-utils.js";
import { AUTO_SAVE_INTERVAL } from "../src/common/constants.js"
import { NoteFormat } from "../src/common/note-format.js"
import { AUTO_SAVE_INTERVAL } from "@/src/common/constants.js"
import { NoteFormat } from "@/src/common/note-format.js"
let heynotePage

View File

@ -20,7 +20,7 @@ test("add custom key binding", async ({page}) => {
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog")).toBeVisible()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog input.keys")).toBeFocused()
await page.locator("body").press("Control+Shift+H")
await page.locator("body").press("Enter")
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .command-field input").click()
await page.locator("body").pressSequentially("language")
await page.locator(".p-autocomplete-list li.p-autocomplete-option.p-focus").click()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .save").click()
@ -36,6 +36,30 @@ test("add custom key binding", async ({page}) => {
)
})
test("add enter as custom key binding", async ({page}) => {
await page.locator("css=.status-block.settings").click()
await page.locator("css=.overlay .settings .dialog .sidebar li.tab-keyboard-bindings").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings")).toBeVisible()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-keybinding").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog")).toBeVisible()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog input.keys")).toBeFocused()
await page.locator("body").press("Control+Enter")
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .command-field input").click()
await page.locator("body").pressSequentially("language")
await page.locator(".p-autocomplete-list li.p-autocomplete-option.p-focus").click()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .save").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings table tr.keybind-user")).toHaveCount(1)
expect((await heynotePage.getSettings()).keyBindings).toEqual([{key:"Ctrl-Enter", command:"openLanguageSelector"}])
await page.locator("css=.overlay .settings .dialog .bottom-bar .close").click()
await page.locator("body").press("Control+Enter")
await expect(page.locator("css=.language-selector .items > li.selected")).toBeVisible()
await page.locator("body").press("Escape")
await expect(page.locator("css=.status .status-block.lang")).toHaveAttribute(
"title",
"Change language for current block (Ctrl+Enter)"
)
})
test("delete custom key binding", async ({page}) => {
await page.locator("css=.status-block.settings").click()
await page.locator("css=.overlay .settings .dialog .sidebar li.tab-keyboard-bindings").click()
@ -44,7 +68,7 @@ test("delete custom key binding", async ({page}) => {
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog")).toBeVisible()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog input.keys")).toBeFocused()
await page.locator("body").press("Control+Shift+H")
await page.locator("body").press("Enter")
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .command-field input").click()
await page.locator("body").pressSequentially("language")
await page.locator(".p-autocomplete-list li.p-autocomplete-option.p-focus").click()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .save").click()

View File

@ -1,8 +1,8 @@
import {expect, test} from "@playwright/test";
import {HeynotePage} from "./test-utils.js";
import { AUTO_SAVE_INTERVAL } from "../src/common/constants.js"
import { NoteFormat } from "../src/common/note-format.js"
import { AUTO_SAVE_INTERVAL } from "@/src/common/constants.js"
import { NoteFormat } from "@/src/common/note-format.js"
let heynotePage

View File

@ -0,0 +1,173 @@
import { test, expect } from "@playwright/test";
import { HeynotePage } from "./test-utils.js";
import { parseImagesFromString } from "../../src/editor/image/image-parsing.js";
let heynotePage;
const buildContent = (tag) => `
text
hello ${tag} world`;
async function openDrawModal(page) {
const imageWidget = page.locator(".heynote-image");
await expect(imageWidget).toBeVisible();
await imageWidget.hover();
const drawButton = page.locator(".heynote-image .buttons-container .draw");
if (await drawButton.isVisible()) {
await drawButton.click();
return;
}
}
test.beforeEach(async ({ page, browserName }) => {
test.skip(browserName === "webkit", "Hovering in headless Webkit is flaky")
await page.addInitScript(() => {
// Playwright can't intercept custom schemes like heynote-file:// with page.route,
// because Chromium never issues a network request. Rewrite image src instead.
const original = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src");
if (!original || typeof original.set !== "function" || typeof original.get !== "function") {
return;
}
Object.defineProperty(HTMLImageElement.prototype, "src", {
configurable: true,
enumerable: original.enumerable,
get() {
return original.get.call(this);
},
set(value) {
// Swap heynote-file://image/... for the mocked saveImage data URL so the
// widget can render in browser tests.
if (typeof value === "string" && value.startsWith("heynote-file://image/")) {
const replacement = window.__mockSavedImageDataUrl || "/icon.png";
return original.set.call(this, replacement);
}
return original.set.call(this, value);
},
});
});
heynotePage = new HeynotePage(page);
await heynotePage.goto();
await heynotePage.page.evaluate(async () => {
const img = new Image();
img.src = "/icon.png";
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
window.__baseImageDataUrl = canvas.toDataURL("image/png");
const base64 = window.__baseImageDataUrl.split(",")[1] || "";
window.__baseImageSize = atob(base64).length;
});
});
test("draw modal saves image and updates tag", async () => {
await heynotePage.page.evaluate(() => {
window.__saveImageCalls = [];
if (!window.heynote?.buffer) {
throw new Error("heynote buffer not available");
}
window.heynote.buffer.saveImage = async (payload) => {
window.__saveImageCalls.push({
mime: payload?.mime,
size: payload?.data?.length ?? payload?.data?.byteLength ?? 0,
});
if (payload?.data && payload?.mime) {
const bytes = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
window.__mockSavedImageDataUrl = `data:${payload.mime};base64,${btoa(binary)}`;
window.__mockSavedImageSize = bytes.length;
}
return "drawn-test.png";
};
});
const tag = "<∞img;id=img-draw-1;file=/icon.png;w=120;h=120∞>";
await heynotePage.setContent(buildContent(tag));
await openDrawModal(heynotePage.page);
const modal = heynotePage.page.locator(".draw-modal");
await expect(modal).toBeVisible();
await expect(modal.locator("canvas").first()).toBeVisible();
const drawCanvas = modal.locator("canvas.upper-canvas");
const box = await drawCanvas.boundingBox();
expect(box).not.toBeNull();
const startX = box.x + box.width * 0.3;
const startY = box.y + box.height * 0.3;
const endX = box.x + box.width * 0.6;
const endY = box.y + box.height * 0.6;
await heynotePage.page.mouse.move(startX, startY);
await heynotePage.page.mouse.down();
await heynotePage.page.mouse.move(endX, endY);
await heynotePage.page.mouse.up();
await modal.locator(".bottom-bar .save").click();
await expect(modal).toHaveCount(0);
await expect.poll(async () => {
return await heynotePage.page.evaluate(() => window.__saveImageCalls.length);
}).toBe(1);
const [saveCall] = await heynotePage.page.evaluate(() => window.__saveImageCalls);
expect(saveCall.mime).toBe("image/png");
expect(saveCall.size).toBeGreaterThan(0);
const sizeChanged = await heynotePage.page.evaluate(() => {
return window.__mockSavedImageSize !== window.__baseImageSize;
});
expect(sizeChanged).toBe(true);
const updatedContent = await heynotePage.getContent();
const images = parseImagesFromString(updatedContent);
const image = images.find((entry) => entry.id === "img-draw-1");
expect(image?.file).toBe("heynote-file://image/drawn-test.png");
});
test("draw modal saves without drawing keeps image unchanged", async () => {
await heynotePage.page.evaluate(() => {
window.__saveImageCalls = [];
window.heynote.buffer.saveImage = async (payload) => {
if (payload?.data && payload?.mime) {
const bytes = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
window.__mockSavedImageDataUrl = `data:${payload.mime};base64,${btoa(binary)}`;
window.__mockSavedImageSize = bytes.length;
}
return "drawn-test.png";
};
});
const tag = "<∞img;id=img-draw-2;file=/icon.png;w=120;h=120∞>";
await heynotePage.setContent(buildContent(tag));
await openDrawModal(heynotePage.page);
const modal = heynotePage.page.locator(".draw-modal");
await expect(modal).toBeVisible();
await expect(modal.locator("canvas").first()).toBeVisible();
await modal.locator(".bottom-bar .save").click();
await expect(modal).toHaveCount(0);
await expect.poll(async () => {
return await heynotePage.page.evaluate(() => window.__mockSavedImageSize || 0);
}).toBeGreaterThan(0);
const isUnchanged = await heynotePage.page.evaluate(() => {
return window.__mockSavedImageDataUrl === window.__baseImageDataUrl
&& window.__mockSavedImageSize === window.__baseImageSize;
});
expect(isUnchanged).toBe(true);
});

View File

@ -0,0 +1,206 @@
import { test, expect, _electron as electron } from '@playwright/test'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { FileLibrary } from '../../electron/main/file-library'
import { HeynotePage } from "./test-utils.js"
import { parseImagesFromString } from "../../src/editor/image/image-parsing.js"
async function ensureElectronBuild() {
const mainPath = path.join(process.cwd(), 'dist-electron', 'main', 'index.js')
const preloadPath = path.join(process.cwd(), 'dist-electron', 'preload', 'index.js')
try {
await fs.stat(mainPath)
await fs.stat(preloadPath)
return
} catch {
// build below
}
await new Promise((resolve, reject) => {
const child = spawn('npx', ['vite', 'build'], {
stdio: 'inherit',
env: {
...process.env,
},
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) {
resolve()
return
}
reject(new Error(`vite build failed with exit code ${code}`))
})
})
}
async function dirExists(path) {
try {
const stat = await fs.stat(path);
return stat.isDirectory();
} catch (err) {
if (err.code === "ENOENT") return false;
throw err; // other errors (permissions etc.)
}
}
async function removeDirWithRetry(dirPath, retries = 5) {
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
await fs.rm(dirPath, { recursive: true, force: true })
return
} catch (err) {
if (err.code !== "ENOTEMPTY" || attempt === retries) {
throw err
}
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
}
}
}
test.describe('electron app', { tag: "@e2e" }, () => {
test.describe.configure({ mode: 'serial' });
test.skip(({ browserName }) => browserName !== 'chromium', 'Electron runs only once under chromium')
/**@type{HeynotePage}*/
let heynotePage
let electronApp
let tmpRoot
let userDataDir
let page
test.beforeEach(async ({ }) => {
test.setTimeout(60000)
await ensureElectronBuild()
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'heynote-e2e-'))
userDataDir = path.join(tmpRoot, 'user-data')
await fs.mkdir(userDataDir)
electronApp = await electron.launch({
args: ['--no-sandbox', 'tests/playwright/electron-runner.cjs'],
env: {
...process.env,
HEYNOTE_TEST_USER_DATA_DIR: userDataDir,
},
})
page = await electronApp.firstWindow()
await page.waitForLoadState('domcontentloaded')
heynotePage = new HeynotePage(page)
//await heynotePage.goto()
})
test.afterEach(async ({ page }) => {
if (electronApp) {
await electronApp.close()
}
if (await dirExists(tmpRoot)) {
await removeDirWithRetry(tmpRoot)
}
})
test('uses temp user data and notes library', async () => {
await expect(page).toHaveTitle(/Heynote/i)
const userData = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const notesDir = path.join(userData, 'notes')
const scratchPath = path.join(notesDir, 'scratch.txt')
const configPath = path.join(userData, 'config.json')
await expect.poll(async () => {
return await fs.stat(scratchPath).then(() => true).catch(() => false)
}).toBe(true)
await expect.poll(async () => {
return await fs.stat(configPath).then(() => true).catch(() => false)
}).toBe(true)
expect(userData).toBe(userDataDir)
expect(configPath.startsWith(userDataDir)).toBe(true)
expect(notesDir.startsWith(userDataDir)).toBe(true)
})
test('buffer is saved to disk', async () => {
//heynotePage.goto()
//await page.waitForTimeout(3000)
await expect.poll(async () => (await heynotePage.getBlocks()).length).toBeGreaterThan(0)
//console.log("blocks:", await heynotePage.getBlocks())
await page.locator("body").press(heynotePage.agnosticKey("Mod+A"))
await page.locator("body").press(heynotePage.agnosticKey("Mod+A"))
page.locator("body").pressSequentially("Hello World!")
await expect.poll(async () => await heynotePage.getBlockContent(0)).toBe("Hello World!")
await heynotePage.waitForContentSaved()
const library = new FileLibrary(path.join(userDataDir, 'notes'))
const bufferContent = await library.load("scratch.txt")
expect(bufferContent.endsWith("Hello World!")).toBeTruthy()
})
test('pastes image data, stores it, and copy button writes image to clipboard', async () => {
await expect.poll(async () => (await heynotePage.getBlocks()).length).toBeGreaterThan(0)
await page.evaluate(async () => {
const canvas = document.createElement("canvas")
canvas.width = 300
canvas.height = 300
const ctx = canvas.getContext("2d")
ctx.fillStyle = "#1e90ff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, "image/png")
)
const item = new ClipboardItem({ [blob.type]: blob })
window.__testClipboardItems = [item]
window.__clipboardWriteItems = []
navigator.clipboard.read = async () => window.__testClipboardItems
navigator.clipboard.write = async (items) => {
window.__clipboardWriteItems = items
}
})
await page.locator("body").press(heynotePage.agnosticKey("Mod+V"))
await expect.poll(async () => {
const content = await heynotePage.getContent()
return parseImagesFromString(content)
}).not.toHaveLength(0)
await expect(page.locator(".heynote-image")).toHaveCount(1)
const content = await heynotePage.getContent()
const images = parseImagesFromString(content)
expect(images).toHaveLength(1)
expect(images[0].file.startsWith("heynote-file://image/")).toBe(true)
const encodedName = images[0].file.replace("heynote-file://image/", "")
const filename = decodeURIComponent(encodedName)
const storedPath = path.join(userDataDir, "notes", ".images", filename)
await expect.poll(async () => {
return await fs.stat(storedPath).then((stat) => stat.size > 0).catch(() => false)
}).toBe(true)
//await page.waitForTimeout(100000)
await page.evaluate(() => {
const button = document.querySelector(".heynote-image .buttons-container button")
if (!button) {
throw new Error("Copy button not found")
}
button.click()
})
await expect.poll(async () => {
const types = await page.evaluate(() => {
return (window.__clipboardWriteItems || []).flatMap((item) => item.types)
})
return types.some((type) => type.startsWith("image/"))
}).toBe(true)
})
})

View File

@ -0,0 +1,8 @@
const path = require('node:path')
const { app } = require('electron')
if (process.env.HEYNOTE_TEST_USER_DATA_DIR) {
app.setPath('userData', process.env.HEYNOTE_TEST_USER_DATA_DIR)
}
require(path.join(process.cwd(), 'dist-electron', 'main', 'index.js'))

View File

@ -99,7 +99,9 @@ test`)
await expect.poll(async () => await heynotePage.getBlockContent(0)).toBe("1test1")
})
test("cut multiple blocks", async ({ page }) => {
test("cut multiple blocks", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.locator("body").pressSequentially("block1")
await page.locator("body").press(heynotePage.agnosticKey("Mod+Enter"))
await page.locator("body").pressSequentially("block2")
@ -114,8 +116,14 @@ test("cut multiple blocks", async ({ page }) => {
await expect.poll(async () => (await heynotePage.getBlocks()).length).toBe(1)
await expect.poll(async () => await heynotePage.getBlockContent(0)).toBe("")
// paste content and check that block separator was replaced with \n\n
// check that block separator was replaced with \n\n for clipboard plain text content
const handle = await page.evaluateHandle(() => navigator.clipboard.readText())
const clipboardTextContent = await handle.jsonValue()
expect(clipboardTextContent).toEqual("block1\n\nblock2")
// paste content and check that block separator was preserved
await page.locator("body").press("Control+Y")
await expect.poll(async () => (await heynotePage.getBlocks()).length).toBe(1)
await expect.poll(async () => await heynotePage.getBlockContent(0)).toBe("block1\n\nblock2")
await expect.poll(async () => (await heynotePage.getBlocks()).length).toBe(2)
//await expect.poll(async () => await heynotePage.getBlockContent(0)).toBe("block1\n\nblock2")
})

View File

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { HeynotePage } from "./test-utils.js";
import { NoteFormat } from "../src/common/note-format.js";
import { NoteFormat } from "@/src/common/note-format.js";
import { formatDate, formatFullDate } from "@/src/common/format-date.js"
export const delimiterRegex = /\n∞∞∞[a-z]+(-a)?(?:;[^\\n]+)*\n/
@ -533,6 +533,33 @@ Block A line 3`)
expect(content).toContain("Block A line 3Y")
});
test("typing in empty block with empty block below does not unfold previous folded block", async ({ page }) => {
await heynotePage.setContent(`
text
Block A line 1
Block A line 2
Block A line 3
text
`)
// Fold the first block
await heynotePage.setCursorPosition(20) // Middle of Block A
const foldKey = heynotePage.isMac ? "Alt+Meta+[" : "Alt+Control+["
await page.locator("body").press(foldKey)
await expect(page.locator(".cm-foldPlaceholder")).toHaveCount(1)
const blocks = await heynotePage.getBlocks()
expect(blocks).toHaveLength(2)
expect(await heynotePage.getBlockContent(1)).toBe("")
// Type into the empty middle block while another empty block follows it
await heynotePage.setCursorPosition(blocks[1].content.from)
await page.locator("body").pressSequentially("a")
// The folded block should remain folded
await expect(page.locator(".cm-foldPlaceholder")).toHaveCount(1)
});
test("folded block does not unfold when language changes", async ({ page }) => {
// Set up test content with a multi-line block
await heynotePage.setContent(`

Some files were not shown because too many files have changed in this diff Show More