Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f237f2e59 | ||
|
|
ca5c013ba7 | ||
|
|
573b93332a | ||
|
|
51ee9e3416 | ||
|
|
1f7558d54a | ||
|
|
e951af3a52 | ||
|
|
ba78d8a9e4 | ||
|
|
b9f0171ada | ||
|
|
c2b53c0cc8 | ||
|
|
542d4ca15f | ||
|
|
4e974a8550 | ||
|
|
432573e80d | ||
|
|
d995c4be4d | ||
|
|
555dc27af6 | ||
|
|
a80b20e5b2 | ||
|
|
04dc51a4fe | ||
|
|
86d943e34c | ||
|
|
19483315af | ||
|
|
236be0ef81 | ||
|
|
8cab62b439 | ||
|
|
cc1a0a90c6 | ||
|
|
a090607cd6 | ||
|
|
f5435e8f57 | ||
|
|
ec87ce975c | ||
|
|
e3ea5d7b8c | ||
|
|
52f23964be | ||
|
|
19b7c15ad9 | ||
|
|
79b44d85a4 | ||
|
|
9a36ccc860 | ||
|
|
5fdced8afa | ||
|
|
e9d5a50613 | ||
|
|
d744d5f9bf | ||
|
|
b6c005055a | ||
|
|
7bc9694b2a | ||
|
|
3ab26057f1 | ||
|
|
baf60cb55d | ||
|
|
b3229b7af4 | ||
|
|
f81602b20d | ||
|
|
a804e50dd6 | ||
|
|
3478503c33 | ||
|
|
cd10ddb53e | ||
|
|
14621c7392 | ||
|
|
f4cae94121 | ||
|
|
7763c2c772 | ||
|
|
6b91d1b071 | ||
|
|
a33f74068b | ||
|
|
3474419521 | ||
|
|
0446f7b090 | ||
|
|
8de030c3d6 | ||
|
|
0bca4a8c89 | ||
|
|
2419ece730 | ||
|
|
e79657de04 | ||
|
|
a7a2728447 | ||
|
|
984da05310 | ||
|
|
31e62b1453 | ||
|
|
e6d1c74bc5 | ||
|
|
7e33a2de68 | ||
|
|
d8a3418e74 | ||
|
|
0222c805bf | ||
|
|
7a2f915fe9 | ||
|
|
cedbe0d5b2 | ||
|
|
d62d2e6696 | ||
|
|
52634ec5a9 | ||
|
|
6b2ed7c1b0 | ||
|
|
55b5630fd7 | ||
|
|
25bd017ab0 | ||
|
|
ec1a4c1e22 | ||
|
|
eccfba2d4e | ||
|
|
176cafc06d | ||
|
|
830dd6f549 | ||
|
|
de86737fb3 | ||
|
|
48c1fd139b | ||
|
|
e7f7f7f7b7 | ||
|
|
afb2dbbb77 | ||
|
|
e1e2163d9f | ||
|
|
7f624f8f87 | ||
|
|
32fd508628 | ||
|
|
bd4cbad307 | ||
|
|
cf3aae3c26 | ||
|
|
cfc596e1f7 | ||
|
|
1955750675 | ||
|
|
1f16fc6efe | ||
|
|
9737723eed | ||
|
|
759043c034 | ||
|
|
72ea8e5e6a | ||
|
|
d0e6aa0cd9 | ||
|
|
6b41281c9f | ||
|
|
2e1b7e9a41 | ||
|
|
0559a05c3f | ||
|
|
ac8d7c36ca | ||
|
|
3f6686c46d | ||
|
|
ba53bc1199 | ||
|
|
c4f716ada7 | ||
|
|
00e35bd4ab | ||
|
|
9f10f4f33b | ||
|
|
34c9d679fb | ||
|
|
c795ff2bfd | ||
|
|
d073442b13 | ||
|
|
dfa73927ea | ||
|
|
bc32aa835f | ||
|
|
196cb45be4 | ||
|
|
eee2bfe94d | ||
|
|
ef3c4677b8 | ||
|
|
950f0199e8 | ||
|
|
d0b116696c | ||
|
|
e13f5d95de | ||
|
|
8f16ab3d0c | ||
|
|
fdae1c1709 | ||
|
|
e5f643a97d | ||
|
|
12bd808b3d | ||
|
|
706bd96943 | ||
|
|
97aeb8e41e | ||
|
|
62ee1dbf69 | ||
|
|
60cdaf2047 | ||
|
|
b90719ee16 | ||
|
|
af71893ab3 | ||
|
|
72955e1c23 |
2
.github/workflows/build.yml
vendored
@ -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' }}
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
5
assets/icons/arrow-pointer-black.svg
Normal 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 |
5
assets/icons/arrow-pointer-white.svg
Normal 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 |
5
assets/icons/copy-dark.svg
Normal 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 |
5
assets/icons/fit-zoom-dark.svg
Normal 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 |
5
assets/icons/fit-zoom-light.svg
Normal 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 |
7
assets/icons/minus-bright-dark.svg
Normal 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 |
7
assets/icons/minus-bright-light.svg
Normal 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 |
5
assets/icons/paint-black.svg
Normal 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 |
5
assets/icons/paint-white.svg
Normal 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 |
5
assets/icons/pencil-white.svg
Normal 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 |
7
assets/icons/plus-bright-dark.svg
Normal 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 |
7
assets/icons/plus-bright-light.svg
Normal 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 |
5
assets/icons/redo-black.svg
Normal 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 |
5
assets/icons/redo-white.svg
Normal 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 |
BIN
assets/icons/resize-handle-se-dark.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/icons/resize-handle-se-light.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
17
assets/icons/shadow-dark.svg
Normal 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 |
17
assets/icons/shadow-light.svg
Normal 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 |
5
assets/icons/undo-black.svg
Normal 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 |
5
assets/icons/undo-white.svg
Normal 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 |
@ -2,6 +2,36 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
@ -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
@ -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
|
||||
}
|
||||
|
||||
@ -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
25
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Heynote",
|
||||
"version": "2.7.1",
|
||||
"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"
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
65
scripts/electron/prepare-rg-universal.js
Normal 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);
|
||||
});
|
||||
@ -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;([^∞>]*)∞>"
|
||||
|
||||
@ -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>
|
||||
|
||||
1030
src/components/draw/DrawImageModal.vue
Normal file
@ -97,7 +97,7 @@
|
||||
ref="keys"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field command-field">
|
||||
<label>Command</label>
|
||||
<AutoComplete
|
||||
dropdown
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -2,3 +2,4 @@
|
||||
@use "font"
|
||||
@use "base"
|
||||
@use "autocomplete"
|
||||
@use "image"
|
||||
|
||||
@ -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
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
281
src/editor/clipboard/copy-paste.js
Normal 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 })])
|
||||
}
|
||||
}
|
||||
105
src/editor/clipboard/drag-drop.js
Normal 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,
|
||||
})
|
||||
}
|
||||
76
src/editor/clipboard/serialize.js
Normal 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("&", "&")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
|
||||
try {
|
||||
return escapeHtmlAttr(await imageUrlToDataUrl(image.file))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return escapeHtmlAttr(image.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
|
||||
@ -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 }
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
]
|
||||
@ -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
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -4,6 +4,7 @@ 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 { 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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
129
src/editor/image/image-parsing.js
Normal 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
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
29
src/editor/search/search-match-filter.js
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { search } from "../codemirror-search/search.ts"
|
||||
import { search } from "@codemirror/search"
|
||||
|
||||
import { createApp } from "vue"
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
@ -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 doesn’t understand "@...").
|
||||
locale = locale.split("@", 1)[0];
|
||||
|
||||
// try again after normalization
|
||||
try {
|
||||
return Intl.getCanonicalLocales(locale)[0]
|
||||
} catch { }
|
||||
|
||||
// last resort
|
||||
return navigator.language
|
||||
}
|
||||
188
tests/main/file-library.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
82
tests/main/image-parsing.test.js
Normal 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∞>"
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 ")
|
||||
})
|
||||
@ -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 = `
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
173
tests/playwright/draw-image.spec.js
Normal 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);
|
||||
});
|
||||
206
tests/playwright/electron-e2e.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
8
tests/playwright/electron-runner.cjs
Normal 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'))
|
||||
@ -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")
|
||||
})
|
||||
@ -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/
|
||||