feat: polish markdown preview and rich editor (#369)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neil 2026-04-07 13:03:43 -07:00 committed by GitHub
parent 9e431e6ca7
commit 165502121b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1361 additions and 258 deletions

View file

@ -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",

View file

@ -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': {}

View file

@ -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 ─────────────────────────────────────────── */

View 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>
)
}

View file

@ -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>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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.

View 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])
}

View 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
}
}