mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: polish markdown preview and rich editor (#369)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e431e6ca7
commit
165502121b
11 changed files with 1361 additions and 258 deletions
|
|
@ -46,6 +46,7 @@
|
|||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.2",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
"@tiptap/extension-link": "^3.22.1",
|
||||
"@tiptap/extension-placeholder": "^3.22.1",
|
||||
|
|
@ -70,6 +71,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"hosted-git-info": "^9.0.2",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "^1.1.0",
|
||||
|
|
|
|||
126
pnpm-lock.yaml
126
pnpm-lock.yaml
|
|
@ -29,6 +29,9 @@ importers:
|
|||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.23
|
||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^3.22.2
|
||||
version: 3.22.2(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
|
|
@ -101,6 +104,9 @@ importers:
|
|||
hosted-git-info:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
lowlight:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.4)
|
||||
|
|
@ -976,48 +982,56 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.41.0':
|
||||
resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.41.0':
|
||||
resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.41.0':
|
||||
resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.41.0':
|
||||
resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.41.0':
|
||||
resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.41.0':
|
||||
resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.41.0':
|
||||
resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.41.0':
|
||||
resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==}
|
||||
|
|
@ -1090,48 +1104,56 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.56.0':
|
||||
resolution: {integrity: sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.56.0':
|
||||
resolution: {integrity: sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.56.0':
|
||||
resolution: {integrity: sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.56.0':
|
||||
resolution: {integrity: sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.56.0':
|
||||
resolution: {integrity: sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.56.0':
|
||||
resolution: {integrity: sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.56.0':
|
||||
resolution: {integrity: sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.56.0':
|
||||
resolution: {integrity: sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==}
|
||||
|
|
@ -1895,66 +1917,79 @@ packages:
|
|||
resolution: {integrity: sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.1':
|
||||
resolution: {integrity: sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.1':
|
||||
resolution: {integrity: sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.1':
|
||||
resolution: {integrity: sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.1':
|
||||
resolution: {integrity: sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.1':
|
||||
resolution: {integrity: sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.1':
|
||||
resolution: {integrity: sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.1':
|
||||
resolution: {integrity: sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.1':
|
||||
resolution: {integrity: sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.1':
|
||||
resolution: {integrity: sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.1':
|
||||
resolution: {integrity: sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.1':
|
||||
resolution: {integrity: sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.1':
|
||||
resolution: {integrity: sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.1':
|
||||
resolution: {integrity: sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==}
|
||||
|
|
@ -2042,24 +2077,28 @@ packages:
|
|||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
|
||||
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
|
||||
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
|
||||
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
|
||||
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
|
||||
|
|
@ -2129,6 +2168,15 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.22.1
|
||||
|
||||
'@tiptap/extension-code-block-lowlight@3.22.2':
|
||||
resolution: {integrity: sha512-z3OUuNulh2ehHPnMw4PLEt4JvR8Xy9GEqaDLDADIU+hfk6ztrbhweGm1evZ6fzUI00274NZQCNNtcUwZSa3IHw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.22.2
|
||||
'@tiptap/extension-code-block': ^3.22.2
|
||||
'@tiptap/pm': ^3.22.2
|
||||
highlight.js: ^11
|
||||
lowlight: ^2 || ^3
|
||||
|
||||
'@tiptap/extension-code-block@3.22.1':
|
||||
resolution: {integrity: sha512-fr3b1seFsAeYHtPAb9fbATkGcgyfStD05GHsZXFLh7yCpf2ejWLNxdWJT/g+FggSEHYFKCXT06aixk0WbtRcWw==}
|
||||
peerDependencies:
|
||||
|
|
@ -2408,41 +2456,6 @@ packages:
|
|||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-q6AMrDHlD6dQHpRuGOewhvKTCBDWRgJ42678+muvHbdHkBafRSUSBRiYasUp8cInK22jlkVwNLqQn6j7Sl9zVg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-3v8LplbhJwY4GlBawgJt4ydn0sYeowGMniGiPMBl38ioYAIJOriuBvAk+S/gbOoIfLjMLq0L65fnQN+w9+i5ag==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-hRY2czBKisYtcbwgl8HZBA7u/KKUumNlL0X2OpCK1BtQzKbHXAXi1HxUCPbwgHu4v6uGibvniny0BPA6MVrHDA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-KLa4bK2BxnQwc9uefI8rtaso3cNiRI5Y19z9Jx7UzFJS4YaxtFp5cVjfy4FlQ55ixtUExmCJH7GhSzuVeFl/Jw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-Cbcy+lSctRHNmqvLl+l8RgomL0qX3wxEPKCOIdQ/ooicsIPFbkK57Cwdhw3RSpJ+Xb2LzLdneA2Q1Vg+f/Nwxg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-IFIkbYABge7f83A16awJJWUCrDG1Lw4//NCdU927i/CjgxG09q4ZJeVXcIbZQ5lJWwraiSTa6AMqvhF/tnR2KQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-Jk7wP6SPaELTGY+ijvpude+dmXX9WeuLQLk9qjevQXTusFVcT8vjvepxJVshSJ1+Thmxy1t9v4l1pHnM3XUMjw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260406.1':
|
||||
resolution: {integrity: sha512-hlVajr01Y/ZmI9iZ7A6BgPxqXccGqxuc/PmVNdanr/LZdtsH9q11y2H3NFMaOrbPlDoeWxHvGUT3wsZllCphyg==}
|
||||
hasBin: true
|
||||
|
|
@ -3775,24 +3788,28 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
|
|
@ -7163,6 +7180,14 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-code-block-lowlight@3.22.2(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-code-block': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
highlight.js: 11.11.1
|
||||
lowlight: 3.3.0
|
||||
|
||||
'@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
|
@ -7487,36 +7512,7 @@ snapshots:
|
|||
'@types/node': 25.5.0
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260406.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260406.1':
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260406.1
|
||||
'@typescript/native-preview@7.0.0-dev.20260406.1': {}
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@
|
|||
/* ── Light Mode ──────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--font-mono:
|
||||
'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
--radius: 0.625rem;
|
||||
--background: #fff;
|
||||
--editor-surface: #ffffff;
|
||||
|
|
@ -469,6 +472,13 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-markdown-toolbar-separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: color-mix(in srgb, var(--border) 72%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-toolbar-button:hover,
|
||||
.rich-markdown-toolbar-button.is-active {
|
||||
border-color: color-mix(in srgb, var(--border) 82%, transparent);
|
||||
|
|
@ -581,8 +591,14 @@
|
|||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rich-markdown-editor > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor h1 {
|
||||
font-size: 1.75em;
|
||||
font-size: 1.85em;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.rich-markdown-editor h2 {
|
||||
|
|
@ -598,8 +614,7 @@
|
|||
.rich-markdown-editor p,
|
||||
.rich-markdown-editor ul,
|
||||
.rich-markdown-editor ol,
|
||||
.rich-markdown-editor blockquote,
|
||||
.rich-markdown-editor pre {
|
||||
.rich-markdown-editor blockquote {
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
|
|
@ -616,47 +631,131 @@
|
|||
list-style: decimal;
|
||||
}
|
||||
|
||||
/* Tiptap wraps each list item's content in a <p>, which inherits the global
|
||||
paragraph margin and bloats inter-item spacing. Suppress it so lists render
|
||||
tight, matching the preview surface. */
|
||||
.rich-markdown-editor li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor li {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Why: nested task lists inside a task item need left padding so they indent
|
||||
under the parent checkbox, matching how nested bullet/ordered lists look. */
|
||||
.rich-markdown-editor ul[data-type='taskList'] ul[data-type='taskList'] {
|
||||
padding-left: 0.75em;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > label {
|
||||
/* Vertically center the checkbox with the first line of text. */
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.55em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox'] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Remove paragraph margin inside task items so the text sits flush with the checkbox. */
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > div > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor blockquote {
|
||||
padding: 0.25em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
.rich-markdown-editor ul[data-type='taskList'] li[data-checked='true'] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-editor blockquote {
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 2%, transparent);
|
||||
}
|
||||
|
||||
.rich-markdown-editor code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
font-size: 0.9em;
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rich-markdown-code-block-wrapper {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.rich-markdown-code-block-lang {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--background) 80%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-markdown-editor pre {
|
||||
padding: 12px 16px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
line-height: 1.55;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rich-markdown-editor pre code {
|
||||
|
|
@ -664,6 +763,127 @@
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
/* Why: CodeBlockLowlight's decoration plugin adds hljs class spans directly
|
||||
inside the <pre> contentDOM — there is no <code> wrapper when using a
|
||||
React NodeView, so selectors must target pre > .hljs-* directly. */
|
||||
.rich-markdown-code-block-wrapper .hljs-keyword {
|
||||
color: #cf222e;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-string {
|
||||
color: #0a3069;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-number {
|
||||
color: #0550ae;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-comment {
|
||||
color: #6e7781;
|
||||
font-style: italic;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-function {
|
||||
color: #8250df;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-title {
|
||||
color: #8250df;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-built_in {
|
||||
color: #0550ae;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-type {
|
||||
color: #0550ae;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-attr {
|
||||
color: #0550ae;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-selector-class {
|
||||
color: #0550ae;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-variable {
|
||||
color: #953800;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-meta {
|
||||
color: #6e7781;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-tag {
|
||||
color: #116329;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-name {
|
||||
color: #116329;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-params {
|
||||
color: #953800;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-literal {
|
||||
color: #0550ae;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-regexp {
|
||||
color: #0a3069;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-operator {
|
||||
color: #cf222e;
|
||||
}
|
||||
.rich-markdown-code-block-wrapper .hljs-property {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-keyword {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-string {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-number {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-comment {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-function {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-title {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-built_in {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-type {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-attr {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-selector-class {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-variable {
|
||||
color: #ffa657;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-meta {
|
||||
color: #8b949e;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-tag {
|
||||
color: #7ee787;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-name {
|
||||
color: #7ee787;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-params {
|
||||
color: #ffa657;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-literal {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-regexp {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-operator {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.dark .rich-markdown-code-block-wrapper .hljs-property {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.rich-markdown-editor hr {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
|
|
@ -671,8 +891,14 @@
|
|||
}
|
||||
|
||||
.rich-markdown-editor a {
|
||||
color: var(--primary);
|
||||
color: #0969da;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark .rich-markdown-editor a {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.rich-markdown-editor img {
|
||||
|
|
@ -729,6 +955,67 @@
|
|||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ── Link Bubble ──────────────────────────────────────── */
|
||||
|
||||
.rich-markdown-link-bubble {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.rich-markdown-link-url {
|
||||
padding: 2px 6px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rich-markdown-link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-link-button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-link-input {
|
||||
font-size: 13px;
|
||||
width: 280px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rich-markdown-link-input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ── Markdown Preview ────────────────────────────────── */
|
||||
|
||||
.markdown-preview {
|
||||
|
|
@ -835,14 +1122,22 @@
|
|||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 1.75em;
|
||||
.markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 1.85em;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.4em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
|
@ -851,46 +1146,66 @@
|
|||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
/* Links use a proper blue instead of --primary (which is near-black/white and
|
||||
visually indistinguishable from body text). */
|
||||
.markdown-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color 150ms;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.markdown-light .markdown-body a {
|
||||
color: #0969da;
|
||||
}
|
||||
|
||||
.markdown-dark .markdown-body a {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
border-radius: 5px;
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.markdown-dark .markdown-body code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.markdown-light .markdown-body code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin: 1em 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.markdown-dark .markdown-body pre {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.markdown-light .markdown-body pre {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: #f6f8fa;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.85em;
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
|
|
@ -934,45 +1249,227 @@
|
|||
|
||||
.markdown-body blockquote {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.25em 1em;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.markdown-dark .markdown-body blockquote {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.markdown-light .markdown-body blockquote {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
padding: 6px 12px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.markdown-dark .markdown-body th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.markdown-light .markdown-body th {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.markdown-dark .markdown-body tr:nth-child(even) td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.markdown-light .markdown-body tr:nth-child(even) td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
margin: 1.5em 0;
|
||||
margin: 2em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ── Syntax Highlighting (GitHub-inspired) ────────── */
|
||||
|
||||
.markdown-light .hljs {
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-comment,
|
||||
.markdown-light .hljs-quote {
|
||||
color: #6e7781;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-keyword,
|
||||
.markdown-light .hljs-selector-tag,
|
||||
.markdown-light .hljs-template-tag {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-string,
|
||||
.markdown-light .hljs-doctag,
|
||||
.markdown-light .hljs-regexp {
|
||||
color: #0a3069;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-number,
|
||||
.markdown-light .hljs-literal {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-title,
|
||||
.markdown-light .hljs-section {
|
||||
color: #8250df;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-type,
|
||||
.markdown-light .hljs-built_in {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-attr,
|
||||
.markdown-light .hljs-attribute {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-variable,
|
||||
.markdown-light .hljs-template-variable {
|
||||
color: #953800;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-tag,
|
||||
.markdown-light .hljs-name {
|
||||
color: #116329;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-symbol,
|
||||
.markdown-light .hljs-bullet,
|
||||
.markdown-light .hljs-selector-class,
|
||||
.markdown-light .hljs-selector-id {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-meta {
|
||||
color: #1f7f34;
|
||||
}
|
||||
|
||||
.markdown-light .hljs-addition {
|
||||
color: #116329;
|
||||
background: rgba(46, 160, 67, 0.1);
|
||||
}
|
||||
.markdown-light .hljs-deletion {
|
||||
color: #82071e;
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.markdown-light .hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
.markdown-light .hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs {
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-comment,
|
||||
.markdown-dark .hljs-quote {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-keyword,
|
||||
.markdown-dark .hljs-selector-tag,
|
||||
.markdown-dark .hljs-template-tag {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-string,
|
||||
.markdown-dark .hljs-doctag,
|
||||
.markdown-dark .hljs-regexp {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-number,
|
||||
.markdown-dark .hljs-literal {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-title,
|
||||
.markdown-dark .hljs-section {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-type,
|
||||
.markdown-dark .hljs-built_in {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-attr,
|
||||
.markdown-dark .hljs-attribute {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-variable,
|
||||
.markdown-dark .hljs-template-variable {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-tag,
|
||||
.markdown-dark .hljs-name {
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-symbol,
|
||||
.markdown-dark .hljs-bullet,
|
||||
.markdown-dark .hljs-selector-class,
|
||||
.markdown-dark .hljs-selector-id {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-meta {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-addition {
|
||||
color: #aff5b4;
|
||||
background: rgba(46, 160, 67, 0.15);
|
||||
}
|
||||
.markdown-dark .hljs-deletion {
|
||||
color: #ffdcd7;
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
.markdown-dark .hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
.markdown-dark .hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Content ─────────────────────────────────────────── */
|
||||
|
|
|
|||
71
src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
Normal file
71
src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Common languages shown in the selector. The user can also type a language
|
||||
* name directly in the markdown fence (```rust) and it will be preserved —
|
||||
* this list is just for quick picking in the UI.
|
||||
*/
|
||||
const LANGUAGES = [
|
||||
{ value: '', label: 'Plain text' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'c', label: 'C' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'diff', label: 'Diff' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'graphql', label: 'GraphQL' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'kotlin', label: 'Kotlin' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'ruby', label: 'Ruby' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'scss', label: 'SCSS' },
|
||||
{ value: 'shell', label: 'Shell' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'swift', label: 'Swift' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'xml', label: 'XML' },
|
||||
{ value: 'yaml', label: 'YAML' }
|
||||
]
|
||||
|
||||
export function RichMarkdownCodeBlock({
|
||||
node,
|
||||
updateAttributes
|
||||
}: NodeViewProps): React.JSX.Element {
|
||||
const language = (node.attrs.language as string) || ''
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
updateAttributes({ language: e.target.value })
|
||||
},
|
||||
[updateAttributes]
|
||||
)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="rich-markdown-code-block-wrapper">
|
||||
<select
|
||||
className="rich-markdown-code-block-lang"
|
||||
contentEditable={false}
|
||||
value={language}
|
||||
onChange={onChange}
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
{/* If the document has a language not in our list, show it as-is */}
|
||||
{language && !LANGUAGES.some((l) => l.value === language) ? (
|
||||
<option value={language}>{language}</option>
|
||||
) : null}
|
||||
</select>
|
||||
<NodeViewContent<'pre'> as="pre" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
|
||||
import { useAppStore } from '@/store'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { RichMarkdownToolbarButton } from './RichMarkdownToolbarButton'
|
||||
import { RichMarkdownToolbar } from './RichMarkdownToolbar'
|
||||
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import { extractIpcErrorMessage, getImageCopyDestination } from './rich-markdown-image-utils'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
|
|
@ -15,6 +13,13 @@ import { runSlashCommand, slashCommands, syncSlashMenu } from './rich-markdown-c
|
|||
import type { SlashCommand, SlashMenuState } from './rich-markdown-commands'
|
||||
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
import {
|
||||
getLinkBubblePosition,
|
||||
RichMarkdownLinkBubble,
|
||||
type LinkBubbleState
|
||||
} from './RichMarkdownLinkBubble'
|
||||
import { useLinkBubble } from './useLinkBubble'
|
||||
import { useEditorScrollRestore } from './useEditorScrollRestore'
|
||||
|
||||
type RichMarkdownEditorProps = {
|
||||
content: string
|
||||
|
|
@ -49,6 +54,9 @@ export default function RichMarkdownEditor({
|
|||
// Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays
|
||||
// stuck at the first-render null value unless we read the live instance here.
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(null)
|
||||
const [isEditingLink, setIsEditingLink] = useState(false)
|
||||
const isEditingLinkRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
onContentChangeRef.current = onContentChange
|
||||
|
|
@ -56,6 +64,9 @@ export default function RichMarkdownEditor({
|
|||
useEffect(() => {
|
||||
onSaveRef.current = onSave
|
||||
}, [onSave])
|
||||
useEffect(() => {
|
||||
isEditingLinkRef.current = isEditingLink
|
||||
}, [isEditingLink])
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
|
|
@ -80,6 +91,72 @@ export default function RichMarkdownEditor({
|
|||
return true
|
||||
}
|
||||
|
||||
// Strikethrough: Cmd/Ctrl+Shift+X (standard shortcut used by Google
|
||||
// Docs, Notion, etc. — supplements Tiptap's built-in Mod+Shift+S).
|
||||
if (mod && event.shiftKey && event.key.toLowerCase() === 'x') {
|
||||
event.preventDefault()
|
||||
editorRef.current?.chain().focus().toggleStrike().run()
|
||||
return true
|
||||
}
|
||||
|
||||
// Link: Cmd/Ctrl+K — insert or edit a hyperlink.
|
||||
if (mod && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
const ed = editorRef.current
|
||||
if (!ed) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isEditingLinkRef.current) {
|
||||
setIsEditingLink(false)
|
||||
if (!ed.isActive('link')) {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
ed.commands.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
const pos = getLinkBubblePosition(ed, rootRef.current)
|
||||
if (pos) {
|
||||
const href = ed.isActive('link') ? (ed.getAttributes('link').href as string) || '' : ''
|
||||
setLinkBubble({ href, ...pos })
|
||||
setIsEditingLink(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Tab/Shift-Tab: indent/outdent lists, insert spaces in code blocks,
|
||||
// and prevent focus from escaping the editor. When the slash menu is
|
||||
// open, Tab selects a command instead (handled in the slash-menu block
|
||||
// below).
|
||||
if (event.key === 'Tab' && !slashMenuRef.current) {
|
||||
event.preventDefault()
|
||||
const ed = editorRef.current
|
||||
if (!ed) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (!ed.commands.liftListItem('listItem')) {
|
||||
ed.commands.liftListItem('taskItem')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (ed.isActive('codeBlock')) {
|
||||
ed.commands.insertContent(' ')
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: sinkListItem succeeds when cursor is in a non-first list item;
|
||||
// otherwise it no-ops. Either way we consume Tab to prevent focus escape.
|
||||
if (!ed.commands.sinkListItem('listItem')) {
|
||||
ed.commands.sinkListItem('taskItem')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Slash menu navigation ─────────────────────────
|
||||
const currentSlashMenu = slashMenuRef.current
|
||||
if (!currentSlashMenu) {
|
||||
return false
|
||||
|
|
@ -131,6 +208,24 @@ export default function RichMarkdownEditor({
|
|||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
// Why: Cmd/Ctrl+click on a link opens it in the system browser, matching
|
||||
// VS Code and other editor conventions. Without the modifier, clicks just
|
||||
// position the cursor normally for editing.
|
||||
handleClick: (_view, _pos, event) => {
|
||||
const ed = editorRef.current
|
||||
if (!ed) {
|
||||
return false
|
||||
}
|
||||
const modKey = isMac ? event.metaKey : event.ctrlKey
|
||||
if (modKey && ed.isActive('link')) {
|
||||
const href = (ed.getAttributes('link').href as string) || ''
|
||||
if (href) {
|
||||
void window.api.shell.openUrl(href)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
|
@ -145,6 +240,19 @@ export default function RichMarkdownEditor({
|
|||
},
|
||||
onSelectionUpdate: ({ editor: nextEditor }) => {
|
||||
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
|
||||
|
||||
// Sync link bubble: show preview when cursor is on a link, hide otherwise.
|
||||
// Any selection change in the editor cancels an in-progress link edit.
|
||||
setIsEditingLink(false)
|
||||
if (nextEditor.isActive('link')) {
|
||||
const attrs = nextEditor.getAttributes('link')
|
||||
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
|
||||
if (pos) {
|
||||
setLinkBubble({ href: (attrs.href as string) || '', ...pos })
|
||||
}
|
||||
} else {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -152,75 +260,7 @@ export default function RichMarkdownEditor({
|
|||
editorRef.current = editor ?? null
|
||||
}, [editor])
|
||||
|
||||
const scrollCacheKey = `${filePath}:rich`
|
||||
|
||||
// Save scroll position with trailing throttle and synchronous unmount snapshot.
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
throttleTimer = setTimeout(() => {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
throttleTimer = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
// Snapshot final position synchronously before detach.
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
container.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [scrollCacheKey])
|
||||
|
||||
// Restore scroll position with RAF retry loop for async Tiptap content.
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
|
||||
if (!container || targetScrollTop === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
|
||||
// Why: Tiptap renders asynchronously as it hydrates its ProseMirror document,
|
||||
// so scrollHeight may be undersized on the initial frame. Retry up to 30
|
||||
// frames (~500ms at 60fps) to accommodate content loading. This matches
|
||||
// CombinedDiffViewer's proven pattern for dynamic-height content restoration.
|
||||
const tryRestore = (): void => {
|
||||
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
const nextScrollTop = Math.min(targetScrollTop, maxScrollTop)
|
||||
container.scrollTop = nextScrollTop
|
||||
|
||||
if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) {
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 30) {
|
||||
frameId = window.requestAnimationFrame(tryRestore)
|
||||
}
|
||||
}
|
||||
|
||||
tryRestore()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
// Why: `editor` is included so the effect re-runs when the Tiptap editor
|
||||
// instance becomes available (non-null). With `immediatelyRender: false`,
|
||||
// editor is null on the first render, so the retry loop would start before
|
||||
// content is mounted and exhaust its 30 frames before Tiptap hydrates.
|
||||
}, [scrollCacheKey, editor])
|
||||
useEditorScrollRestore(scrollContainerRef, `${filePath}:rich`, editor)
|
||||
|
||||
// Why: the custom Image extension reads filePath from editor.storage to resolve
|
||||
// relative image src values to file:// URLs for display. After updating the
|
||||
|
|
@ -270,6 +310,15 @@ export default function RichMarkdownEditor({
|
|||
useEffect(() => {
|
||||
handleLocalImagePickRef.current = handleLocalImagePick
|
||||
}, [handleLocalImagePick])
|
||||
|
||||
const {
|
||||
handleLinkSave,
|
||||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
toggleLinkFromToolbar
|
||||
} = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink)
|
||||
|
||||
const {
|
||||
activeMatchIndex,
|
||||
closeSearch,
|
||||
|
|
@ -300,11 +349,9 @@ export default function RichMarkdownEditor({
|
|||
useEffect(() => {
|
||||
slashMenuRef.current = slashMenu
|
||||
}, [slashMenu])
|
||||
|
||||
useEffect(() => {
|
||||
filteredSlashCommandsRef.current = filteredSlashCommands
|
||||
}, [filteredSlashCommands])
|
||||
|
||||
useEffect(() => {
|
||||
selectedCommandIndexRef.current = selectedCommandIndex
|
||||
}, [selectedCommandIndex])
|
||||
|
|
@ -345,53 +392,11 @@ export default function RichMarkdownEditor({
|
|||
className="rich-markdown-editor-shell"
|
||||
style={{ '--editor-font-zoom-level': editorFontZoomLevel } as React.CSSProperties}
|
||||
>
|
||||
<div className="rich-markdown-editor-toolbar">
|
||||
<RichMarkdownToolbarButton
|
||||
active={editor?.isActive('bold') ?? false}
|
||||
label="Bold"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={editor?.isActive('italic') ?? false}
|
||||
label="Italic"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
I
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={editor?.isActive('strike') ?? false}
|
||||
label="Strike"
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
S
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={editor?.isActive('bulletList') ?? false}
|
||||
label="Bullet list"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={editor?.isActive('orderedList') ?? false}
|
||||
label="Numbered list"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={editor?.isActive('blockquote') ?? false}
|
||||
label="Quote"
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<Quote className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton active={false} label="Image" onClick={handleLocalImagePick}>
|
||||
<ImageIcon className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
</div>
|
||||
<RichMarkdownToolbar
|
||||
editor={editor}
|
||||
onToggleLink={toggleLinkFromToolbar}
|
||||
onImagePick={handleLocalImagePick}
|
||||
/>
|
||||
<RichMarkdownSearchBar
|
||||
activeMatchIndex={activeMatchIndex}
|
||||
isOpen={isSearchOpen}
|
||||
|
|
@ -405,41 +410,25 @@ export default function RichMarkdownEditor({
|
|||
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
{linkBubble ? (
|
||||
<RichMarkdownLinkBubble
|
||||
linkBubble={linkBubble}
|
||||
isEditing={isEditingLink}
|
||||
onSave={handleLinkSave}
|
||||
onRemove={handleLinkRemove}
|
||||
onEditStart={() => setIsEditingLink(true)}
|
||||
onEditCancel={handleLinkEditCancel}
|
||||
onOpen={handleLinkOpen}
|
||||
/>
|
||||
) : null}
|
||||
{slashMenu && filteredSlashCommands.length > 0 ? (
|
||||
<div
|
||||
className="rich-markdown-slash-menu"
|
||||
style={{ left: slashMenu.left, top: slashMenu.top }}
|
||||
role="listbox"
|
||||
aria-label="Slash commands"
|
||||
>
|
||||
{filteredSlashCommands.map((command, index) => {
|
||||
const Icon = command.icon
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rich-markdown-slash-item',
|
||||
index === selectedCommandIndex && 'is-active'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
editor && runSlashCommand(editor, slashMenu, command, handleLocalImagePick)
|
||||
}
|
||||
>
|
||||
<span className="rich-markdown-slash-icon">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start">
|
||||
<span className="truncate text-sm font-medium">{command.label}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{command.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<RichMarkdownSlashMenu
|
||||
editor={editor}
|
||||
slashMenu={slashMenu}
|
||||
filteredCommands={filteredSlashCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onImagePick={handleLocalImagePick}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
141
src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx
Normal file
141
src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
|
||||
|
||||
export type LinkBubbleState = {
|
||||
href: string
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
export function getLinkBubblePosition(
|
||||
editor: Editor,
|
||||
rootEl: HTMLElement | null
|
||||
): { left: number; top: number } | null {
|
||||
const { from } = editor.state.selection
|
||||
try {
|
||||
const coords = editor.view.coordsAtPos(from)
|
||||
const rootRect = rootEl?.getBoundingClientRect()
|
||||
if (!rootRect) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
left: coords.left - rootRect.left,
|
||||
top: coords.bottom - rootRect.top + 4
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function LinkEditInput({
|
||||
initialHref,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
initialHref: string
|
||||
onSave: (href: string) => void
|
||||
onCancel: () => void
|
||||
}): React.JSX.Element {
|
||||
const [value, setValue] = useState(initialHref)
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus()
|
||||
ref.current?.select()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSave(value.trim())
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
// Cmd/Ctrl+K while editing cancels the edit.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
placeholder="Paste or type a link…"
|
||||
className="rich-markdown-link-input"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type RichMarkdownLinkBubbleProps = {
|
||||
linkBubble: LinkBubbleState
|
||||
isEditing: boolean
|
||||
onSave: (href: string) => void
|
||||
onRemove: () => void
|
||||
onEditStart: () => void
|
||||
onEditCancel: () => void
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
export function RichMarkdownLinkBubble({
|
||||
linkBubble,
|
||||
isEditing,
|
||||
onSave,
|
||||
onRemove,
|
||||
onEditStart,
|
||||
onEditCancel,
|
||||
onOpen
|
||||
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="rich-markdown-link-bubble"
|
||||
style={{ left: linkBubble.left, top: linkBubble.top }}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent editor blur when clicking bubble buttons, but let inputs
|
||||
// receive focus normally.
|
||||
if (!(e.target instanceof HTMLInputElement)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isEditing ? (
|
||||
<LinkEditInput initialHref={linkBubble.href} onSave={onSave} onCancel={onEditCancel} />
|
||||
) : (
|
||||
<>
|
||||
<span className="rich-markdown-link-url" title={linkBubble.href}>
|
||||
{linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onOpen}
|
||||
title="Open link"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onEditStart}
|
||||
title="Edit link"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onRemove}
|
||||
title="Remove link"
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx
Normal file
51
src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { runSlashCommand } from './rich-markdown-commands'
|
||||
import type { SlashCommand, SlashMenuState } from './rich-markdown-commands'
|
||||
|
||||
type RichMarkdownSlashMenuProps = {
|
||||
editor: Editor | null
|
||||
slashMenu: SlashMenuState
|
||||
filteredCommands: SlashCommand[]
|
||||
selectedIndex: number
|
||||
onImagePick: () => void
|
||||
}
|
||||
|
||||
export function RichMarkdownSlashMenu({
|
||||
editor,
|
||||
slashMenu,
|
||||
filteredCommands,
|
||||
selectedIndex,
|
||||
onImagePick
|
||||
}: RichMarkdownSlashMenuProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="rich-markdown-slash-menu"
|
||||
style={{ left: slashMenu.left, top: slashMenu.top }}
|
||||
role="listbox"
|
||||
aria-label="Slash commands"
|
||||
>
|
||||
{filteredCommands.map((command, index) => {
|
||||
const Icon = command.icon
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
type="button"
|
||||
className={cn('rich-markdown-slash-item', index === selectedIndex && 'is-active')}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => editor && runSlashCommand(editor, slashMenu, command, onImagePick)}
|
||||
>
|
||||
<span className="rich-markdown-slash-icon">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start">
|
||||
<span className="truncate text-sm font-medium">{command.label}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{command.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
src/renderer/src/components/editor/RichMarkdownToolbar.tsx
Normal file
142
src/renderer/src/components/editor/RichMarkdownToolbar.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
Quote
|
||||
} from 'lucide-react'
|
||||
import { RichMarkdownToolbarButton } from './RichMarkdownToolbarButton'
|
||||
|
||||
type RichMarkdownToolbarProps = {
|
||||
editor: Editor | null
|
||||
onToggleLink: () => void
|
||||
onImagePick: () => void
|
||||
}
|
||||
|
||||
function Separator(): React.JSX.Element {
|
||||
return <div className="rich-markdown-toolbar-separator" />
|
||||
}
|
||||
|
||||
export function RichMarkdownToolbar({
|
||||
editor,
|
||||
onToggleLink,
|
||||
onImagePick
|
||||
}: RichMarkdownToolbarProps): React.JSX.Element {
|
||||
// Why: the editor object reference is stable across transactions, so passing
|
||||
// it as a prop alone won't re-render this component when the selection moves.
|
||||
// useEditorState subscribes to editor transactions and returns derived state,
|
||||
// triggering a re-render only when the active formatting actually changes.
|
||||
const active = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
const ed = ctx.editor
|
||||
if (!ed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
h1: ed.isActive('heading', { level: 1 }),
|
||||
h2: ed.isActive('heading', { level: 2 }),
|
||||
h3: ed.isActive('heading', { level: 3 }),
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
strike: ed.isActive('strike'),
|
||||
bulletList: ed.isActive('bulletList'),
|
||||
orderedList: ed.isActive('orderedList'),
|
||||
taskList: ed.isActive('taskList'),
|
||||
blockquote: ed.isActive('blockquote'),
|
||||
link: ed.isActive('link')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rich-markdown-editor-toolbar">
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.h1 ?? false}
|
||||
label="Heading 1"
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
>
|
||||
<Heading1 className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.h2 ?? false}
|
||||
label="Heading 2"
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2 className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.h3 ?? false}
|
||||
label="Heading 3"
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3 className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<Separator />
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.bold ?? false}
|
||||
label="Bold"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.italic ?? false}
|
||||
label="Italic"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
I
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.strike ?? false}
|
||||
label="Strike"
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
S
|
||||
</RichMarkdownToolbarButton>
|
||||
<Separator />
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.bulletList ?? false}
|
||||
label="Bullet list"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.orderedList ?? false}
|
||||
label="Numbered list"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.taskList ?? false}
|
||||
label="Checklist"
|
||||
onClick={() => editor?.chain().focus().toggleTaskList().run()}
|
||||
>
|
||||
<ListTodo className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<Separator />
|
||||
<RichMarkdownToolbarButton
|
||||
active={active?.blockquote ?? false}
|
||||
label="Quote"
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<Quote className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton active={active?.link ?? false} label="Link" onClick={onToggleLink}>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
<RichMarkdownToolbarButton active={false} label="Image" onClick={onImagePick}>
|
||||
<ImageIcon className="size-3.5" />
|
||||
</RichMarkdownToolbarButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { AnyExtension } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
|
|
@ -10,8 +12,12 @@ import { TableCell } from '@tiptap/extension-table-cell'
|
|||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createLowlight, common } from 'lowlight'
|
||||
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
|
||||
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
|
||||
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
|
||||
const RICH_MARKDOWN_PLACEHOLDER = 'Write markdown… Type / for blocks.'
|
||||
|
||||
|
|
@ -25,10 +31,21 @@ export function createRichMarkdownExtensions({
|
|||
// the live editor. If these drift, Orca can claim a document is editable in
|
||||
// preview and then still lose syntax on save.
|
||||
StarterKit.configure({
|
||||
link: false
|
||||
link: false,
|
||||
codeBlock: false
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(RichMarkdownCodeBlock)
|
||||
}
|
||||
}).configure({
|
||||
lowlight,
|
||||
defaultLanguage: null
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true
|
||||
}),
|
||||
// Why: in dev mode the renderer is served from http://localhost, so
|
||||
// file:// URLs in <img> tags are blocked by cross-origin restrictions.
|
||||
|
|
|
|||
81
src/renderer/src/components/editor/useEditorScrollRestore.ts
Normal file
81
src/renderer/src/components/editor/useEditorScrollRestore.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useLayoutEffect, type RefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
|
||||
/**
|
||||
* Saves and restores scroll position for the rich markdown editor.
|
||||
* Extracted to keep the editor component under the max-lines lint limit.
|
||||
*/
|
||||
export function useEditorScrollRestore(
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>,
|
||||
scrollCacheKey: string,
|
||||
editor: Editor | null
|
||||
): void {
|
||||
// Save scroll position with trailing throttle and synchronous unmount snapshot.
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
throttleTimer = setTimeout(() => {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
throttleTimer = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
// Snapshot final position synchronously before detach.
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
container.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [scrollContainerRef, scrollCacheKey])
|
||||
|
||||
// Restore scroll position with RAF retry loop for async Tiptap content.
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
|
||||
if (!container || targetScrollTop === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
|
||||
// Why: Tiptap renders asynchronously as it hydrates its ProseMirror document,
|
||||
// so scrollHeight may be undersized on the initial frame. Retry up to 30
|
||||
// frames (~500ms at 60fps) to accommodate content loading. This matches
|
||||
// CombinedDiffViewer's proven pattern for dynamic-height content restoration.
|
||||
const tryRestore = (): void => {
|
||||
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
const nextScrollTop = Math.min(targetScrollTop, maxScrollTop)
|
||||
container.scrollTop = nextScrollTop
|
||||
|
||||
if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) {
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 30) {
|
||||
frameId = window.requestAnimationFrame(tryRestore)
|
||||
}
|
||||
}
|
||||
|
||||
tryRestore()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
// Why: `editor` is included so the effect re-runs when the Tiptap editor
|
||||
// instance becomes available (non-null). With `immediatelyRender: false`,
|
||||
// editor is null on the first render, so the retry loop would start before
|
||||
// content is mounted and exhaust its 30 frames before Tiptap hydrates.
|
||||
}, [scrollContainerRef, scrollCacheKey, editor])
|
||||
}
|
||||
116
src/renderer/src/components/editor/useLinkBubble.ts
Normal file
116
src/renderer/src/components/editor/useLinkBubble.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useCallback } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
|
||||
/**
|
||||
* Extracts link-editing action handlers from the editor component to reduce
|
||||
* file size. State lives in the parent (declared before useEditor so the
|
||||
* editor callbacks can reference the setters).
|
||||
*/
|
||||
export function useLinkBubble(
|
||||
editor: Editor | null,
|
||||
rootRef: React.RefObject<HTMLElement | null>,
|
||||
linkBubble: LinkBubbleState | null,
|
||||
setLinkBubble: (v: LinkBubbleState | null) => void,
|
||||
setIsEditingLink: (v: boolean) => void
|
||||
): {
|
||||
handleLinkSave: (href: string) => void
|
||||
handleLinkRemove: () => void
|
||||
handleLinkEditCancel: () => void
|
||||
handleLinkOpen: () => void
|
||||
toggleLinkFromToolbar: () => void
|
||||
} {
|
||||
const startLinkEdit = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
const pos = getLinkBubblePosition(editor, rootRef.current)
|
||||
if (pos) {
|
||||
const href = editor.isActive('link')
|
||||
? (editor.getAttributes('link').href as string) || ''
|
||||
: ''
|
||||
setLinkBubble({ href, ...pos })
|
||||
setIsEditingLink(true)
|
||||
}
|
||||
}, [editor, rootRef, setLinkBubble, setIsEditingLink])
|
||||
|
||||
const handleLinkSave = useCallback(
|
||||
(href: string) => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (href) {
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||
} else {
|
||||
const { from, to } = editor.state.selection
|
||||
if (from === to) {
|
||||
// No selection: insert URL as both the link text and href.
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: href,
|
||||
marks: [{ type: 'link', attrs: { href } }]
|
||||
})
|
||||
.run()
|
||||
} else {
|
||||
editor.chain().focus().setLink({ href }).run()
|
||||
}
|
||||
}
|
||||
} else if (editor.isActive('link')) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
} else {
|
||||
editor.commands.focus()
|
||||
}
|
||||
setIsEditingLink(false)
|
||||
},
|
||||
[editor, setIsEditingLink]
|
||||
)
|
||||
|
||||
const handleLinkRemove = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setLinkBubble(null)
|
||||
setIsEditingLink(false)
|
||||
}, [editor, setLinkBubble, setIsEditingLink])
|
||||
|
||||
const handleLinkEditCancel = useCallback(() => {
|
||||
setIsEditingLink(false)
|
||||
if (!linkBubble?.href) {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
editor?.commands.focus()
|
||||
}, [editor, linkBubble?.href, setLinkBubble, setIsEditingLink])
|
||||
|
||||
const handleLinkOpen = useCallback(() => {
|
||||
if (linkBubble?.href) {
|
||||
void window.api.shell.openUrl(linkBubble.href)
|
||||
}
|
||||
}, [linkBubble?.href])
|
||||
|
||||
const toggleLinkFromToolbar = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setLinkBubble(null)
|
||||
} else {
|
||||
startLinkEdit()
|
||||
}
|
||||
}, [editor, setLinkBubble, startLinkEdit])
|
||||
|
||||
return {
|
||||
handleLinkSave,
|
||||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
toggleLinkFromToolbar
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue