feat(markdown): support HTML superscript links (#8307)
* feat(markdown): support HTML superscript links * Add HTML superscript citation links to the rich markdown editor - Introduce marked-based tokenizer support for `<sup><a>` citation links, with a shared editor context so key/link handlers can resolve selection status and open an action bubble for citation atoms (which markdown setLink/unsetLink can't edit). - Extract clipboard-write logic shared by cut and cut-visual-line into rich-markdown-clipboard-write.ts, and surface a cut-limit error when clipboard readback fails. - Fix MarkdownPreview same-file anchor scrolling to run before the unknown-ownership guard so ambiguous folder-workspace ownership still scrolls within the open document. - Keep search's replace-disabled state derived from live matches instead of a stale snapshot. * Update lockfile to dedupe stale package versions after dependency resolu - Removes duplicate/superseded resolutions (older @babel/* versions, fs-extra 11.3.4/11.3.5, plist 3.1.0, semver 7.7.4) left over from a prior install - Aligns transitive deps to the single resolved versions already in use elsewhere in the tree
This commit is contained in:
parent
6be8687c34
commit
ac8879c789
|
|
@ -177,6 +177,7 @@
|
|||
"lint-staged": "^16.4.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"oxfmt": "^0.52.0",
|
||||
|
|
|
|||
195
pnpm-lock.yaml
195
pnpm-lock.yaml
|
|
@ -263,6 +263,9 @@ importers:
|
|||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.5)
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.6
|
||||
mermaid:
|
||||
specifier: ^11.15.0
|
||||
version: 11.15.0
|
||||
|
|
@ -399,10 +402,6 @@ packages:
|
|||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -415,10 +414,6 @@ packages:
|
|||
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/generator@7.29.7':
|
||||
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -437,10 +432,6 @@ packages:
|
|||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
|
||||
'@babel/helper-globals@7.28.0':
|
||||
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-globals@7.29.7':
|
||||
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -449,20 +440,10 @@ packages:
|
|||
resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-imports@7.28.6':
|
||||
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-transforms@7.28.6':
|
||||
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
|
||||
'@babel/helper-module-transforms@7.29.7':
|
||||
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -487,26 +468,14 @@ packages:
|
|||
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1':
|
||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.29.7':
|
||||
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -515,11 +484,6 @@ packages:
|
|||
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.3':
|
||||
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
|
@ -577,26 +541,14 @@ packages:
|
|||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -3457,6 +3409,7 @@ packages:
|
|||
|
||||
boolean@3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
brace-expansion@1.1.14:
|
||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||
|
|
@ -4354,14 +4307,6 @@ packages:
|
|||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fs-extra@11.3.4:
|
||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.3.5:
|
||||
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.3.6:
|
||||
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
|
@ -4446,6 +4391,7 @@ packages:
|
|||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
|
|
@ -4642,6 +4588,7 @@ packages:
|
|||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
|
@ -4967,6 +4914,7 @@ packages:
|
|||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
|
@ -5330,6 +5278,7 @@ packages:
|
|||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
|
|
@ -6740,12 +6689,6 @@ snapshots:
|
|||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.1.2
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
|
@ -6774,14 +6717,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/generator@7.29.7':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
|
|
@ -6792,7 +6727,7 @@ snapshots:
|
|||
|
||||
'@babel/helper-annotate-as-pure@7.27.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/helper-compilation-targets@7.29.7':
|
||||
dependencies:
|
||||
|
|
@ -6810,26 +6745,17 @@ snapshots:
|
|||
'@babel/helper-optimise-call-expression': 7.27.1
|
||||
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-globals@7.28.0': {}
|
||||
|
||||
'@babel/helper-globals@7.29.7': {}
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.28.5':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.28.6':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -6840,15 +6766,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
|
|
@ -6860,7 +6777,7 @@ snapshots:
|
|||
|
||||
'@babel/helper-optimise-call-expression@7.27.1':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/helper-plugin-utils@7.28.6': {}
|
||||
|
||||
|
|
@ -6869,27 +6786,21 @@ snapshots:
|
|||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.28.5
|
||||
'@babel/helper-optimise-call-expression': 7.27.1
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-option@7.29.7': {}
|
||||
|
||||
'@babel/helpers@7.29.7':
|
||||
|
|
@ -6897,10 +6808,6 @@ snapshots:
|
|||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/parser@7.29.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
|
@ -6923,7 +6830,7 @@ snapshots:
|
|||
'@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -6953,7 +6860,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
'@babel/helper-validator-option': 7.29.7
|
||||
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
|
||||
|
|
@ -6962,30 +6869,12 @@ snapshots:
|
|||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
|
|
@ -6998,11 +6887,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
|
|
@ -7127,7 +7011,7 @@ snapshots:
|
|||
fs-extra: 10.1.0
|
||||
isbinaryfile: 4.0.10
|
||||
minimist: 1.2.8
|
||||
plist: 3.1.0
|
||||
plist: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -7148,9 +7032,9 @@ snapshots:
|
|||
'@malept/cross-spawn-promise': 2.0.0
|
||||
debug: 4.4.3
|
||||
dir-compare: 4.2.0
|
||||
fs-extra: 11.3.4
|
||||
fs-extra: 11.3.6
|
||||
minimatch: 9.0.9
|
||||
plist: 3.1.0
|
||||
plist: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -8992,24 +8876,24 @@ snapshots:
|
|||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@types/babel__generator': 7.27.0
|
||||
'@types/babel__template': 7.4.4
|
||||
'@types/babel__traverse': 7.28.0
|
||||
|
||||
'@types/babel__generator@7.27.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/babel__template@7.4.4':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/babel__traverse@7.28.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/cacheable-request@6.0.3':
|
||||
dependencies:
|
||||
|
|
@ -9369,8 +9253,7 @@ snapshots:
|
|||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.10':
|
||||
optional: true
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
|
||||
dependencies:
|
||||
|
|
@ -10588,24 +10471,11 @@ snapshots:
|
|||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.4:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.5:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.6:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
optional: true
|
||||
|
||||
fs-extra@7.0.1:
|
||||
dependencies:
|
||||
|
|
@ -12019,7 +11889,7 @@ snapshots:
|
|||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/code-frame': 7.29.7
|
||||
error-ex: 1.3.4
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
|
@ -12089,7 +11959,6 @@ snapshots:
|
|||
'@xmldom/xmldom': 0.9.10
|
||||
base64-js: 1.5.1
|
||||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
|
|
@ -12689,7 +12558,7 @@ snapshots:
|
|||
shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
|
||||
'@dotenvx/dotenvx': 1.66.0
|
||||
|
|
@ -12703,7 +12572,7 @@ snapshots:
|
|||
diff: 8.0.4
|
||||
execa: 9.6.1
|
||||
fast-glob: 3.3.3
|
||||
fs-extra: 11.3.5
|
||||
fs-extra: 11.3.6
|
||||
fuzzysort: 3.1.0
|
||||
https-proxy-agent: 7.0.6
|
||||
kleur: 4.1.5
|
||||
|
|
@ -12788,7 +12657,7 @@ snapshots:
|
|||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1026,6 +1026,27 @@
|
|||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor .rich-markdown-html-superscript-link {
|
||||
color: #0969da;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.rich-markdown-mod-held
|
||||
.rich-markdown-editor
|
||||
.rich-markdown-html-superscript-link[data-actionable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-markdown-editor img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react'
|
|||
|
||||
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
|
||||
import {
|
||||
createRichMarkdownEditorCodec,
|
||||
type RichMarkdownEditorCodec
|
||||
} from '@/components/editor/rich-markdown-source-transport'
|
||||
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = {
|
|||
submitShortcutLabel: string
|
||||
}
|
||||
|
||||
function createLinearIssueMarkdownExtensions() {
|
||||
const extensions = createRichMarkdownExtensions()
|
||||
function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) {
|
||||
const extensions = createRichMarkdownExtensions({ codec })
|
||||
return [
|
||||
...extensions,
|
||||
Placeholder.configure({
|
||||
|
|
@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({
|
|||
const richMarkdownSpellcheckEnabled = useAppStore(
|
||||
(s) => s.settings?.richMarkdownSpellcheckEnabled ?? true
|
||||
)
|
||||
// Why: changing language recreates Tiptap and re-registers tokenizers, so it
|
||||
// must also receive a fresh private Marked registry instead of growing one.
|
||||
const codec = useMemo(() => {
|
||||
void language
|
||||
return createRichMarkdownEditorCodec()
|
||||
}, [language])
|
||||
const linearIssueMarkdownExtensions = useMemo(() => {
|
||||
// Why: Tiptap freezes extension options when the editor is created; the
|
||||
// language value is the recreation key for translated extension options.
|
||||
void language
|
||||
return createLinearIssueMarkdownExtensions()
|
||||
}, [language])
|
||||
return createLinearIssueMarkdownExtensions(codec)
|
||||
}, [codec, language])
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
immediatelyRender: false,
|
||||
extensions: linearIssueMarkdownExtensions,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
|
||||
contentType: 'markdown',
|
||||
editable: !disabled,
|
||||
editorProps: {
|
||||
|
|
@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({
|
|||
onChange(nextValue)
|
||||
}
|
||||
},
|
||||
[language]
|
||||
[codec, language]
|
||||
)
|
||||
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
|
||||
|
||||
|
|
@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({
|
|||
|
||||
// Why: Linear remains the source of truth when the selected issue changes
|
||||
// or an optimistic save is reverted; keep the rich view aligned with it.
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
lastEditorMarkdownRef.current = value
|
||||
}, [editor, value])
|
||||
}, [codec, editor, value])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const createBrowserTabMock = vi.fn()
|
||||
const setActiveWorktreeMock = vi.fn()
|
||||
const openUrlMock = vi.fn()
|
||||
const openFileUriMock = vi.fn()
|
||||
const pathExistsMock = vi.fn(async () => true)
|
||||
const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined }))
|
||||
const targetConnectionOwners = vi.hoisted(() => new Map<string, string | null | undefined>())
|
||||
const worktreeLookup = vi.hoisted(() => ({
|
||||
value: [] as { id: string; path: string; diffComments: never[] }[]
|
||||
}))
|
||||
const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false })))
|
||||
|
||||
// Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful
|
||||
// of action functions. None of the actions fire on the http path under test.
|
||||
|
|
@ -31,6 +39,9 @@ const storeState = {
|
|||
clearDeliveredDiffComments: vi.fn(),
|
||||
keybindings: {},
|
||||
worktreesByRepo: {},
|
||||
repos: [],
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
settings: { openLinksInApp: true },
|
||||
|
|
@ -44,14 +55,22 @@ vi.mock('@/store', () => {
|
|||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null }))
|
||||
vi.mock('@/store/slices/worktree-helpers', () => ({
|
||||
findWorktreeById: (_worktrees: unknown, id: string) =>
|
||||
worktreeLookup.value.find((worktree) => worktree.id === id) ?? null
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
settingsForRuntimeOwner: (settings: unknown) => settings
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
|
||||
statRuntimePath: statRuntimePathMock
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId)
|
||||
}))
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
createConnectionIdForFileSelector: () => () => connectionOwner.value
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
|
||||
vi.mock('./MermaidBlock', () => ({ default: () => null }))
|
||||
|
|
@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
|||
;(window as unknown as { api: unknown }).api = {
|
||||
shell: {
|
||||
openUrl: openUrlMock,
|
||||
openFileUri: vi.fn(),
|
||||
pathExists: vi.fn(async () => true)
|
||||
openFileUri: openFileUriMock,
|
||||
pathExists: pathExistsMock
|
||||
},
|
||||
ui: { writeClipboardText: vi.fn(async () => true) }
|
||||
}
|
||||
|
|
@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
|||
createBrowserTabMock.mockClear()
|
||||
setActiveWorktreeMock.mockClear()
|
||||
openUrlMock.mockClear()
|
||||
openFileUriMock.mockClear()
|
||||
pathExistsMock.mockClear()
|
||||
connectionOwner.value = null
|
||||
targetConnectionOwners.clear()
|
||||
worktreeLookup.value = []
|
||||
storeState.worktreesByRepo = {}
|
||||
storeState.openMarkdownPreview.mockClear()
|
||||
statRuntimePathMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
|||
container.remove()
|
||||
})
|
||||
|
||||
function render(): HTMLAnchorElement {
|
||||
function render(
|
||||
content = '[example](https://example.com)',
|
||||
expectedHref = 'https://example.com',
|
||||
sourceWorktreeId = 'wt-1',
|
||||
filePath = '/repo/docs/README.md'
|
||||
): HTMLAnchorElement {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(
|
||||
<MarkdownPreview
|
||||
content="[example](https://example.com)"
|
||||
filePath="/repo/docs/README.md"
|
||||
sourceWorktreeId="wt-1"
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
sourceWorktreeId={sourceWorktreeId}
|
||||
scrollCacheKey="test-key"
|
||||
/>
|
||||
)
|
||||
})
|
||||
const anchor = container.querySelector<HTMLAnchorElement>('a[href="https://example.com"]')
|
||||
const anchor = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find(
|
||||
(candidate) => candidate.getAttribute('href') === expectedHref
|
||||
)
|
||||
if (!anchor) {
|
||||
throw new Error('expected a rendered http anchor')
|
||||
}
|
||||
|
|
@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
|||
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps system-browser HTTP and file links inert while ownership is unknown', () => {
|
||||
connectionOwner.value = undefined
|
||||
const httpAnchor = render()
|
||||
click(httpAnchor, { metaKey: true, shiftKey: true })
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md')
|
||||
click(fileAnchor, { metaKey: true, shiftKey: true })
|
||||
expect(pathExistsMock).not.toHaveBeenCalled()
|
||||
expect(openFileUriMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps same-path file links on the explicit SSH source worktree', async () => {
|
||||
const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] }
|
||||
const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] }
|
||||
worktreeLookup.value = [localWorktree, sshWorktree]
|
||||
storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] }
|
||||
connectionOwner.value = 'ssh-1'
|
||||
targetConnectionOwners.set('local-wt', null)
|
||||
targetConnectionOwners.set('ssh-wt', 'ssh-1')
|
||||
const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md')
|
||||
|
||||
await act(async () => {
|
||||
anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
|
||||
expect(statRuntimePathMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }),
|
||||
'/srv/repo/child.md'
|
||||
)
|
||||
expect(storeState.openMarkdownPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }),
|
||||
{ anchor: null }
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a raw HTML superscript citation and routes it like a Markdown link', () => {
|
||||
const source = '<sup><a href="https://example.com">[12]</a></sup>'
|
||||
const anchor = render(source)
|
||||
|
||||
expect(anchor.parentElement?.tagName).toBe('SUP')
|
||||
expect(anchor.textContent).toBe('[12]')
|
||||
expect(container.textContent).not.toContain('<sup>')
|
||||
click(anchor, { metaKey: true })
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const storeState = {
|
|||
clearDeliveredDiffComments: vi.fn(),
|
||||
keybindings: {},
|
||||
worktreesByRepo: {},
|
||||
repos: [],
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
settings: { openLinksInApp: true },
|
||||
|
|
@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
|
|||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
createConnectionIdForFileSelector: () => () => null
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
|
||||
vi.mock('./MermaidBlock', () => ({ default: () => null }))
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input'
|
|||
import { useAppStore } from '@/store'
|
||||
import { toast } from 'sonner'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types'
|
||||
|
|
@ -68,7 +69,7 @@ import {
|
|||
setActiveMarkdownPreviewSearchMatch
|
||||
} from './markdown-preview-search'
|
||||
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
|
||||
|
|
@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot(
|
|||
|
||||
function findWorktreeForMarkdownPreviewPath(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
absolutePath: string
|
||||
absolutePath: string,
|
||||
acceptsWorktree: (worktree: Worktree) => boolean = () => true
|
||||
): Worktree | null {
|
||||
let bestMatch: Worktree | null = null
|
||||
let bestMatchLength = -1
|
||||
|
||||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (relativePathInsideRoot(worktree.path, absolutePath) !== null) {
|
||||
if (
|
||||
acceptsWorktree(worktree) &&
|
||||
relativePathInsideRoot(worktree.path, absolutePath) !== null
|
||||
) {
|
||||
const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath(
|
||||
worktree.path
|
||||
).length
|
||||
|
|
@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath(
|
|||
return bestMatch
|
||||
}
|
||||
|
||||
function findMarkdownPreviewTargetWorktree(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
absolutePath: string,
|
||||
sourceWorktree: Worktree | null,
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
): Worktree | null {
|
||||
if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) {
|
||||
return sourceWorktree
|
||||
}
|
||||
return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => {
|
||||
const connectionId = getConnectionIdForFile(worktree.id, absolutePath)
|
||||
if (sourceOwner.kind === 'local') {
|
||||
return connectionId === null
|
||||
}
|
||||
if (sourceOwner.kind === 'ssh') {
|
||||
return connectionId === sourceOwner.connectionId
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveMarkdownPreviewSourceWorktree(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
sourceWorktreeId: string | null | undefined,
|
||||
|
|
@ -498,9 +524,26 @@ export default function MarkdownPreview({
|
|||
)
|
||||
const allDiffComments = sourceWorktree?.diffComments
|
||||
const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId
|
||||
const sourceConnectionId = sourceRoutingWorktreeId
|
||||
? (getConnectionId(sourceRoutingWorktreeId) ?? null)
|
||||
: null
|
||||
const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim()
|
||||
const sourceConnectionIdSelector = useMemo(
|
||||
() =>
|
||||
createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, {
|
||||
skip: Boolean(runtimeOwnerId)
|
||||
}),
|
||||
[filePath, runtimeOwnerId, sourceRoutingWorktreeId]
|
||||
)
|
||||
const sourceConnectionId = useAppStore(sourceConnectionIdSelector)
|
||||
const sourceOwner = useMemo<HttpLinkSourceOwner>(
|
||||
() =>
|
||||
runtimeOwnerId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
|
||||
: sourceConnectionId === undefined
|
||||
? { kind: 'unknown' }
|
||||
: sourceConnectionId === null
|
||||
? { kind: 'local' }
|
||||
: { kind: 'ssh', connectionId: sourceConnectionId },
|
||||
[runtimeOwnerId, sourceConnectionId]
|
||||
)
|
||||
const worktreeRoot =
|
||||
sourceWorktree?.path ??
|
||||
(sourceRoutingWorktreeId
|
||||
|
|
@ -1254,6 +1297,9 @@ export default function MarkdownPreview({
|
|||
// dangling in-worktree .md, pre-check existence so the user sees a
|
||||
// toast instead of the silent no-op from shell.openFileUri.
|
||||
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const osTarget = getMarkdownPreviewLinkTarget(href, filePath)
|
||||
if (!osTarget) {
|
||||
return
|
||||
|
|
@ -1267,7 +1313,12 @@ export default function MarkdownPreview({
|
|||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
openHttpLink(
|
||||
parsed.toString(),
|
||||
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
event,
|
||||
isMac,
|
||||
sourceRoutingWorktreeId,
|
||||
sourceOwner
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -1326,7 +1377,12 @@ export default function MarkdownPreview({
|
|||
// handled above; this path only sees non-escape-hatch clicks.)
|
||||
openHttpLink(
|
||||
target.toString(),
|
||||
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
event,
|
||||
isMac,
|
||||
sourceRoutingWorktreeId,
|
||||
sourceOwner
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -1347,12 +1403,25 @@ export default function MarkdownPreview({
|
|||
? { line: classifiedFileTarget.line, column: classifiedFileTarget.column }
|
||||
: parseLineTarget(target.hash)
|
||||
|
||||
// Why: same-file anchors need no ownership/filesystem resolution (e.g.
|
||||
// `./README.md#heading` when this file is README.md). Run before the
|
||||
// unknown-ownership guard so ambiguous folder-workspace ownership still
|
||||
// scrolls within the open document.
|
||||
if (absolutePath === filePath && target.hash && !lineTarget) {
|
||||
void scrollToAnchor(target.hash.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath)
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
|
||||
const targetWorktree = findMarkdownPreviewTargetWorktree(
|
||||
worktreesByRepo,
|
||||
absolutePath,
|
||||
sourceWorktree,
|
||||
sourceOwner
|
||||
)
|
||||
if (!targetWorktree) {
|
||||
if (sourceRoutingWorktreeId && worktreeRoot) {
|
||||
// Why: floating markdown files are owned by a synthetic workspace,
|
||||
|
|
@ -1362,7 +1431,8 @@ export default function MarkdownPreview({
|
|||
sourceFilePath: filePath,
|
||||
worktreeId: sourceRoutingWorktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -1384,8 +1454,15 @@ export default function MarkdownPreview({
|
|||
return
|
||||
}
|
||||
|
||||
const relativePath = absolutePath.slice(targetWorktree.path.length + 1)
|
||||
const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath)
|
||||
if (relativePath === null) {
|
||||
return
|
||||
}
|
||||
const language = detectLanguage(absolutePath)
|
||||
const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath)
|
||||
if (targetConnectionId === undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const stats = await statRuntimePath(
|
||||
{
|
||||
|
|
@ -1395,7 +1472,7 @@ export default function MarkdownPreview({
|
|||
),
|
||||
worktreeId: targetWorktree.id,
|
||||
worktreePath: targetWorktree.path,
|
||||
connectionId: getConnectionId(targetWorktree.id) ?? undefined
|
||||
connectionId: targetConnectionId ?? undefined
|
||||
},
|
||||
absolutePath
|
||||
)
|
||||
|
|
@ -1512,7 +1589,8 @@ export default function MarkdownPreview({
|
|||
sourceFilePath: filePath,
|
||||
worktreeId: sourceRoutingWorktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1673,6 +1751,8 @@ export default function MarkdownPreview({
|
|||
setMarkdownViewMode,
|
||||
setPendingEditorReveal,
|
||||
sourceConnectionId,
|
||||
sourceOwner,
|
||||
sourceWorktree,
|
||||
resolvedSourceRuntimeEnvironmentId,
|
||||
sourceRoutingWorktreeId,
|
||||
worktreeRoot,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState, type Editor } from '@tiptap/react'
|
||||
import type { DiffComment, MarkdownDocument } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { useLocalImagePick } from './useLocalImagePick'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
|
|
@ -25,6 +24,11 @@ import {
|
|||
runRichMarkdownContextCommand
|
||||
} from './rich-markdown-context-command-routing'
|
||||
import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
|
||||
import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup'
|
||||
import {
|
||||
formatSelectedHtmlSuperscriptLinkStatus,
|
||||
getSelectedHtmlSuperscriptLinkStatus
|
||||
} from './rich-markdown-selected-link-actions'
|
||||
|
||||
type RichMarkdownEditorProps = {
|
||||
fileId: string
|
||||
|
|
@ -86,9 +90,11 @@ export default function RichMarkdownEditor({
|
|||
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
|
||||
selectWorktreeDiffComments(s, worktreeId)
|
||||
)
|
||||
const worktreeRoot = useAppStore(
|
||||
(s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null
|
||||
)
|
||||
const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
worktreeId
|
||||
})
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const menu = useRichMarkdownMenuController({ markdownDocuments })
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
|
@ -196,6 +202,8 @@ export default function RichMarkdownEditor({
|
|||
)
|
||||
|
||||
const editor = useRichMarkdownEditorInstance({
|
||||
codec,
|
||||
htmlSuperscriptLinkContext,
|
||||
content,
|
||||
filePath,
|
||||
worktreeId,
|
||||
|
|
@ -240,6 +248,13 @@ export default function RichMarkdownEditor({
|
|||
setSlashMenu: menu.setSlashMenu,
|
||||
setDocLinkMenu: menu.setDocLinkMenu
|
||||
})
|
||||
// Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
|
||||
// citation NodeSelections would leave aria status stale without useEditorState.
|
||||
const selectedCitationStatus = useEditorState({
|
||||
editor,
|
||||
selector: (snapshot) =>
|
||||
getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext)
|
||||
})
|
||||
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
|
||||
|
||||
// Why: use useLayoutEffect (synchronous cleanup) so the pending serialization
|
||||
|
|
@ -265,6 +280,7 @@ export default function RichMarkdownEditor({
|
|||
})
|
||||
|
||||
useRichMarkdownProgrammaticSync({
|
||||
codec,
|
||||
content,
|
||||
docLinkMenuSetter: menu.setDocLinkMenu,
|
||||
editor,
|
||||
|
|
@ -289,12 +305,14 @@ export default function RichMarkdownEditor({
|
|||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
handleLinkCopy,
|
||||
toggleLinkFromToolbar
|
||||
} = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, {
|
||||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
htmlSuperscriptLinkContext
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -370,6 +388,12 @@ export default function RichMarkdownEditor({
|
|||
showTableOfContents={showTableOfContents}
|
||||
searchState={searchState}
|
||||
searchActions={searchActions}
|
||||
citationStatus={
|
||||
selectedCitationStatus
|
||||
? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus)
|
||||
: ''
|
||||
}
|
||||
linkBubbleOwnerId={codec.transport.key}
|
||||
linkBubbleActions={{
|
||||
dismissLinkBubble: () => {
|
||||
setLinkBubble(null)
|
||||
|
|
@ -379,6 +403,7 @@ export default function RichMarkdownEditor({
|
|||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
handleLinkCopy,
|
||||
setIsEditingLink
|
||||
}}
|
||||
onToggleLink={toggleLinkFromToolbar}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = {
|
|||
matchCase: boolean
|
||||
matchCount: number
|
||||
replaceQuery: string
|
||||
replaceDisabled: boolean
|
||||
searchQuery: string
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
wholeWord: boolean
|
||||
|
|
@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = {
|
|||
toggleReplaceMode: () => void
|
||||
toggleWholeWord: () => void
|
||||
}
|
||||
citationStatus: string
|
||||
linkBubbleOwnerId: string
|
||||
linkBubbleActions: {
|
||||
dismissLinkBubble: () => void
|
||||
handleLinkSave: (href: string) => void
|
||||
handleLinkRemove: () => void
|
||||
handleLinkEditCancel: () => void
|
||||
handleLinkOpen: () => void
|
||||
handleLinkCopy: () => void
|
||||
setIsEditingLink: (editing: boolean) => void
|
||||
}
|
||||
onToggleLink: () => void
|
||||
|
|
@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({
|
|||
showTableOfContents,
|
||||
searchState,
|
||||
searchActions,
|
||||
citationStatus,
|
||||
linkBubbleOwnerId,
|
||||
linkBubbleActions,
|
||||
onToggleLink,
|
||||
onImagePick,
|
||||
|
|
@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({
|
|||
matchCount={searchState.matchCount}
|
||||
query={searchState.searchQuery}
|
||||
replaceQuery={searchState.replaceQuery}
|
||||
replaceDisabled={searchState.replaceDisabled}
|
||||
searchInputRef={searchState.searchInputRef}
|
||||
wholeWord={searchState.wholeWord}
|
||||
onClose={searchActions.closeSearch}
|
||||
|
|
@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({
|
|||
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
|
||||
onEditCancel={linkBubbleActions.handleLinkEditCancel}
|
||||
onOpen={linkBubbleActions.handleLinkOpen}
|
||||
onCopy={linkBubbleActions.handleLinkCopy}
|
||||
ownerId={linkBubbleOwnerId}
|
||||
/>
|
||||
) : null}
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{citationStatus}
|
||||
</span>
|
||||
{slashMenu ? (
|
||||
<RichMarkdownSlashMenu
|
||||
editor={editor}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
|
||||
import { Copy, ExternalLink, Pencil, Unlink } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export type LinkBubbleState = {
|
||||
kind: 'markdown' | 'html-superscript'
|
||||
href: string
|
||||
left: number
|
||||
top: number
|
||||
openEnabled: boolean
|
||||
copyEnabled: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
const LINK_BUBBLE_VIEWPORT_MARGIN = 8
|
||||
|
|
@ -160,6 +166,8 @@ type RichMarkdownLinkBubbleProps = {
|
|||
onEditStart: () => void
|
||||
onEditCancel: () => void
|
||||
onOpen: () => void
|
||||
onCopy: () => void
|
||||
ownerId?: string
|
||||
portalToDocument?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({
|
|||
onEditStart,
|
||||
onEditCancel,
|
||||
onOpen,
|
||||
onCopy,
|
||||
ownerId,
|
||||
portalToDocument = false
|
||||
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
|
||||
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
|
|||
<div
|
||||
ref={bubbleRef}
|
||||
className="rich-markdown-link-bubble"
|
||||
data-rich-markdown-link-bubble=""
|
||||
data-rich-markdown-link-bubble-owner={ownerId}
|
||||
style={positionStyle}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent editor blur when clicking bubble buttons, but let inputs
|
||||
|
|
@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({
|
|||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onDismiss()
|
||||
anchorElement?.querySelector<HTMLElement>('[contenteditable="true"]')?.focus()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
|
||||
return
|
||||
}
|
||||
const buttons = Array.from(
|
||||
bubbleRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') ?? []
|
||||
)
|
||||
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
if (currentIndex === -1 || buttons.length === 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||
buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus()
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<LinkEditInput initialHref={linkBubble.href} onSave={onSave} onCancel={onEditCancel} />
|
||||
|
|
@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({
|
|||
<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={translate(
|
||||
<LinkBubbleAction
|
||||
label={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.bfc813e909',
|
||||
'Open link'
|
||||
)}
|
||||
disabled={!linkBubble.openEnabled}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onEditStart}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
|
||||
'Edit link'
|
||||
)}
|
||||
</LinkBubbleAction>
|
||||
<LinkBubbleAction
|
||||
label={translate('auto.components.editor.RichMarkdownLinkBubble.copyLink', 'Copy link')}
|
||||
disabled={!linkBubble.copyEnabled}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onRemove}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
|
||||
'Remove link'
|
||||
)}
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</button>
|
||||
<Copy size={14} />
|
||||
</LinkBubbleAction>
|
||||
{linkBubble.kind === 'markdown' ? (
|
||||
<>
|
||||
<LinkBubbleAction
|
||||
label={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
|
||||
'Edit link'
|
||||
)}
|
||||
onClick={onEditStart}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</LinkBubbleAction>
|
||||
<LinkBubbleAction
|
||||
label={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
|
||||
'Remove link'
|
||||
)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</LinkBubbleAction>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({
|
|||
// actions must portal to the app layer to remain above the right sidebar.
|
||||
return portalToDocument ? createPortal(bubble, document.body) : bubble
|
||||
}
|
||||
|
||||
function LinkBubbleAction({
|
||||
children,
|
||||
disabled = false,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="rich-markdown-link-button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = {
|
|||
matchCount: number
|
||||
query: string
|
||||
replaceQuery: string
|
||||
replaceDisabled: boolean
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
wholeWord: boolean
|
||||
onClose: () => void
|
||||
|
|
@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({
|
|||
matchCount,
|
||||
query,
|
||||
replaceQuery,
|
||||
replaceDisabled,
|
||||
searchInputRef,
|
||||
wholeWord,
|
||||
onClose,
|
||||
|
|
@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({
|
|||
// Why: surface the same replace shortcut the source editor uses so the toggle
|
||||
// is discoverable; reads the user's effective binding, formatted per platform.
|
||||
const replaceShortcut = useOptionalShortcutLabel('editor.replace')
|
||||
const readOnlyExplanationId = React.useId()
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
|
|
@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({
|
|||
}
|
||||
|
||||
const noMatches = matchCount === 0
|
||||
const readOnlyReplaceExplanation = translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly',
|
||||
'Preserved rich content is read-only in rich mode.'
|
||||
)
|
||||
const toggleReplaceLabel = isReplaceMode
|
||||
? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace')
|
||||
: translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace')
|
||||
|
|
@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({
|
|||
'auto.components.editor.RichMarkdownSearchBar.44682b4159',
|
||||
'Replace in rich markdown editor'
|
||||
)}
|
||||
aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -267,11 +275,12 @@ export function RichMarkdownSearchBar({
|
|||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={onReplaceCurrent}
|
||||
disabled={noMatches}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
|
||||
'Replace'
|
||||
)}
|
||||
disabled={noMatches || replaceDisabled}
|
||||
title={
|
||||
replaceDisabled
|
||||
? readOnlyReplaceExplanation
|
||||
: translate('auto.components.editor.RichMarkdownSearchBar.fd97c7e585', 'Replace')
|
||||
}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
|
||||
'Replace'
|
||||
|
|
@ -286,11 +295,15 @@ export function RichMarkdownSearchBar({
|
|||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={onReplaceAll}
|
||||
disabled={noMatches}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
|
||||
'Replace all'
|
||||
)}
|
||||
disabled={noMatches || replaceDisabled}
|
||||
title={
|
||||
replaceDisabled
|
||||
? readOnlyReplaceExplanation
|
||||
: translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
|
||||
'Replace all'
|
||||
)
|
||||
}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
|
||||
'Replace all'
|
||||
|
|
@ -299,6 +312,11 @@ export function RichMarkdownSearchBar({
|
|||
>
|
||||
<ReplaceAll size={14} />
|
||||
</Button>
|
||||
{replaceDisabled ? (
|
||||
<span id={readOnlyExplanationId} className="sr-only" role="status">
|
||||
{readOnlyReplaceExplanation}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
export type HtmlAttributeQuote = '"' | "'" | null
|
||||
|
||||
export function decodeHtmlTextCharacterReferences(value: string): string {
|
||||
// Why: a <template> parses markup inertly (no script/resource execution), so
|
||||
// reading textContent safely decodes character references without XSS risk.
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = value
|
||||
return template.content.textContent ?? ''
|
||||
}
|
||||
|
||||
export function decodeHtmlAttributeCharacterReferences(
|
||||
value: string,
|
||||
quote: HtmlAttributeQuote
|
||||
): string {
|
||||
// Why: inert <template> decoding; attribute write uses the caller-supplied
|
||||
// quote and parser-constrained value, so this is not an injection sink.
|
||||
const template = document.createElement('template')
|
||||
const delimiter = quote ?? ''
|
||||
template.innerHTML = `<span data-orca-value=${delimiter}${value}${delimiter}></span>`
|
||||
const element = template.content.firstElementChild
|
||||
return element?.getAttribute('data-orca-value') ?? ''
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
export function createIsolatedMarkdownExtensionForTests() {
|
||||
return Markdown.configure({
|
||||
marked: createRichMarkdownEditorCodec().marked,
|
||||
markedOptions: { gfm: true }
|
||||
})
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
|
|||
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 { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { normalizeEmptyListItems } from './rich-markdown-normalize'
|
||||
|
||||
const testExtensions = [
|
||||
|
|
@ -18,7 +18,7 @@ const testExtensions = [
|
|||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({ markedOptions: { gfm: true } })
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
]
|
||||
|
||||
function createEditor(markdown: string): Editor {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import {
|
|||
filesystemPathToFileUri,
|
||||
fileUriToFilesystemPath
|
||||
} from '../../../../shared/file-uri-path'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import {
|
||||
isWindowsAbsolutePathLike,
|
||||
relativePathInsideRoot
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
|
||||
// Pure classifier for markdown link targets. Called by the link-activation
|
||||
// dispatcher (activateMarkdownLink slice action) from three call sites —
|
||||
|
|
@ -31,13 +34,6 @@ export type MarkdownLinkTarget =
|
|||
column?: number
|
||||
}
|
||||
|
||||
// Why: renderer runs with sandbox + contextIsolation, so process.platform is
|
||||
// unavailable. navigator.userAgent is the portable fallback (AGENTS.md).
|
||||
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
|
||||
const isMacLike = ua.includes('Mac')
|
||||
const isWindowsLike = ua.includes('Windows')
|
||||
const caseInsensitiveFs = isMacLike || isWindowsLike
|
||||
|
||||
const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown'])
|
||||
|
||||
export function absolutePathToFileUri(filePath: string): string {
|
||||
|
|
@ -60,17 +56,6 @@ function normalizePathForCompare(p: string): string {
|
|||
return np
|
||||
}
|
||||
|
||||
function isDescendantOf(childAbs: string, parentAbs: string): boolean {
|
||||
const child = normalizePathForCompare(childAbs)
|
||||
const parent = normalizePathForCompare(parentAbs)
|
||||
if (caseInsensitiveFs) {
|
||||
const lc = child.toLowerCase()
|
||||
const lp = parent.toLowerCase()
|
||||
return lc === lp || lc.startsWith(`${lp}/`)
|
||||
}
|
||||
return child === parent || child.startsWith(`${parent}/`)
|
||||
}
|
||||
|
||||
function hasMarkdownExtension(p: string): boolean {
|
||||
const lastDot = p.lastIndexOf('.')
|
||||
if (lastDot === -1) {
|
||||
|
|
@ -125,25 +110,7 @@ function resolveRelativeToSource(rawHref: string, sourceFilePath: string): URL |
|
|||
}
|
||||
|
||||
function computeRelativePath(absolutePath: string, worktreeRoot: string): string {
|
||||
const parent = normalizePathForCompare(worktreeRoot)
|
||||
const child = normalizePathForCompare(absolutePath)
|
||||
const prefix = `${parent}/`
|
||||
if (caseInsensitiveFs) {
|
||||
if (child.toLowerCase() === parent.toLowerCase()) {
|
||||
return ''
|
||||
}
|
||||
if (child.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return child.slice(prefix.length)
|
||||
}
|
||||
} else {
|
||||
if (child === parent) {
|
||||
return ''
|
||||
}
|
||||
if (child.startsWith(prefix)) {
|
||||
return child.slice(prefix.length)
|
||||
}
|
||||
}
|
||||
return child
|
||||
return relativePathInsideRoot(worktreeRoot, absolutePath) ?? normalizePathForCompare(absolutePath)
|
||||
}
|
||||
|
||||
export function resolveMarkdownLinkTarget(
|
||||
|
|
@ -195,7 +162,7 @@ export function resolveMarkdownLinkTarget(
|
|||
if (
|
||||
worktreeRoot !== null &&
|
||||
hasMarkdownExtension(pathForClassification) &&
|
||||
isDescendantOf(pathForClassification, worktreeRoot)
|
||||
relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
|
||||
) {
|
||||
const relativePath = computeRelativePath(pathForClassification, worktreeRoot)
|
||||
return {
|
||||
|
|
@ -208,7 +175,7 @@ export function resolveMarkdownLinkTarget(
|
|||
}
|
||||
|
||||
const relativePath =
|
||||
worktreeRoot !== null && isDescendantOf(pathForClassification, worktreeRoot)
|
||||
worktreeRoot !== null && relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
|
||||
? computeRelativePath(pathForClassification, worktreeRoot)
|
||||
: undefined
|
||||
|
||||
|
|
@ -226,3 +193,52 @@ export function resolveMarkdownLinkTarget(
|
|||
column
|
||||
}
|
||||
}
|
||||
|
||||
const HTML_ATTRIBUTE_WHITESPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g
|
||||
|
||||
export function projectMarkdownHrefForClipboard(href: string): string | null {
|
||||
const projected = href.replace(HTML_ATTRIBUTE_WHITESPACE, '')
|
||||
if (!projected || containsAsciiControl(projected)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
isWindowsAbsolutePathLike(projected) ||
|
||||
projected.startsWith('\\\\') ||
|
||||
projected.startsWith('//')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (projected.startsWith('#')) {
|
||||
return projected
|
||||
}
|
||||
|
||||
const scheme = /^([A-Za-z][A-Za-z\d+.-]*):/.exec(projected)?.[1]?.toLowerCase()
|
||||
if (!scheme) {
|
||||
return projected
|
||||
}
|
||||
if (scheme !== 'http' && scheme !== 'https' && scheme !== 'file') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(projected)
|
||||
if (parsed.protocol !== `${scheme}:`) {
|
||||
return null
|
||||
}
|
||||
if (scheme === 'file' && !parsed.pathname) {
|
||||
return null
|
||||
}
|
||||
return projected
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function containsAsciiControl(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code <= 31 || code === 127) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,9 +149,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: true, ctrlKey: false, shiftKey: true },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ forceSystemBrowser: true })
|
||||
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => {
|
||||
|
|
@ -159,9 +160,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: true, shiftKey: true },
|
||||
false,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ forceSystemBrowser: true })
|
||||
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('routes a plain Cmd-click through the worktree so it can open in Orca', () => {
|
||||
|
|
@ -169,9 +171,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: true, ctrlKey: false, shiftKey: false },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('routes a plain click (no modifier) through the worktree', () => {
|
||||
|
|
@ -179,9 +182,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey: false },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('does not force the system browser for Shift without the platform mod key', () => {
|
||||
|
|
@ -190,9 +194,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey: true },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('does not treat Mac Ctrl+Shift-click as the escape hatch', () => {
|
||||
|
|
@ -201,9 +206,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: true, shiftKey: true },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('passes through a null worktree (openHttpLink then falls back to system browser)', () => {
|
||||
|
|
@ -211,9 +217,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
|||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey: false },
|
||||
true,
|
||||
null
|
||||
null,
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: null })
|
||||
).toEqual({ worktreeId: null, sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
fileUriToFilesystemPath
|
||||
} from '../../../../shared/file-uri-path'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import type { OpenHttpLinkOptions } from '@/lib/http-link-routing'
|
||||
import type { HttpLinkSourceOwner, OpenHttpLinkOptions } from '@/lib/http-link-routing'
|
||||
|
||||
function toFileUrl(filePath: string): string {
|
||||
return filesystemPathToFileUri(filePath)
|
||||
|
|
@ -120,12 +120,13 @@ export function isMarkdownPreviewSystemBrowserModifier(
|
|||
export function resolveMarkdownPreviewHttpOpenOptions(
|
||||
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
|
||||
isMac: boolean,
|
||||
worktreeId: string | null
|
||||
worktreeId: string | null,
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
): OpenHttpLinkOptions {
|
||||
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
|
||||
return { forceSystemBrowser: true }
|
||||
return { forceSystemBrowser: true, sourceOwner }
|
||||
}
|
||||
return { worktreeId }
|
||||
return { worktreeId, sourceOwner }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest'
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import type { SlashCommandId } from './rich-markdown-slash-commands'
|
||||
import { slashCommands } from './rich-markdown-slash-commands'
|
||||
|
||||
function roundTripMarkdown(content: string): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
|
|
@ -21,10 +23,11 @@ function roundTripMarkdown(content: string): string {
|
|||
}
|
||||
|
||||
function markdownAfterTextReplace(content: string, search: string, replacement: string): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
|
|
@ -50,9 +53,10 @@ function markdownAfterTextReplace(content: string, search: string, replacement:
|
|||
}
|
||||
|
||||
function slashCommandMarkdown(commandId: SlashCommandId): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: '',
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
|
@ -71,9 +75,10 @@ function slashCommandMarkdown(commandId: SlashCommandId): string {
|
|||
}
|
||||
|
||||
function slashCommandSelectionParent(commandId: SlashCommandId): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: '',
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
// Why: extensions are lazily created on first use to avoid eager instantiation
|
||||
// at import time. A single shared instance is safe here because only one
|
||||
// round-trip Editor is alive at a time (created and destroyed synchronously).
|
||||
let roundTripExtensions: ReturnType<typeof createRichMarkdownExtensions> | null = null
|
||||
const roundTripCache = new Map<string, string | null>()
|
||||
const MAX_CACHE_ENTRIES = 20
|
||||
|
||||
|
|
@ -23,13 +21,23 @@ export function getRichMarkdownRoundTripOutput(content: string): string | null {
|
|||
let output: string | null = null
|
||||
|
||||
try {
|
||||
if (!roundTripExtensions) {
|
||||
roundTripExtensions = createRichMarkdownExtensions()
|
||||
}
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '',
|
||||
worktreeId: '',
|
||||
worktreeRoot: null,
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: roundTripExtensions,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,53 +2,16 @@ import { Node, mergeAttributes } from '@tiptap/core'
|
|||
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
|
||||
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
|
||||
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
|
||||
|
||||
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
|
||||
const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
|
||||
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
|
||||
const PLACEHOLDER_SUFFIX = ']]'
|
||||
import type {
|
||||
RichMarkdownEditorCodec,
|
||||
RichMarkdownSourceKind,
|
||||
RichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport'
|
||||
import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source'
|
||||
|
||||
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
|
||||
|
||||
function encodeHtmlPayload(raw: string): string {
|
||||
return encodeURIComponent(raw)
|
||||
}
|
||||
|
||||
function decodeHtmlPayload(payload: string): string {
|
||||
try {
|
||||
return decodeURIComponent(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function createPlaceholder(kind: 'inline' | 'block', raw: string): string {
|
||||
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
|
||||
return `${prefix}${encodeHtmlPayload(raw)}${PLACEHOLDER_SUFFIX}`
|
||||
}
|
||||
|
||||
function matchPlaceholder(
|
||||
src: string,
|
||||
kind: 'inline' | 'block'
|
||||
): { placeholder: string; value: string } | null {
|
||||
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
|
||||
if (!src.startsWith(prefix)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const endIndex = src.indexOf(PLACEHOLDER_SUFFIX, prefix.length)
|
||||
if (endIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const placeholder = src.slice(0, endIndex + PLACEHOLDER_SUFFIX.length)
|
||||
const payload = src.slice(prefix.length, endIndex)
|
||||
return {
|
||||
placeholder,
|
||||
value: decodeHtmlPayload(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function matchInlineHtml(src: string): string | null {
|
||||
const match = src.match(INLINE_HTML_PATTERN)
|
||||
return match?.[0] ?? null
|
||||
|
|
@ -90,8 +53,13 @@ function matchBlockHtml(content: string, start: number): string | null {
|
|||
return line
|
||||
}
|
||||
|
||||
export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
|
||||
export function encodeRawMarkdownHtmlForRichEditor(
|
||||
content: string,
|
||||
codec: RichMarkdownEditorCodec,
|
||||
{ htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {}
|
||||
): string {
|
||||
const normalizedContent = normalizeMarkdownReferenceLinks(content)
|
||||
const { transport } = codec
|
||||
let index = 0
|
||||
let isLineStart = true
|
||||
let activeFence: '`' | '~' | null = null
|
||||
|
|
@ -170,47 +138,65 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
|
|||
}
|
||||
|
||||
if (detailsHtml) {
|
||||
result += createPlaceholder('block', detailsHtml.raw)
|
||||
result += transport.create('block-html', detailsHtml.raw)
|
||||
index += detailsHtml.raw.length
|
||||
continue
|
||||
}
|
||||
|
||||
const blockHtml = matchBlockHtml(normalizedContent, index)
|
||||
if (blockHtml) {
|
||||
result += createPlaceholder('block', blockHtml)
|
||||
result += transport.create('block-html', blockHtml)
|
||||
index += blockHtml.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Why: authored text that happens to contain this editor's random envelope
|
||||
// prefix must remain literal even in HTML-free documents and after edits.
|
||||
if (normalizedContent.startsWith(transport.authoredPrefix, index)) {
|
||||
const authoredEnd = normalizedContent.indexOf(']]', index + transport.authoredPrefix.length)
|
||||
const authoredOccurrence =
|
||||
authoredEnd === -1
|
||||
? transport.authoredPrefix
|
||||
: normalizedContent.slice(index, authoredEnd + 2)
|
||||
result += transport.create('literal', authoredOccurrence)
|
||||
index += authoredOccurrence.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) {
|
||||
if (htmlSuperscriptLinks) {
|
||||
const superscriptLink = matchHtmlSuperscriptLinkSource(normalizedContent, index)
|
||||
if (superscriptLink) {
|
||||
result += transport.create('html-superscript-link', JSON.stringify(superscriptLink.value))
|
||||
index = superscriptLink.end
|
||||
continue
|
||||
}
|
||||
}
|
||||
const inlineHtml = matchInlineHtml(normalizedContent.slice(index))
|
||||
if (inlineHtml) {
|
||||
result += createPlaceholder('inline', inlineHtml)
|
||||
result += transport.create('inline-html', inlineHtml)
|
||||
index += inlineHtml.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Why: doc link encoding runs inside the same while loop (not a separate
|
||||
// pre-pass) so that fenced code blocks and backtick code spans are already
|
||||
// skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
|
||||
// sibling placeholders that were already emitted earlier in this pass.
|
||||
// Why: doc link encoding runs inside this loop so fenced code and backtick
|
||||
// spans have already been excluded from semantic preprocessing.
|
||||
if (
|
||||
normalizedContent[index] === '[' &&
|
||||
normalizedContent[index + 1] === '[' &&
|
||||
!normalizedContent.startsWith('[[ORCA_', index) &&
|
||||
!isEscaped(normalizedContent, index)
|
||||
) {
|
||||
const closingIndex = normalizedContent.indexOf(']]', index + 2)
|
||||
if (closingIndex !== -1) {
|
||||
const rawTarget = normalizedContent.slice(index + 2, closingIndex)
|
||||
const link = parseMarkdownDocLink(rawTarget)
|
||||
if (link) {
|
||||
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody(
|
||||
link.target,
|
||||
link.alias
|
||||
)}${PLACEHOLDER_SUFFIX}`
|
||||
if (link && !isReservedRichMarkdownTransportBody(rawTarget)) {
|
||||
result += transport.create(
|
||||
'document-link',
|
||||
formatMarkdownDocLinkBody(link.target, link.alias)
|
||||
)
|
||||
index = closingIndex + 2
|
||||
continue
|
||||
}
|
||||
|
|
@ -226,129 +212,123 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
|
|||
return result
|
||||
}
|
||||
|
||||
export const RawMarkdownHtmlInline = Node.create({
|
||||
name: 'rawMarkdownHtmlInline',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
export function createRichMarkdownLiteral(transport: RichMarkdownSourceTransport) {
|
||||
return createRawSourceNode({
|
||||
name: 'richMarkdownLiteral',
|
||||
kind: 'literal',
|
||||
inline: true,
|
||||
transport,
|
||||
marker: 'data-rich-markdown-literal'
|
||||
})
|
||||
}
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
value: {
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Why: converting embedded HTML tags into placeholder tokens before the
|
||||
// markdown parser runs keeps marked's built-in paragraph tokenization intact
|
||||
// while still letting Orca round-trip the raw markup verbatim.
|
||||
markdownTokenName: 'rawMarkdownHtmlInline',
|
||||
markdownTokenizer: {
|
||||
export function createRawMarkdownHtmlInline(transport: RichMarkdownSourceTransport) {
|
||||
return createRawSourceNode({
|
||||
name: 'rawMarkdownHtmlInline',
|
||||
level: 'inline',
|
||||
start: INLINE_PLACEHOLDER_PREFIX,
|
||||
tokenize(src) {
|
||||
const matched = matchPlaceholder(src, 'inline')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
kind: 'inline-html',
|
||||
inline: true,
|
||||
transport,
|
||||
marker: 'data-raw-markdown-html-inline',
|
||||
className: 'raw-markdown-html-inline'
|
||||
})
|
||||
}
|
||||
|
||||
function createRawSourceNode({
|
||||
name,
|
||||
kind,
|
||||
inline,
|
||||
transport,
|
||||
marker,
|
||||
className
|
||||
}: {
|
||||
name: string
|
||||
kind: RichMarkdownSourceKind
|
||||
inline: boolean
|
||||
transport: RichMarkdownSourceTransport
|
||||
marker: string
|
||||
className?: string
|
||||
}) {
|
||||
return Node.create({
|
||||
name,
|
||||
inline,
|
||||
group: inline ? 'inline' : 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
type: 'rawMarkdownHtmlInline',
|
||||
raw: matched.placeholder,
|
||||
text: matched.value
|
||||
value: {
|
||||
default: '',
|
||||
rendered: false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'rawMarkdownHtmlInline') {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
return helpers.createNode('rawMarkdownHtmlInline', {
|
||||
value: typeof token.text === 'string' ? token.text : ''
|
||||
})
|
||||
},
|
||||
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
|
||||
// Why: converting embedded HTML tags into placeholder tokens before the
|
||||
// markdown parser runs keeps marked's built-in paragraph tokenization intact
|
||||
// while still letting Orca round-trip the raw markup verbatim.
|
||||
markdownTokenName: name,
|
||||
markdownTokenizer: {
|
||||
name,
|
||||
level: inline ? 'inline' : 'block',
|
||||
start: transport.startFor(kind),
|
||||
tokenize(src) {
|
||||
const matched = transport.match(src, kind)
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-raw-markdown-html-inline]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-raw-markdown-html-inline': '',
|
||||
contenteditable: 'false',
|
||||
class: 'raw-markdown-html-inline'
|
||||
}),
|
||||
value
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export const RawMarkdownHtmlBlock = Node.create({
|
||||
name: 'rawMarkdownHtmlBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
value: {
|
||||
default: ''
|
||||
return {
|
||||
type: name,
|
||||
raw: matched.raw,
|
||||
text: matched.value,
|
||||
block: !inline
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== name) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
markdownTokenName: 'rawMarkdownHtmlBlock',
|
||||
markdownTokenizer: {
|
||||
return helpers.createNode(name, {
|
||||
value: typeof token.text === 'string' ? token.text : ''
|
||||
})
|
||||
},
|
||||
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
|
||||
renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}[${marker}]`,
|
||||
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
inline ? 'span' : 'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
[marker]: '',
|
||||
contenteditable: 'false',
|
||||
class: className
|
||||
}),
|
||||
inline ? value : ['pre', value]
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createRawMarkdownHtmlBlock(transport: RichMarkdownSourceTransport) {
|
||||
return createRawSourceNode({
|
||||
name: 'rawMarkdownHtmlBlock',
|
||||
level: 'block',
|
||||
start: BLOCK_PLACEHOLDER_PREFIX,
|
||||
tokenize(src) {
|
||||
const matched = matchPlaceholder(src, 'block')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'rawMarkdownHtmlBlock',
|
||||
raw: matched.placeholder,
|
||||
text: matched.value,
|
||||
block: true
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'rawMarkdownHtmlBlock') {
|
||||
return []
|
||||
}
|
||||
|
||||
return helpers.createNode('rawMarkdownHtmlBlock', {
|
||||
value: typeof token.text === 'string' ? token.text : ''
|
||||
})
|
||||
},
|
||||
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-raw-markdown-html-block]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-raw-markdown-html-block': '',
|
||||
contenteditable: 'false',
|
||||
class: 'raw-markdown-html-block'
|
||||
}),
|
||||
['pre', value]
|
||||
]
|
||||
}
|
||||
})
|
||||
kind: 'block-html',
|
||||
inline: false,
|
||||
transport,
|
||||
marker: 'data-raw-markdown-html-block',
|
||||
className: 'raw-markdown-html-block'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
|
||||
export function handleRichMarkdownCitationKey({
|
||||
editor,
|
||||
event,
|
||||
linkBubbleOwnerId,
|
||||
onOpen
|
||||
}: {
|
||||
editor: Editor | null
|
||||
event: KeyboardEvent
|
||||
linkBubbleOwnerId: string
|
||||
onOpen?: () => boolean
|
||||
}): boolean {
|
||||
const selection = editor?.state?.selection
|
||||
if (
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink' ||
|
||||
event.isComposing ||
|
||||
editor?.view.composing === true
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
return onOpen?.() ?? true
|
||||
}
|
||||
if (event.key !== 'Tab' || event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
const firstAction = document.querySelector<HTMLButtonElement>(
|
||||
`[data-rich-markdown-link-bubble-owner="${linkBubbleOwnerId}"] button:not([disabled])`
|
||||
)
|
||||
if (!firstAction) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
firstAction.focus()
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { DOMSerializer, type Slice } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
|
||||
export function serializeRichMarkdownSliceForClipboard(
|
||||
view: EditorView,
|
||||
slice: Slice
|
||||
): { html: string } {
|
||||
if (typeof view.serializeForClipboard === 'function') {
|
||||
return { html: view.serializeForClipboard(slice).dom.innerHTML }
|
||||
}
|
||||
// Why: lightweight unit-test views predate ProseMirror's public clipboard
|
||||
// serializer; production always takes the metadata-preserving branch above.
|
||||
const fragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(slice.content)
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(fragment)
|
||||
return { html: container.innerHTML }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Slice } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
|
||||
|
||||
/**
|
||||
* Writes a rich-markdown slice to the clipboard (HTML + plain), verifying
|
||||
* readback. Returns false when the write must not be followed by a delete
|
||||
* (source-owning limit or clipboard rejection).
|
||||
*/
|
||||
export function writeRichMarkdownSliceToClipboard(
|
||||
clipboardData: DataTransfer,
|
||||
view: EditorView,
|
||||
slice: Slice,
|
||||
visibleText: string
|
||||
): boolean {
|
||||
const status = inspectRichMarkdownSourceOwningSlice(slice)
|
||||
if (status.containsSourceOwningNode && !status.canPreserve) {
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return false
|
||||
}
|
||||
const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
|
||||
clipboardData.setData('text/html', serialized.html)
|
||||
clipboardData.setData('text/plain', visibleText)
|
||||
// Why: if the clipboard rejected the write we must not delete, and we must
|
||||
// surface the same cut-limit feedback so the no-op is not silent.
|
||||
if (
|
||||
typeof clipboardData.getData === 'function' &&
|
||||
(clipboardData.getData('text/html') !== serialized.html ||
|
||||
clipboardData.getData('text/plain') !== visibleText)
|
||||
) {
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands'
|
||||
|
||||
function createEditor(content = '/'): Editor {
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content,
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { DOMSerializer } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
|
||||
import { cutVisualLine, getVisualLineRange } from './rich-markdown-visual-line'
|
||||
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
|
||||
|
||||
function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: number): void {
|
||||
let tr = view.state.tr.delete(from, to)
|
||||
|
|
@ -24,6 +27,12 @@ function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: numb
|
|||
export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent): boolean {
|
||||
const { selection } = view.state
|
||||
if (!selection.empty) {
|
||||
const status = inspectRichMarkdownSourceOwningSlice(selection.content())
|
||||
if (status.containsSourceOwningNode && !status.canPreserve) {
|
||||
event.preventDefault()
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +63,9 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
|
|||
}
|
||||
|
||||
const cutNode = $from.node(cutDepth)
|
||||
const text = cutNode.textContent
|
||||
const contentFrom = $from.start(cutDepth)
|
||||
const contentTo = $from.end(cutDepth)
|
||||
const text = createRichMarkdownVisibleTextMap(view.state.doc, contentFrom, contentTo).text
|
||||
|
||||
// Why: for paragraphs that word-wrap across multiple visual lines, cut
|
||||
// only the visual line the cursor is on rather than the entire paragraph.
|
||||
|
|
@ -85,15 +96,10 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
|
|||
}
|
||||
event.preventDefault()
|
||||
|
||||
// Why: writing both text/html and text/plain preserves inline formatting
|
||||
// (bold, italic, links) on round-trip cut-then-paste, while still giving
|
||||
// a plain-text fallback for external targets.
|
||||
const serializer = DOMSerializer.fromSchema(view.state.schema)
|
||||
const fragment = serializer.serializeFragment(cutNode.content)
|
||||
const div = document.createElement('div')
|
||||
div.appendChild(fragment)
|
||||
event.clipboardData.setData('text/html', div.innerHTML)
|
||||
event.clipboardData.setData('text/plain', text)
|
||||
const slice = view.state.doc.slice($from.before(cutDepth), $from.after(cutDepth))
|
||||
if (!writeRichMarkdownSliceToClipboard(event.clipboardData, view, slice, text)) {
|
||||
return true
|
||||
}
|
||||
|
||||
deleteBlockAndRestoreSelection(view, $from.before(cutDepth), $from.after(cutDepth))
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ import { Table } from '@tiptap/extension-table'
|
|||
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 { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
|
||||
const showCutLimitErrorMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./rich-markdown-source-owning-cut-feedback', () => ({
|
||||
showRichMarkdownSourceOwningCutLimitError: showCutLimitErrorMock
|
||||
}))
|
||||
|
||||
/**
|
||||
* Minimal extensions matching the rich editor schema without UI dependencies.
|
||||
*/
|
||||
|
|
@ -25,7 +31,7 @@ const testExtensions = [
|
|||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({ markedOptions: { gfm: true } })
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
]
|
||||
|
||||
function createEditor(markdown: string): Editor {
|
||||
|
|
@ -38,6 +44,7 @@ function createEditor(markdown: string): Editor {
|
|||
}
|
||||
|
||||
afterEach(() => {
|
||||
showCutLimitErrorMock.mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
|
@ -109,7 +116,7 @@ function countParagraphs(editor: Editor): number {
|
|||
return count
|
||||
}
|
||||
|
||||
function createClipboardEventMock(): {
|
||||
function createClipboardEventMock(options?: { failReadback?: boolean }): {
|
||||
data: Map<string, string>
|
||||
event: ClipboardEvent
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
|
|
@ -120,7 +127,10 @@ function createClipboardEventMock(): {
|
|||
clipboardData: {
|
||||
setData: vi.fn((type: string, value: string) => {
|
||||
data.set(type, value)
|
||||
})
|
||||
}),
|
||||
getData: options?.failReadback
|
||||
? vi.fn(() => '')
|
||||
: vi.fn((type: string) => data.get(type) ?? '')
|
||||
},
|
||||
preventDefault
|
||||
} as unknown as ClipboardEvent
|
||||
|
|
@ -243,6 +253,38 @@ describe('rich markdown cut handler behavior', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('surfaces cut-limit feedback when clipboard readback fails', () => {
|
||||
const editor = createEditor('Body text to cut.\n')
|
||||
try {
|
||||
const pos = 1
|
||||
let viewState = editor.state.apply(
|
||||
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
|
||||
)
|
||||
const view = {
|
||||
get state() {
|
||||
return viewState
|
||||
},
|
||||
dispatch: vi.fn((tr) => {
|
||||
viewState = viewState.apply(tr)
|
||||
}),
|
||||
domAtPos: vi.fn(() => ({ node: document.createElement('p'), offset: 0 })),
|
||||
coordsAtPos: vi.fn(() => ({ top: 0, bottom: 20, left: 0, right: 20 })),
|
||||
posAtCoords: vi.fn(() => null)
|
||||
} as unknown as EditorView
|
||||
|
||||
const clipboard = createClipboardEventMock({ failReadback: true })
|
||||
const handled = handleRichMarkdownCut(view, clipboard.event)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(clipboard.preventDefault).toHaveBeenCalled()
|
||||
expect(showCutLimitErrorMock).toHaveBeenCalledTimes(1)
|
||||
expect(view.dispatch).not.toHaveBeenCalled()
|
||||
expect(view.state.doc.textContent).toBe('Body text to cut.')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('paragraphs separated by blank lines are separate blocks', () => {
|
||||
const editor = createEditor('First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n')
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import {
|
|||
moveFromEmptyDetailsBodyToSummary
|
||||
} from './rich-markdown-details-extension'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
function createEditor(content: string | JSONContent) {
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content,
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { mergeAttributes } from '@tiptap/core'
|
||||
import type { DOMOutputSpec } from '@tiptap/pm/model'
|
||||
|
||||
export function renderRichMarkdownDocLinkHtml(
|
||||
node: { attrs: Record<string, unknown> },
|
||||
htmlAttributes: Record<string, unknown>
|
||||
): DOMOutputSpec {
|
||||
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
|
||||
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(htmlAttributes, {
|
||||
'data-doc-link-target': target,
|
||||
...(label ? { 'data-doc-link-label': label } : {}),
|
||||
contenteditable: 'false',
|
||||
class: 'rich-markdown-doc-link'
|
||||
}),
|
||||
label ?? target
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { Node } from '@tiptap/core'
|
||||
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
|
|
@ -10,9 +10,11 @@ import {
|
|||
resolveMarkdownDocLink
|
||||
} from './markdown-doc-links'
|
||||
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
|
||||
|
||||
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
|
||||
const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
|
||||
import {
|
||||
isReservedRichMarkdownTransportBody,
|
||||
type RichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
|
||||
|
||||
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
|
||||
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
|
||||
|
|
@ -55,7 +57,9 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
|
|||
return
|
||||
}
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
const link = parseMarkdownDocLink(match[1])
|
||||
const link = isReservedRichMarkdownTransportBody(match[1])
|
||||
? null
|
||||
: parseMarkdownDocLink(match[1])
|
||||
if (!link || match.index === undefined) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -96,262 +100,252 @@ function getDocLinkDisplayText(node: { attrs: Record<string, unknown> }): string
|
|||
return getDocLinkAlias(node) ?? getDocLinkTarget(node)
|
||||
}
|
||||
|
||||
export const MarkdownDocLink = Node.create({
|
||||
name: 'markdownDocLink',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
documents: [] as MarkdownDocument[],
|
||||
_cachedDocs: null as MarkdownDocument[] | null,
|
||||
_cachedIndex: null as MarkdownDocumentIndex | null
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
target: {
|
||||
default: '',
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
markdownTokenName: 'markdownDocLink',
|
||||
markdownTokenizer: {
|
||||
export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
|
||||
return Node.create({
|
||||
name: 'markdownDocLink',
|
||||
level: 'inline',
|
||||
start: DOC_LINK_PLACEHOLDER_PREFIX,
|
||||
tokenize(src: string) {
|
||||
if (!src.startsWith(DOC_LINK_PLACEHOLDER_PREFIX)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const endIndex = src.indexOf(DOC_LINK_PLACEHOLDER_SUFFIX, DOC_LINK_PLACEHOLDER_PREFIX.length)
|
||||
if (endIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const placeholder = src.slice(0, endIndex + DOC_LINK_PLACEHOLDER_SUFFIX.length)
|
||||
const link = parseMarkdownDocLink(src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex))
|
||||
if (!link) {
|
||||
return undefined
|
||||
}
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
type: 'markdownDocLink',
|
||||
raw: placeholder,
|
||||
text: link.target,
|
||||
label: link.alias ?? undefined
|
||||
documents: [] as MarkdownDocument[],
|
||||
_cachedDocs: null as MarkdownDocument[] | null,
|
||||
_cachedIndex: null as MarkdownDocumentIndex | null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'markdownDocLink') {
|
||||
return []
|
||||
}
|
||||
return helpers.createNode('markdownDocLink', {
|
||||
target: typeof token.text === 'string' ? token.text : '',
|
||||
label:
|
||||
typeof (token as { label?: unknown }).label === 'string'
|
||||
? (token as { label: string }).label
|
||||
: null
|
||||
})
|
||||
},
|
||||
|
||||
renderMarkdown: (node) =>
|
||||
formatMarkdownDocLink(
|
||||
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
|
||||
typeof node.attrs?.label === 'string' ? node.attrs.label : null
|
||||
),
|
||||
|
||||
addNodeView() {
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
|
||||
const target = getDocLinkTarget(node)
|
||||
const dom = document.createElement('span')
|
||||
dom.setAttribute('data-doc-link-target', target)
|
||||
const alias = getDocLinkAlias(node)
|
||||
if (alias) {
|
||||
dom.setAttribute('data-doc-link-label', alias)
|
||||
}
|
||||
dom.setAttribute('contenteditable', 'false')
|
||||
dom.textContent = getDocLinkDisplayText(node)
|
||||
|
||||
const applyResolutionClass = (t: string): void => {
|
||||
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
|
||||
dom.className = resolved
|
||||
? 'rich-markdown-doc-link'
|
||||
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
|
||||
}
|
||||
|
||||
applyResolutionClass(target)
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
dom,
|
||||
// Why: this fires on every transaction, including the no-op dispatched
|
||||
// when the document list changes in storage. Re-checking resolution
|
||||
// here keeps the blue/grey styling current without a full re-render.
|
||||
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
|
||||
if (updatedNode.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
const newTarget = getDocLinkTarget(updatedNode)
|
||||
const newAlias = getDocLinkAlias(updatedNode)
|
||||
dom.setAttribute('data-doc-link-target', newTarget)
|
||||
if (newAlias) {
|
||||
dom.setAttribute('data-doc-link-label', newAlias)
|
||||
} else {
|
||||
dom.removeAttribute('data-doc-link-label')
|
||||
}
|
||||
dom.textContent = getDocLinkDisplayText(updatedNode)
|
||||
applyResolutionClass(newTarget)
|
||||
return true
|
||||
target: {
|
||||
default: '',
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
|
||||
// any order — brackets first then target, paste, etc. — converts to a doc
|
||||
// link node. Input rules only fire on sequential append at the cursor.
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return [
|
||||
// Why: when the cursor is adjacent to a doc link atom and the user presses
|
||||
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
|
||||
// Without this, atom nodes are un-enterable — the cursor jumps over them.
|
||||
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
|
||||
// keys and let Shift+Arrow extend the selection normally.
|
||||
new Plugin({
|
||||
key: docLinkDissolveKey,
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
|
||||
markdownTokenName: 'markdownDocLink',
|
||||
markdownTokenizer: {
|
||||
name: 'markdownDocLink',
|
||||
level: 'inline',
|
||||
start: transport.startFor('document-link'),
|
||||
tokenize(src: string) {
|
||||
const matched = transport.match(src, 'document-link')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
const link = isReservedRichMarkdownTransportBody(matched.value)
|
||||
? null
|
||||
: parseMarkdownDocLink(matched.value)
|
||||
if (!link) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'markdownDocLink',
|
||||
raw: matched.raw,
|
||||
text: link.target,
|
||||
label: link.alias ?? undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'markdownDocLink') {
|
||||
return []
|
||||
}
|
||||
return helpers.createNode('markdownDocLink', {
|
||||
target: typeof token.text === 'string' ? token.text : '',
|
||||
label:
|
||||
typeof (token as { label?: unknown }).label === 'string'
|
||||
? (token as { label: string }).label
|
||||
: null
|
||||
})
|
||||
},
|
||||
|
||||
renderMarkdown: (node) =>
|
||||
formatMarkdownDocLink(
|
||||
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
|
||||
typeof node.attrs?.label === 'string' ? node.attrs.label : null
|
||||
),
|
||||
renderText: ({ node }) => getDocLinkDisplayText(node),
|
||||
|
||||
addNodeView() {
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
|
||||
const target = getDocLinkTarget(node)
|
||||
const dom = document.createElement('span')
|
||||
dom.setAttribute('data-doc-link-target', target)
|
||||
const alias = getDocLinkAlias(node)
|
||||
if (alias) {
|
||||
dom.setAttribute('data-doc-link-label', alias)
|
||||
}
|
||||
dom.setAttribute('contenteditable', 'false')
|
||||
dom.textContent = getDocLinkDisplayText(node)
|
||||
|
||||
const applyResolutionClass = (t: string): void => {
|
||||
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
|
||||
dom.className = resolved
|
||||
? 'rich-markdown-doc-link'
|
||||
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
|
||||
}
|
||||
|
||||
applyResolutionClass(target)
|
||||
|
||||
return {
|
||||
dom,
|
||||
// Why: this fires on every transaction, including the no-op dispatched
|
||||
// when the document list changes in storage. Re-checking resolution
|
||||
// here keeps the blue/grey styling current without a full re-render.
|
||||
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
|
||||
if (updatedNode.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
let direction: 'left' | 'right'
|
||||
if (event.key === 'ArrowLeft') {
|
||||
direction = 'left'
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
direction = 'right'
|
||||
const newTarget = getDocLinkTarget(updatedNode)
|
||||
const newAlias = getDocLinkAlias(updatedNode)
|
||||
dom.setAttribute('data-doc-link-target', newTarget)
|
||||
if (newAlias) {
|
||||
dom.setAttribute('data-doc-link-label', newAlias)
|
||||
} else {
|
||||
return false
|
||||
dom.removeAttribute('data-doc-link-label')
|
||||
}
|
||||
const { state } = view
|
||||
if (!(state.selection instanceof TextSelection)) {
|
||||
return false
|
||||
}
|
||||
const { $from } = state.selection
|
||||
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
|
||||
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
const target = getDocLinkTarget(adjacent)
|
||||
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
|
||||
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
|
||||
const nodeEnd = nodeStart + adjacent.nodeSize
|
||||
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
|
||||
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
|
||||
view.dispatch(tr)
|
||||
dom.textContent = getDocLinkDisplayText(updatedNode)
|
||||
applyResolutionClass(newTarget)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
new Plugin({
|
||||
key: docLinkAutoConvertKey,
|
||||
appendTransaction(_transactions, _oldState, newState) {
|
||||
const { tr } = newState
|
||||
const cursor = newState.selection.from
|
||||
let modified = false
|
||||
|
||||
newState.doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name !== 'text' || !node.text) {
|
||||
return
|
||||
}
|
||||
if (isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
return
|
||||
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
|
||||
// any order — brackets first then target, paste, etc. — converts to a doc
|
||||
// link node. Input rules only fire on sequential append at the cursor.
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return [
|
||||
// Why: when the cursor is adjacent to a doc link atom and the user presses
|
||||
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
|
||||
// Without this, atom nodes are un-enterable — the cursor jumps over them.
|
||||
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
|
||||
// keys and let Shift+Arrow extend the selection normally.
|
||||
new Plugin({
|
||||
key: docLinkDissolveKey,
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
|
||||
return false
|
||||
}
|
||||
let direction: 'left' | 'right'
|
||||
if (event.key === 'ArrowLeft') {
|
||||
direction = 'left'
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
direction = 'right'
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
const { state } = view
|
||||
if (!(state.selection instanceof TextSelection)) {
|
||||
return false
|
||||
}
|
||||
const { $from } = state.selection
|
||||
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
|
||||
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
const target = getDocLinkTarget(adjacent)
|
||||
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
|
||||
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
|
||||
const nodeEnd = nodeStart + adjacent.nodeSize
|
||||
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
|
||||
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
const link = parseMarkdownDocLink(match[1])
|
||||
if (!link || match.index === undefined) {
|
||||
continue
|
||||
new Plugin({
|
||||
key: docLinkAutoConvertKey,
|
||||
appendTransaction(_transactions, _oldState, newState) {
|
||||
const { tr } = newState
|
||||
const cursor = newState.selection.from
|
||||
let modified = false
|
||||
|
||||
newState.doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name !== 'text' || !node.text) {
|
||||
return
|
||||
}
|
||||
if (isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
const link = isReservedRichMarkdownTransportBody(match[1])
|
||||
? null
|
||||
: parseMarkdownDocLink(match[1])
|
||||
if (!link || match.index === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: skip when the cursor is anywhere from just inside [[
|
||||
// through the closing ]]. The inline preview decoration gives
|
||||
// real-time resolution feedback while the user is still editing.
|
||||
if (cursor > from && cursor <= to) {
|
||||
continue
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
|
||||
// Why: skip when the cursor is anywhere from just inside [[
|
||||
// through the closing ]]. The inline preview decoration gives
|
||||
// real-time resolution feedback while the user is still editing.
|
||||
if (cursor > from && cursor <= to) {
|
||||
continue
|
||||
}
|
||||
|
||||
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
|
||||
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
|
||||
modified = true
|
||||
}
|
||||
})
|
||||
|
||||
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
|
||||
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
|
||||
modified = true
|
||||
return modified ? tr : null
|
||||
}
|
||||
}),
|
||||
|
||||
// Why: while the cursor is inside [[target]], the text hasn't converted
|
||||
// to an atom node yet. This decoration gives real-time blue/grey feedback
|
||||
// so the user knows whether the target resolves before moving the cursor out.
|
||||
new Plugin({
|
||||
key: docLinkInlinePreviewKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return buildPreviewDecorations(state, storage)
|
||||
},
|
||||
apply(tr, prev, oldState, newState) {
|
||||
const selectionMoved = !oldState.selection.eq(newState.selection)
|
||||
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
|
||||
return prev
|
||||
}
|
||||
return buildPreviewDecorations(newState, storage)
|
||||
}
|
||||
})
|
||||
|
||||
return modified ? tr : null
|
||||
}
|
||||
}),
|
||||
|
||||
// Why: while the cursor is inside [[target]], the text hasn't converted
|
||||
// to an atom node yet. This decoration gives real-time blue/grey feedback
|
||||
// so the user knows whether the target resolves before moving the cursor out.
|
||||
new Plugin({
|
||||
key: docLinkInlinePreviewKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return buildPreviewDecorations(state, storage)
|
||||
},
|
||||
apply(tr, prev, oldState, newState) {
|
||||
const selectionMoved = !oldState.selection.eq(newState.selection)
|
||||
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
|
||||
return prev
|
||||
props: {
|
||||
decorations(state) {
|
||||
return docLinkInlinePreviewKey.getState(state)
|
||||
}
|
||||
return buildPreviewDecorations(newState, storage)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return docLinkInlinePreviewKey.getState(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
})
|
||||
]
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-doc-link-target]' }]
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-doc-link-target]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
|
||||
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-doc-link-target': target,
|
||||
...(label ? { 'data-doc-link-label': label } : {}),
|
||||
contenteditable: 'false',
|
||||
class: 'rich-markdown-doc-link'
|
||||
}),
|
||||
label ?? target
|
||||
]
|
||||
}
|
||||
})
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
return renderRichMarkdownDocLinkHtml(node, HTMLAttributes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import type { MutableRefObject } from 'react'
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
|
|
@ -14,6 +13,10 @@ import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
|
|||
import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
export type ActivateMarkdownLink = (
|
||||
href: string,
|
||||
|
|
@ -22,6 +25,7 @@ export type ActivateMarkdownLink = (
|
|||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
}
|
||||
) => void | Promise<unknown>
|
||||
|
||||
|
|
@ -33,6 +37,7 @@ type RichMarkdownEditorClickRoutingOptions = {
|
|||
event: MouseEvent
|
||||
filePath: string
|
||||
isMac: boolean
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
markdownCommentsRef: MutableRefObject<DiffComment[]>
|
||||
markdownSourceLineOffsetRef: MutableRefObject<number>
|
||||
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
|
||||
|
|
@ -52,6 +57,7 @@ export function handleRichMarkdownEditorClick({
|
|||
event,
|
||||
filePath,
|
||||
isMac,
|
||||
htmlSuperscriptLinkContext,
|
||||
markdownCommentsRef,
|
||||
markdownSourceLineOffsetRef,
|
||||
onOpenDocLinkRef,
|
||||
|
|
@ -65,6 +71,8 @@ export function handleRichMarkdownEditorClick({
|
|||
worktreeRoot
|
||||
}: RichMarkdownEditorClickRoutingOptions): boolean {
|
||||
const editor = editorRef.current
|
||||
const sourceSnapshot = htmlSuperscriptLinkContext.getSnapshot()
|
||||
const sourceOwner = sourceSnapshot.sourceOwner
|
||||
const modKey = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!editor) {
|
||||
return false
|
||||
|
|
@ -88,6 +96,7 @@ export function handleRichMarkdownEditorClick({
|
|||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
src: (clickedNode.attrs.src as string | undefined) ?? '',
|
||||
sourceOwner,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
})
|
||||
|
|
@ -96,7 +105,16 @@ export function handleRichMarkdownEditorClick({
|
|||
onOpenDocLinkRef.current?.(clickedNode.attrs.target as string)
|
||||
return true
|
||||
}
|
||||
const href = getClickedLinkHref(view, pos)
|
||||
const href =
|
||||
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink'
|
||||
? String(clickedNode.attrs.href ?? '')
|
||||
: getClickedLinkHref(view, pos)
|
||||
if (
|
||||
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink' &&
|
||||
!classifyHtmlSuperscriptLinkAction(href, sourceSnapshot)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (!href) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -109,8 +127,8 @@ export function handleRichMarkdownEditorClick({
|
|||
href,
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner,
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
})
|
||||
return true
|
||||
|
|
@ -119,7 +137,8 @@ export function handleRichMarkdownEditorClick({
|
|||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
|
@ -128,6 +147,7 @@ function activateMarkdownImageClick({
|
|||
activateMarkdownLink,
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner,
|
||||
src,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
|
|
@ -135,6 +155,7 @@ function activateMarkdownImageClick({
|
|||
activateMarkdownLink: ActivateMarkdownLink
|
||||
filePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
src: string
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
|
|
@ -146,7 +167,8 @@ function activateMarkdownImageClick({
|
|||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
|
@ -162,24 +184,27 @@ function getClickedLinkHref(view: EditorView, pos: number): string {
|
|||
function openMarkdownLinkInClientOs({
|
||||
href,
|
||||
filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner,
|
||||
settings
|
||||
}: {
|
||||
href: string
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
settings: RichMarkdownRuntimeSettings
|
||||
}): void {
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
|
||||
if (!classified) {
|
||||
return
|
||||
}
|
||||
if (classified.kind === 'external') {
|
||||
openHttpLink(classified.url, { forceSystemBrowser: true })
|
||||
openHttpLink(classified.url, { forceSystemBrowser: true, sourceOwner })
|
||||
return
|
||||
}
|
||||
if (classified.kind === 'anchor') {
|
||||
|
|
@ -187,7 +212,7 @@ function openMarkdownLinkInClientOs({
|
|||
}
|
||||
if (
|
||||
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), {
|
||||
connectionId: getConnectionId(worktreeId)
|
||||
connectionId: sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
|
||||
})
|
||||
) {
|
||||
// Why: Shift-click opens through the client OS, which cannot safely resolve
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
type EditorConfigParams
|
||||
} from './rich-markdown-editor-config'
|
||||
import type { SlashMenuState } from './rich-markdown-slash-commands'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
function ref<T>(current: T): MutableRefObject<T> {
|
||||
return { current }
|
||||
|
|
@ -25,7 +27,15 @@ function getSpellcheckAttribute(config: ReturnType<typeof createRichMarkdownEdit
|
|||
}
|
||||
|
||||
function createConfigParams(overrides: Partial<EditorConfigParams> = {}): EditorConfigParams {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
return {
|
||||
codec,
|
||||
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
}),
|
||||
content: '',
|
||||
filePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type DocLinkMenuState
|
||||
} from './rich-markdown-commands'
|
||||
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import {
|
||||
handleRichMarkdownEditorClick,
|
||||
type ActivateMarkdownLink,
|
||||
|
|
@ -29,8 +29,16 @@ import {
|
|||
import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
|
||||
import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
getRichMarkdownSelectionLinkBubble,
|
||||
openSelectedHtmlSuperscriptLink
|
||||
} from './rich-markdown-selected-link-actions'
|
||||
|
||||
export type EditorConfigParams = {
|
||||
codec: RichMarkdownEditorCodec
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
content: string
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
|
|
@ -79,6 +87,8 @@ export type EditorConfigParams = {
|
|||
export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions {
|
||||
const {
|
||||
content,
|
||||
codec,
|
||||
htmlSuperscriptLinkContext,
|
||||
filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
|
|
@ -125,7 +135,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
|
||||
return {
|
||||
immediatelyRender: false,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
|
||||
contentType: 'markdown' as const,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
|
|
@ -135,13 +145,15 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
handleDOMEvents: {
|
||||
cut: handleRichMarkdownCut
|
||||
},
|
||||
handlePaste: (_view, event) =>
|
||||
handlePaste: (view, event, slice) =>
|
||||
handleRichMarkdownPaste({
|
||||
editor: editorRef.current,
|
||||
event,
|
||||
filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
slice,
|
||||
view
|
||||
}),
|
||||
handleTextInput: (view, from, to, text) => {
|
||||
typedEmptyOrderedListMarkerRef.current = false
|
||||
|
|
@ -172,12 +184,22 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
typedEmptyOrderedListMarkerRef,
|
||||
flushPendingSerialization,
|
||||
openSearchRef,
|
||||
linkBubbleOwnerId: codec.transport.key,
|
||||
htmlSuperscriptLinkContext,
|
||||
setIsEditingLink,
|
||||
setLinkBubble,
|
||||
setSelectedCommandIndex,
|
||||
setSelectedDocLinkIndex,
|
||||
setSlashMenu,
|
||||
setDocLinkMenu
|
||||
setDocLinkMenu,
|
||||
openSelectedHtmlSuperscriptLink: () =>
|
||||
openSelectedHtmlSuperscriptLink({
|
||||
activateMarkdownLink,
|
||||
context: htmlSuperscriptLinkContext,
|
||||
editor: editorRef.current,
|
||||
root: rootRef.current,
|
||||
runtimeEnvironmentId
|
||||
})
|
||||
}),
|
||||
handleClick: (view, pos, event) => {
|
||||
return handleRichMarkdownEditorClick({
|
||||
|
|
@ -185,6 +207,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
editorRef,
|
||||
event,
|
||||
filePath,
|
||||
htmlSuperscriptLinkContext,
|
||||
isMac,
|
||||
markdownCommentsRef,
|
||||
markdownSourceLineOffsetRef,
|
||||
|
|
@ -259,13 +282,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
|
||||
syncAnnotationTarget(nextEditor)
|
||||
setIsEditingLink(false)
|
||||
if (nextEditor.isActive('link')) {
|
||||
const attrs = nextEditor.getAttributes('link')
|
||||
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
|
||||
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
|
||||
} else {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
setLinkBubble(
|
||||
getRichMarkdownSelectionLinkBubble(nextEditor, rootRef.current, htmlSuperscriptLinkContext)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
const hardWrappedMarkdown =
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
|
|
|
|||
|
|
@ -16,16 +16,23 @@ import { Markdown } from '@tiptap/markdown'
|
|||
import { createLowlight, common } from 'lowlight'
|
||||
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
|
||||
import {
|
||||
createRawMarkdownHtmlBlock,
|
||||
createRawMarkdownHtmlInline,
|
||||
createRichMarkdownLiteral
|
||||
} from './raw-markdown-html'
|
||||
import {
|
||||
createOrcaDetailsExtensions,
|
||||
getRichMarkdownPlaceholder
|
||||
} from './rich-markdown-details-extension'
|
||||
import { MarkdownDocLink } from './rich-markdown-doc-link'
|
||||
import { createMarkdownDocLink } from './rich-markdown-doc-link'
|
||||
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
|
||||
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
|
||||
import { DragSelectionGuard } from './drag-selection-guard'
|
||||
import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-annotation-highlight'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
|
||||
|
|
@ -42,10 +49,19 @@ const RichMarkdownCode = Code.extend({
|
|||
})
|
||||
|
||||
export function createRichMarkdownExtensions({
|
||||
includePlaceholder = false
|
||||
codec,
|
||||
includePlaceholder = false,
|
||||
htmlSuperscriptLinks = false,
|
||||
htmlSuperscriptLinkContext
|
||||
}: {
|
||||
codec: RichMarkdownEditorCodec
|
||||
includePlaceholder?: boolean
|
||||
} = {}): AnyExtension[] {
|
||||
htmlSuperscriptLinks?: boolean
|
||||
htmlSuperscriptLinkContext?: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}): AnyExtension[] {
|
||||
if (htmlSuperscriptLinks && !htmlSuperscriptLinkContext) {
|
||||
throw new Error('HTML superscript links require a document interaction context')
|
||||
}
|
||||
const extensions: AnyExtension[] = [
|
||||
// Why: rich-mode detection must use the exact same markdown extension set as
|
||||
// the live editor. If these drift, Orca can claim a document is editable in
|
||||
|
|
@ -196,11 +212,16 @@ export function createRichMarkdownExtensions({
|
|||
throwOnError: false
|
||||
}
|
||||
}),
|
||||
RawMarkdownHtmlInline,
|
||||
RawMarkdownHtmlBlock,
|
||||
MarkdownDocLink,
|
||||
createRichMarkdownLiteral(codec.transport),
|
||||
...(htmlSuperscriptLinks
|
||||
? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
|
||||
: []),
|
||||
createRawMarkdownHtmlInline(codec.transport),
|
||||
createRawMarkdownHtmlBlock(codec.transport),
|
||||
createMarkdownDocLink(codec.transport),
|
||||
DragSelectionGuard,
|
||||
Markdown.configure({
|
||||
marked: codec.marked,
|
||||
markedOptions: {
|
||||
gfm: true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { resolveMarkdownLinkTarget } from './markdown-internal-links'
|
||||
|
||||
export type RichMarkdownHtmlSuperscriptLinkContextSnapshot = {
|
||||
version: number
|
||||
sourceFilePath: string
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
}
|
||||
|
||||
export type RichMarkdownHtmlSuperscriptLinkContext = {
|
||||
getSnapshot: () => RichMarkdownHtmlSuperscriptLinkContextSnapshot
|
||||
subscribe: (listener: () => void) => () => void
|
||||
update: (snapshot: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>) => void
|
||||
}
|
||||
|
||||
export function createRichMarkdownHtmlSuperscriptLinkContext(
|
||||
initial: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>
|
||||
): RichMarkdownHtmlSuperscriptLinkContext {
|
||||
let snapshot = { ...initial, version: 0 }
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
update: (next) => {
|
||||
if (
|
||||
next.sourceFilePath === snapshot.sourceFilePath &&
|
||||
next.worktreeId === snapshot.worktreeId &&
|
||||
next.worktreeRoot === snapshot.worktreeRoot &&
|
||||
sameOwner(next.sourceOwner, snapshot.sourceOwner)
|
||||
) {
|
||||
return
|
||||
}
|
||||
snapshot = { ...next, version: snapshot.version + 1 }
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyHtmlSuperscriptLinkAction(
|
||||
href: string,
|
||||
snapshot: RichMarkdownHtmlSuperscriptLinkContextSnapshot
|
||||
): boolean {
|
||||
if (snapshot.sourceOwner.kind === 'unknown' || /^[\t\n\f\r ]*$/.test(href)) {
|
||||
return false
|
||||
}
|
||||
const target = resolveMarkdownLinkTarget(href, snapshot.sourceFilePath, snapshot.worktreeRoot)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
return !(
|
||||
target.kind === 'file' &&
|
||||
target.relativePath === undefined &&
|
||||
(snapshot.sourceOwner.kind === 'runtime' || snapshot.sourceOwner.kind === 'ssh')
|
||||
)
|
||||
}
|
||||
|
||||
function sameOwner(left: HttpLinkSourceOwner, right: HttpLinkSourceOwner): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false
|
||||
}
|
||||
if (left.kind === 'runtime' && right.kind === 'runtime') {
|
||||
return left.runtimeEnvironmentId === right.runtimeEnvironmentId
|
||||
}
|
||||
if (left.kind === 'ssh' && right.kind === 'ssh') {
|
||||
return left.connectionId === right.connectionId
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
import {
|
||||
decodeHtmlAttributeCharacterReferences,
|
||||
decodeHtmlTextCharacterReferences,
|
||||
type HtmlAttributeQuote
|
||||
} from './html-character-reference-decoder'
|
||||
|
||||
export const HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT = 16 * 1024
|
||||
export const HTML_SUPERSCRIPT_LINK_HREF_LIMIT = 8 * 1024
|
||||
export const HTML_SUPERSCRIPT_LINK_TEXT_LIMIT = 2 * 1024
|
||||
|
||||
export type HtmlSuperscriptLinkSource = {
|
||||
source: string
|
||||
href: string
|
||||
label: string
|
||||
title: string | null
|
||||
}
|
||||
|
||||
export type HtmlSuperscriptLinkMatch = {
|
||||
end: number
|
||||
value: HtmlSuperscriptLinkSource
|
||||
}
|
||||
|
||||
export type HtmlSuperscriptLinkParseStats = {
|
||||
transitions: number
|
||||
}
|
||||
|
||||
type ParsedAttribute = {
|
||||
name: 'href' | 'title'
|
||||
rawValue: string
|
||||
quote: HtmlAttributeQuote
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
export function matchHtmlSuperscriptLinkSource(
|
||||
input: string,
|
||||
start = 0,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): HtmlSuperscriptLinkMatch | null {
|
||||
const limit = Math.min(input.length, start + HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
|
||||
let index = matchSimpleTag(input, start, 'sup', false, limit, stats)
|
||||
if (index === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const anchor = matchAnchorStart(input, index, limit, stats)
|
||||
if (!anchor) {
|
||||
return null
|
||||
}
|
||||
index = anchor.end
|
||||
|
||||
const labelStart = index
|
||||
while (index < limit) {
|
||||
step(stats)
|
||||
const code = input.charCodeAt(index)
|
||||
if (code === 60 || code === 10 || code === 13) {
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
if (index === labelStart || index >= limit || input.charCodeAt(index) !== 60) {
|
||||
return null
|
||||
}
|
||||
const rawLabel = input.slice(labelStart, index)
|
||||
index = matchSimpleTag(input, index, 'a', true, limit, stats) ?? -1
|
||||
if (index < 0) {
|
||||
return null
|
||||
}
|
||||
index = matchSimpleTag(input, index, 'sup', true, limit, stats) ?? -1
|
||||
if (index < 0 || index - start > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hrefAttribute = anchor.attributes.find((attribute) => attribute.name === 'href')
|
||||
const titleAttribute = anchor.attributes.find((attribute) => attribute.name === 'title')
|
||||
if (!hrefAttribute) {
|
||||
return null
|
||||
}
|
||||
|
||||
const href = decodeHtmlAttributeCharacterReferences(hrefAttribute.rawValue, hrefAttribute.quote)
|
||||
const label = decodeHtmlTextCharacterReferences(rawLabel)
|
||||
const title = titleAttribute
|
||||
? decodeHtmlAttributeCharacterReferences(titleAttribute.rawValue, titleAttribute.quote)
|
||||
: null
|
||||
const source = input.slice(start, index)
|
||||
if (
|
||||
byteLength(source) > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
|
||||
byteLength(href) > HTML_SUPERSCRIPT_LINK_HREF_LIMIT ||
|
||||
byteLength(label) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT ||
|
||||
(title !== null && byteLength(title) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { end: index, value: { source, href, label, title } }
|
||||
}
|
||||
|
||||
export function parseHtmlSuperscriptLinkSource(source: string): HtmlSuperscriptLinkSource | null {
|
||||
const match = matchHtmlSuperscriptLinkSource(source)
|
||||
return match?.end === source.length ? match.value : null
|
||||
}
|
||||
|
||||
function matchAnchorStart(
|
||||
input: string,
|
||||
start: number,
|
||||
limit: number,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): { end: number; attributes: ParsedAttribute[] } | null {
|
||||
let index = start
|
||||
if (input.charCodeAt(index) !== 60 || lowerCode(input.charCodeAt(index + 1)) !== 97) {
|
||||
return null
|
||||
}
|
||||
step(stats, 2)
|
||||
index += 2
|
||||
if (!isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attributes: ParsedAttribute[] = []
|
||||
while (index < limit) {
|
||||
const whitespaceStart = index
|
||||
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (input.charCodeAt(index) === 62) {
|
||||
step(stats)
|
||||
return attributes.some((attribute) => attribute.name === 'href')
|
||||
? { end: index + 1, attributes }
|
||||
: null
|
||||
}
|
||||
if (index === whitespaceStart) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nameStart = index
|
||||
while (isAttributeNameCode(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (index === nameStart) {
|
||||
return null
|
||||
}
|
||||
const normalizedName = input.slice(nameStart, index).toLowerCase()
|
||||
if (normalizedName !== 'href' && normalizedName !== 'title') {
|
||||
return null
|
||||
}
|
||||
if (attributes.some((attribute) => attribute.name === normalizedName)) {
|
||||
return null
|
||||
}
|
||||
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (input.charCodeAt(index) !== 61) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
index += 1
|
||||
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
|
||||
const parsedValue = parseAttributeValue(input, index, limit, stats)
|
||||
if (!parsedValue) {
|
||||
return null
|
||||
}
|
||||
attributes.push({
|
||||
name: normalizedName,
|
||||
rawValue: parsedValue.rawValue,
|
||||
quote: parsedValue.quote
|
||||
})
|
||||
index = parsedValue.end
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAttributeValue(
|
||||
input: string,
|
||||
start: number,
|
||||
limit: number,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): { end: number; rawValue: string; quote: HtmlAttributeQuote } | null {
|
||||
const first = input[start]
|
||||
if (first === '"' || first === "'") {
|
||||
let index = start + 1
|
||||
while (index < limit && input[index] !== first) {
|
||||
step(stats)
|
||||
const code = input.charCodeAt(index)
|
||||
if (code === 10 || code === 13) {
|
||||
return null
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
if (index >= limit) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
return {
|
||||
end: index + 1,
|
||||
rawValue: input.slice(start + 1, index),
|
||||
quote: first
|
||||
}
|
||||
}
|
||||
|
||||
let index = start
|
||||
while (index < limit) {
|
||||
const code = input.charCodeAt(index)
|
||||
if (isHtmlWhitespace(code) || code === 62) {
|
||||
break
|
||||
}
|
||||
step(stats)
|
||||
if (code === 34 || code === 39 || code === 60 || code === 61 || code === 96) {
|
||||
return null
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return index === start ? null : { end: index, rawValue: input.slice(start, index), quote: null }
|
||||
}
|
||||
|
||||
function matchSimpleTag(
|
||||
input: string,
|
||||
start: number,
|
||||
name: 'a' | 'sup',
|
||||
closing: boolean,
|
||||
limit: number,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): number | null {
|
||||
let index = start
|
||||
if (input.charCodeAt(index) !== 60) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
index += 1
|
||||
if (closing) {
|
||||
if (input.charCodeAt(index) !== 47) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
for (let nameIndex = 0; nameIndex < name.length; nameIndex += 1) {
|
||||
step(stats)
|
||||
if (lowerCode(input.charCodeAt(index)) !== name.charCodeAt(nameIndex)) {
|
||||
return null
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
while (index < limit && isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (input.charCodeAt(index) !== 62) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function isAttributeNameCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
(code >= 48 && code <= 57) ||
|
||||
code === 95 ||
|
||||
code === 46 ||
|
||||
code === 58 ||
|
||||
code === 45
|
||||
)
|
||||
}
|
||||
|
||||
function isHtmlWhitespace(code: number): boolean {
|
||||
return code === 9 || code === 10 || code === 12 || code === 13 || code === 32
|
||||
}
|
||||
|
||||
function isSingleLineHtmlWhitespace(code: number): boolean {
|
||||
return code === 9 || code === 12 || code === 32
|
||||
}
|
||||
|
||||
function lowerCode(code: number): number {
|
||||
return code >= 65 && code <= 90 ? code + 32 : code
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return encoder.encode(value).byteLength
|
||||
}
|
||||
|
||||
function step(stats: HtmlSuperscriptLinkParseStats | undefined, count = 1): void {
|
||||
if (stats) {
|
||||
stats.transitions += count
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { marked } from 'marked'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import {
|
||||
createRichMarkdownEditorCodec,
|
||||
createRichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
createRichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
|
||||
matchHtmlSuperscriptLinkSource,
|
||||
parseHtmlSuperscriptLinkSource
|
||||
} from './rich-markdown-html-superscript-link-source'
|
||||
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
|
||||
import { findRichMarkdownSearchMatches } from './rich-markdown-search'
|
||||
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
|
||||
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
|
||||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { getSelectedHtmlSuperscriptLinkStatus } from './rich-markdown-selected-link-actions'
|
||||
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
|
||||
import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
inspectRichMarkdownSourceOwningSlice,
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
} from './rich-markdown-source-owning-slice'
|
||||
|
||||
const TEST_KEY = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
function createEditor(content: string, key = TEST_KEY, element: HTMLElement | null = null): Editor {
|
||||
const codec = createRichMarkdownEditorCodec(key)
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
return new Editor({
|
||||
element,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
}
|
||||
|
||||
function nodeNames(editor: Editor): string[] {
|
||||
const names: string[] = []
|
||||
editor.state.doc.descendants((node) => {
|
||||
names.push(node.type.name)
|
||||
})
|
||||
return names
|
||||
}
|
||||
|
||||
describe('rich Markdown HTML superscript links', () => {
|
||||
it('parses the reported fragment and preserves its exact source', () => {
|
||||
const source = '<sup><a href="https://example.com/source">[12]</a></sup>'
|
||||
const editor = createEditor(`研究结果${source}。`)
|
||||
try {
|
||||
expect(nodeNames(editor)).toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(editor.getMarkdown()).toBe(`研究结果${source}。`)
|
||||
const citation = editor.state.doc.firstChild?.child(1)
|
||||
expect(citation?.attrs).toMatchObject({
|
||||
source,
|
||||
href: 'https://example.com/source',
|
||||
label: '[12]',
|
||||
title: null
|
||||
})
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains casing, whitespace, quote style, entities, title, and adjacency', () => {
|
||||
const first =
|
||||
"<SUP ><A title='A & B' href='https://example.com/?a=1&b=2'>[¬it;]</A ></SUP >"
|
||||
const second = '<sup><a href=#section>[13]</a></sup>'
|
||||
const editor = createEditor(`${first}${second}`)
|
||||
try {
|
||||
expect(
|
||||
nodeNames(editor).filter((name) => name === 'richMarkdownHtmlSuperscriptLink')
|
||||
).toHaveLength(2)
|
||||
expect(editor.getMarkdown()).toBe(`${first}${second}`)
|
||||
const citation = editor.state.doc.firstChild?.firstChild
|
||||
expect(citation?.attrs.href).toBe('https://example.com/?a=1&b=2')
|
||||
expect(citation?.attrs.title).toBe('A & B')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves malformed and broadened HTML on the inert raw-source path', () => {
|
||||
const rejected = [
|
||||
'<sup><a href="">[12]<a></sup>',
|
||||
'<sup><a href="x" onclick="go()">[12]</a></sup>',
|
||||
'<sup><a href="x"><b>[12]</b></a></sup>',
|
||||
'<sup class="x"><a href="x">[12]</a></sup>',
|
||||
'<sup><a href="x">[12]\n</a></sup>',
|
||||
'<sup\n><a href="x">[12]</a></sup>',
|
||||
'<sup><a\n href="x">[12]</a></sup>',
|
||||
'<sup><a href =\n "x">[12]</a></sup>',
|
||||
'<sup><a href="x">[12]</a\n></sup>'
|
||||
]
|
||||
for (const source of rejected) {
|
||||
const editor = createEditor(source)
|
||||
try {
|
||||
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
if (!source.includes('\n')) {
|
||||
expect(editor.getMarkdown()).toBe(source)
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not recognize citations inside inline or fenced code', () => {
|
||||
const source = '<sup><a href="https://example.com">[12]</a></sup>'
|
||||
const markdown = `\`${source}\`\n\n\`\`\`html\n${source}\n\`\`\``
|
||||
const editor = createEditor(markdown)
|
||||
try {
|
||||
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(editor.getMarkdown()).toBe(markdown)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves authored current-key and legacy transport-looking text', () => {
|
||||
const transport = createRichMarkdownSourceTransport(TEST_KEY)
|
||||
const authored = `${transport.create('inline-html', '<b>authored</b>')} [[ORCA_RAW_HTML_INLINE:%3Ci%3Ex%3C%2Fi%3E]]`
|
||||
const editor = createEditor(authored)
|
||||
try {
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!')
|
||||
expect(editor.getMarkdown()).toBe(`${authored}!`)
|
||||
expect(nodeNames(editor)).not.toContain('markdownDocLink')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps sequential codecs isolated and exposes the complete marked surface', () => {
|
||||
const first = createRichMarkdownEditorCodec(TEST_KEY)
|
||||
const second = createRichMarkdownEditorCodec('fedcba9876543210fedcba9876543210')
|
||||
expect(Object.getOwnPropertyNames(first.marked).sort()).toEqual(
|
||||
Object.getOwnPropertyNames(marked).sort()
|
||||
)
|
||||
const authoredFirstToken = first.transport.create('inline-html', '<b>K1</b>')
|
||||
const editor = createEditor(authoredFirstToken, second.transport.key)
|
||||
try {
|
||||
expect(editor.getMarkdown()).toBe(authoredFirstToken)
|
||||
expect(nodeNames(editor)).not.toContain('rawMarkdownHtmlInline')
|
||||
expect(nodeNames(editor)).not.toContain('markdownDocLink')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps source matching linear across many rejected candidates', () => {
|
||||
const input = '<sup>'.repeat(2_000)
|
||||
let transitions = 0
|
||||
for (let index = 0; index < input.length; index += 5) {
|
||||
const stats = { transitions: 0 }
|
||||
expect(matchHtmlSuperscriptLinkSource(input, index, stats)).toBeNull()
|
||||
transitions += stats.transitions
|
||||
}
|
||||
expect(transitions).toBeLessThan(input.length * 4)
|
||||
})
|
||||
|
||||
it('projects only browser-safe clipboard hrefs', () => {
|
||||
expect(projectMarkdownHrefForClipboard(' https://example.com ')).toBe('https://example.com')
|
||||
expect(projectMarkdownHrefForClipboard('#section')).toBe('#section')
|
||||
expect(projectMarkdownHrefForClipboard('./guide.md')).toBe('./guide.md')
|
||||
expect(projectMarkdownHrefForClipboard('javascript:alert(1)')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('java\nscript:alert(1)')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('C:\\repo\\guide.md')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('\\\\server\\share\\guide.md')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('//example.com/path')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects duplicate attributes and parses quoted greater-than characters', () => {
|
||||
expect(
|
||||
parseHtmlSuperscriptLinkSource('<sup><a href="https://example.com/?q=>">[1]</a></sup>')?.href
|
||||
).toBe('https://example.com/?q=>')
|
||||
expect(parseHtmlSuperscriptLinkSource('<sup><a href="a" href="b">[1]</a></sup>')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders inert live DOM while exposing link semantics through Orca context', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const source = '<sup><a href="https://example.com">[12]</a></sup>'
|
||||
const codec = createRichMarkdownEditorCodec(TEST_KEY)
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
const editor = new Editor({
|
||||
element: host,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(source, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
try {
|
||||
const liveLabel = host.querySelector<HTMLElement>(
|
||||
'sup[data-rich-markdown-html-superscript-link] > span'
|
||||
)
|
||||
expect(liveLabel?.textContent).toBe('[12]')
|
||||
expect(liveLabel?.getAttribute('role')).toBe('link')
|
||||
expect(host.querySelector('sup a')).toBeNull()
|
||||
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
|
||||
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)).toEqual({
|
||||
href: 'https://example.com',
|
||||
label: '[12]',
|
||||
openEnabled: true
|
||||
})
|
||||
context.update({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
expect(liveLabel?.hasAttribute('role')).toBe(false)
|
||||
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)?.openEnabled).toBe(false)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps whitespace and remote outside-root destinations non-actionable', () => {
|
||||
const local = {
|
||||
version: 0,
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' as const }
|
||||
}
|
||||
expect(classifyHtmlSuperscriptLinkAction(' ', local)).toBe(false)
|
||||
expect(classifyHtmlSuperscriptLinkAction('file:///etc/passwd', local)).toBe(true)
|
||||
expect(
|
||||
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
|
||||
...local,
|
||||
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
|
||||
...local,
|
||||
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'runtime-1' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('scopes citation Tab focus to the selected editor bubble', () => {
|
||||
const editor = createEditor('<sup><a href="https://example.com">[12]</a></sup>')
|
||||
const firstBubble = document.createElement('div')
|
||||
firstBubble.dataset.richMarkdownLinkBubbleOwner = 'first-owner'
|
||||
const firstButton = document.createElement('button')
|
||||
firstBubble.appendChild(firstButton)
|
||||
const secondBubble = document.createElement('div')
|
||||
secondBubble.dataset.richMarkdownLinkBubbleOwner = 'second-owner'
|
||||
const secondButton = document.createElement('button')
|
||||
secondBubble.appendChild(secondButton)
|
||||
document.body.append(firstBubble, secondBubble)
|
||||
try {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
|
||||
const event = {
|
||||
key: 'Tab',
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
preventDefault: () => {}
|
||||
} as KeyboardEvent
|
||||
expect(
|
||||
handleRichMarkdownCitationKey({
|
||||
editor,
|
||||
event,
|
||||
linkBubbleOwnerId: 'second-owner'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(document.activeElement).toBe(secondButton)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
firstBubble.remove()
|
||||
secondBubble.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a folder workspace path as the citation source root', () => {
|
||||
const state = {
|
||||
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' }],
|
||||
worktreesByRepo: {}
|
||||
} as Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
|
||||
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
|
||||
'/workspace/platform'
|
||||
)
|
||||
})
|
||||
|
||||
it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
|
||||
const source = '<sup><a title="Source" href="./guide.md">[12]</a></sup>'
|
||||
const host = document.createElement('div')
|
||||
const editor = createEditor(source, TEST_KEY, host)
|
||||
try {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
|
||||
const html = editor.view.serializeForClipboard(editor.state.selection.content()).dom.innerHTML
|
||||
const pasted = createEditor('')
|
||||
try {
|
||||
pasted.commands.setContent(html, { contentType: 'html' })
|
||||
expect(pasted.getMarkdown()).toBe(source)
|
||||
} finally {
|
||||
pasted.destroy()
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
const anchor = template.content.querySelector('a')
|
||||
if (!anchor) {
|
||||
throw new Error('expected serialized citation anchor')
|
||||
}
|
||||
anchor.textContent = '[99]'
|
||||
const forged = createEditor('')
|
||||
try {
|
||||
forged.commands.setContent(template.innerHTML, { contentType: 'html' })
|
||||
expect(nodeNames(forged)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(forged.state.doc.textContent).toContain('[99]')
|
||||
} finally {
|
||||
forged.destroy()
|
||||
}
|
||||
|
||||
anchor.textContent = '[12]'
|
||||
anchor.before('hidden sibling')
|
||||
const siblingForged = createEditor('')
|
||||
try {
|
||||
siblingForged.commands.setContent(template.innerHTML, { contentType: 'html' })
|
||||
expect(nodeNames(siblingForged)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(siblingForged.state.doc.textContent).toContain('hidden sibling')
|
||||
} finally {
|
||||
siblingForged.destroy()
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects oversized clipboard source before UTF-8 encoding', () => {
|
||||
const editor = createEditor('')
|
||||
const oversizedSource = 'x'.repeat(HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
|
||||
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
|
||||
try {
|
||||
editor.commands.setContent(
|
||||
`<sup data-rich-markdown-html-superscript-link="1" data-orca-superscript-link-source="${oversizedSource}"><a>x</a></sup>`,
|
||||
{ contentType: 'html' }
|
||||
)
|
||||
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(encodeSpy.mock.calls.some(([value]) => value === oversizedSource)).toBe(false)
|
||||
} finally {
|
||||
encodeSpy.mockRestore()
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps citation labels into search, review, and empty-selection Cut', () => {
|
||||
const source = 'Before <sup><a href="https://example.com">[12]</a></sup> after'
|
||||
const editor = createEditor(source)
|
||||
try {
|
||||
const matches = findRichMarkdownSearchMatches(editor.state.doc, 'e [12] a')
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]?.touchesReadOnlyAtom).toBe(true)
|
||||
let citationPosition = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
|
||||
citationPosition = pos
|
||||
}
|
||||
})
|
||||
const reviewRanges = findRichMarkdownSelectedTextRanges({
|
||||
editor,
|
||||
selectedText: 'Before [12] after'
|
||||
})
|
||||
expect(
|
||||
reviewRanges.some(
|
||||
(range) => range.from <= citationPosition && range.to >= citationPosition + 1
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const citationHost = document.createElement('div')
|
||||
const citationOnly = createEditor(
|
||||
'<sup><a href="https://example.com">[12]</a></sup>',
|
||||
TEST_KEY,
|
||||
citationHost
|
||||
)
|
||||
try {
|
||||
citationOnly.view.dispatch(
|
||||
citationOnly.state.tr.setSelection(TextSelection.create(citationOnly.state.doc, 1))
|
||||
)
|
||||
const clipboard = new Map<string, string>()
|
||||
const event = {
|
||||
clipboardData: {
|
||||
setData: (type: string, value: string) => clipboard.set(type, value),
|
||||
getData: (type: string) => clipboard.get(type) ?? ''
|
||||
},
|
||||
preventDefault: () => {}
|
||||
} as unknown as ClipboardEvent
|
||||
expect(handleRichMarkdownCut(citationOnly.view, event)).toBe(true)
|
||||
expect(clipboard.get('text/plain')).toBe('[12]')
|
||||
expect(clipboard.get('text/html')).toContain('data-orca-superscript-link-source')
|
||||
expect(citationOnly.getMarkdown()).toBe('')
|
||||
} finally {
|
||||
citationOnly.destroy()
|
||||
citationHost.remove()
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces aggregate UTF-8 bounds even when oversized text precedes the citation', () => {
|
||||
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
|
||||
const exactText = 'a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)
|
||||
const exact = createEditor(`${exactText}${citation}`)
|
||||
try {
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(exact.state.doc.slice(0, exact.state.doc.content.size))
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
|
||||
} finally {
|
||||
exact.destroy()
|
||||
}
|
||||
|
||||
const over = createEditor(`${exactText}a${citation}`)
|
||||
try {
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(over.state.doc.slice(0, over.state.doc.content.size))
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
} finally {
|
||||
over.destroy()
|
||||
}
|
||||
|
||||
const astralOver = createEditor(
|
||||
`${'😀'.repeat(Math.floor(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT / 4))}${citation}`
|
||||
)
|
||||
try {
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(
|
||||
astralOver.state.doc.slice(0, astralOver.state.doc.content.size)
|
||||
)
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
} finally {
|
||||
astralOver.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts visible leaf serializers in source-owning slice bounds', () => {
|
||||
const editor = createEditor('')
|
||||
try {
|
||||
const schema = editor.state.schema
|
||||
const hardBreak = schema.nodes.hardBreak
|
||||
const docLink = schema.nodes.markdownDocLink
|
||||
const citation = schema.nodes.richMarkdownHtmlSuperscriptLink
|
||||
if (!hardBreak || !docLink || !citation) {
|
||||
throw new Error('Expected rich Markdown leaf node types')
|
||||
}
|
||||
const citationAttrs = {
|
||||
source: '<sup><a href="x">[1]</a></sup>',
|
||||
href: 'x',
|
||||
label: '[1]',
|
||||
title: null
|
||||
}
|
||||
const exactDoc = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [
|
||||
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 4)),
|
||||
hardBreak.create(),
|
||||
citation.create(citationAttrs)
|
||||
])
|
||||
])
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(exactDoc.slice(0, exactDoc.content.size))
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
|
||||
const overHardBreakDoc = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [
|
||||
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)),
|
||||
hardBreak.create(),
|
||||
citation.create(citationAttrs)
|
||||
])
|
||||
])
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(
|
||||
overHardBreakDoc.slice(0, overHardBreakDoc.content.size)
|
||||
)
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
|
||||
const oversizedLeafDoc = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [
|
||||
docLink.create({ target: 'Guide', label: 'x'.repeat(300_000) }),
|
||||
citation.create(citationAttrs)
|
||||
])
|
||||
])
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(
|
||||
oversizedLeafDoc.slice(0, oversizedLeafDoc.content.size)
|
||||
)
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('remaps review text across adjacent citation atoms without inventing spaces', () => {
|
||||
const editor = createEditor(
|
||||
'<sup><a href="https://one.example">[1]</a></sup><sup><a href="https://two.example">[2]</a></sup>'
|
||||
)
|
||||
try {
|
||||
const ranges = findRichMarkdownSelectedTextRanges({ editor, selectedText: '[1][2]' })
|
||||
expect(ranges).toHaveLength(1)
|
||||
expect(ranges[0]).toEqual({ from: 1, to: 3 })
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves block separators in visible text and review remapping', () => {
|
||||
const editor = createEditor('foo\n\nbar')
|
||||
try {
|
||||
expect(getRichMarkdownVisibleText(editor.state.doc)).toBe('foo\nbar')
|
||||
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'foo bar' })).toEqual([
|
||||
{ from: 1, to: 4 },
|
||||
{ from: 6, to: 9 }
|
||||
])
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps dense search matches with a monotonic segment walk', () => {
|
||||
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
|
||||
const editor = createEditor(`x${citation}`.repeat(200))
|
||||
try {
|
||||
const stats = { segmentVisits: 0 }
|
||||
expect(findRichMarkdownSearchMatches(editor.state.doc, 'x', undefined, stats)).toHaveLength(
|
||||
200
|
||||
)
|
||||
expect(stats.segmentVisits).toBeLessThan(1_000)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let search bridge omitted atoms or block boundaries', () => {
|
||||
const docLink = createEditor('foo[[Guide]]bar')
|
||||
try {
|
||||
expect(findRichMarkdownSearchMatches(docLink.state.doc, 'foobar')).toEqual([])
|
||||
expect(
|
||||
findRichMarkdownSearchMatches(docLink.state.doc, 'Guide')[0]?.touchesReadOnlyAtom
|
||||
).toBe(true)
|
||||
} finally {
|
||||
docLink.destroy()
|
||||
}
|
||||
|
||||
const rawHtml = createEditor('foo<kbd>bar')
|
||||
try {
|
||||
expect(findRichMarkdownSearchMatches(rawHtml.state.doc, 'foobar')).toEqual([])
|
||||
} finally {
|
||||
rawHtml.destroy()
|
||||
}
|
||||
|
||||
const blocks = createEditor('foo\n\nbar')
|
||||
try {
|
||||
expect(findRichMarkdownSearchMatches(blocks.state.doc, 'foobar')).toEqual([])
|
||||
} finally {
|
||||
blocks.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { Node } from '@tiptap/core'
|
||||
import type { RichMarkdownSourceTransport } from './rich-markdown-source-transport'
|
||||
import {
|
||||
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
|
||||
parseHtmlSuperscriptLinkSource,
|
||||
type HtmlSuperscriptLinkSource
|
||||
} from './rich-markdown-html-superscript-link-source'
|
||||
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const CLIPBOARD_VERSION = '1'
|
||||
const MARKER_ATTRIBUTE = 'data-rich-markdown-html-superscript-link'
|
||||
const SOURCE_ATTRIBUTE = 'data-orca-superscript-link-source'
|
||||
const clipboardEncoder = new TextEncoder()
|
||||
|
||||
export function createRichMarkdownHtmlSuperscriptLink(
|
||||
transport: RichMarkdownSourceTransport,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
) {
|
||||
return Node.create({
|
||||
name: 'richMarkdownHtmlSuperscriptLink',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
source: { default: '', rendered: false },
|
||||
href: { default: '', rendered: false },
|
||||
label: { default: '', rendered: false },
|
||||
title: { default: null, rendered: false }
|
||||
}
|
||||
},
|
||||
|
||||
markdownTokenName: 'richMarkdownHtmlSuperscriptLink',
|
||||
markdownTokenizer: {
|
||||
name: 'richMarkdownHtmlSuperscriptLink',
|
||||
level: 'inline',
|
||||
start: transport.startFor('html-superscript-link'),
|
||||
tokenize(source) {
|
||||
const matched = transport.match(source, 'html-superscript-link')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseStructuredPayload(matched.value)
|
||||
if (!parsed) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
type: 'richMarkdownHtmlSuperscriptLink',
|
||||
raw: matched.raw,
|
||||
citation: parsed
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
const citation = (token as { citation?: HtmlSuperscriptLinkSource }).citation
|
||||
if (token.type !== 'richMarkdownHtmlSuperscriptLink' || !citation) {
|
||||
return []
|
||||
}
|
||||
return helpers.createNode('richMarkdownHtmlSuperscriptLink', citation)
|
||||
},
|
||||
renderMarkdown: (node) => String(node.attrs?.source ?? ''),
|
||||
renderText: ({ node }) => String(node.attrs.label ?? ''),
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `sup[${MARKER_ATTRIBUTE}]`,
|
||||
getAttrs: (element: HTMLElement) => validateClipboardElement(element)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
const citation = node.attrs as HtmlSuperscriptLinkSource
|
||||
const projectedHref = projectMarkdownHrefForClipboard(citation.href)
|
||||
const anchorAttributes: Record<string, string> = {}
|
||||
if (projectedHref !== null) {
|
||||
anchorAttributes.href = projectedHref
|
||||
}
|
||||
if (citation.title !== null) {
|
||||
anchorAttributes.title = citation.title
|
||||
}
|
||||
return [
|
||||
'sup',
|
||||
{
|
||||
[MARKER_ATTRIBUTE]: CLIPBOARD_VERSION,
|
||||
[SOURCE_ATTRIBUTE]: citation.source
|
||||
},
|
||||
['a', anchorAttributes, citation.label]
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement('sup')
|
||||
dom.setAttribute(MARKER_ATTRIBUTE, '')
|
||||
dom.setAttribute('contenteditable', 'false')
|
||||
const label = document.createElement('span')
|
||||
label.className = 'rich-markdown-html-superscript-link'
|
||||
label.textContent = String(node.attrs.label ?? '')
|
||||
dom.appendChild(label)
|
||||
|
||||
const updateActionability = (): void => {
|
||||
const href = String(node.attrs.href ?? '')
|
||||
const actionable = classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
|
||||
label.setAttribute(
|
||||
'aria-label',
|
||||
actionable
|
||||
? translate(
|
||||
'auto.components.editor.richMarkdownHtmlSuperscriptLink.availableAriaLabel',
|
||||
'{{value0}}, link to {{value1}}',
|
||||
{ value0: String(node.attrs.label ?? ''), value1: href }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.richMarkdownHtmlSuperscriptLink.unavailableAriaLabel',
|
||||
'{{value0}}, citation link unavailable',
|
||||
{ value0: String(node.attrs.label ?? '') }
|
||||
)
|
||||
)
|
||||
if (actionable) {
|
||||
label.setAttribute('role', 'link')
|
||||
} else {
|
||||
label.removeAttribute('role')
|
||||
}
|
||||
label.toggleAttribute('data-actionable', actionable)
|
||||
}
|
||||
updateActionability()
|
||||
const unsubscribe = context.subscribe(updateActionability)
|
||||
return { dom, destroy: unsubscribe }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseStructuredPayload(value: string): HtmlSuperscriptLinkSource | null {
|
||||
let candidate: unknown
|
||||
try {
|
||||
candidate = JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isCitationShape(candidate)) {
|
||||
return null
|
||||
}
|
||||
const parsed = parseHtmlSuperscriptLinkSource(candidate.source)
|
||||
return parsed && sameCitation(parsed, candidate) ? parsed : null
|
||||
}
|
||||
|
||||
function validateClipboardElement(element: HTMLElement): false | Record<string, unknown> {
|
||||
if (
|
||||
element.getAttribute(MARKER_ATTRIBUTE) !== CLIPBOARD_VERSION ||
|
||||
!hasOnlyAttributes(element, [MARKER_ATTRIBUTE, SOURCE_ATTRIBUTE, 'data-pm-slice'])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const source = element.getAttribute(SOURCE_ATTRIBUTE)
|
||||
if (
|
||||
!source ||
|
||||
source.length > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
|
||||
clipboardEncoder.encode(source).byteLength > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const parsed = parseHtmlSuperscriptLinkSource(source)
|
||||
const anchor = element.firstElementChild
|
||||
if (
|
||||
!parsed ||
|
||||
element.childNodes.length !== 1 ||
|
||||
element.children.length !== 1 ||
|
||||
!anchor ||
|
||||
element.firstChild !== anchor ||
|
||||
anchor.tagName !== 'A' ||
|
||||
!hasOnlyAttributes(anchor, ['href', 'title']) ||
|
||||
anchor.childNodes.length !== 1 ||
|
||||
anchor.firstChild?.nodeType !== window.Node.TEXT_NODE ||
|
||||
anchor.textContent !== parsed.label ||
|
||||
anchor.getAttribute('title') !== parsed.title ||
|
||||
anchor.getAttribute('href') !== projectMarkdownHrefForClipboard(parsed.href)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function hasOnlyAttributes(element: Element, allowed: string[]): boolean {
|
||||
const allowedSet = new Set(allowed)
|
||||
return Array.from(element.attributes).every((attribute) => allowedSet.has(attribute.name))
|
||||
}
|
||||
|
||||
function isCitationShape(value: unknown): value is HtmlSuperscriptLinkSource {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidate = value as Record<string, unknown>
|
||||
return (
|
||||
Object.keys(candidate).length === 4 &&
|
||||
typeof candidate.source === 'string' &&
|
||||
typeof candidate.href === 'string' &&
|
||||
typeof candidate.label === 'string' &&
|
||||
(typeof candidate.title === 'string' || candidate.title === null)
|
||||
)
|
||||
}
|
||||
|
||||
function sameCitation(left: HtmlSuperscriptLinkSource, right: HtmlSuperscriptLinkSource): boolean {
|
||||
return (
|
||||
left.source === right.source &&
|
||||
left.href === right.href &&
|
||||
left.label === right.label &&
|
||||
left.title === right.title
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
function createEditor(content: object): Editor {
|
||||
return new Editor({
|
||||
|
|
@ -67,6 +67,18 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
|
|||
typedEmptyOrderedListMarkerRef: { current: typedMarker },
|
||||
flushPendingSerialization: vi.fn(),
|
||||
openSearchRef: { current: vi.fn() },
|
||||
linkBubbleOwnerId: 'test-owner',
|
||||
htmlSuperscriptLinkContext: {
|
||||
getSnapshot: () => ({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' as const },
|
||||
version: 0
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
update: () => {}
|
||||
},
|
||||
setIsEditingLink: vi.fn(),
|
||||
setLinkBubble: vi.fn(),
|
||||
setSelectedCommandIndex: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
|||
import { useAppStore } from '@/store'
|
||||
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import { editorShortcutMatches } from './editor-shortcuts'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
|
||||
import {
|
||||
runSlashCommand,
|
||||
|
|
@ -18,6 +18,9 @@ import {
|
|||
exitTrailingEmptyOrderedListItem
|
||||
} from './rich-markdown-list-continuation'
|
||||
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
|
||||
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
|
||||
|
||||
export type KeyHandlerContext = {
|
||||
isMac: boolean
|
||||
|
|
@ -44,6 +47,9 @@ export type KeyHandlerContext = {
|
|||
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
|
||||
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
|
||||
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
|
||||
openSelectedHtmlSuperscriptLink?: () => boolean
|
||||
linkBubbleOwnerId: string
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}
|
||||
|
||||
function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): boolean {
|
||||
|
|
@ -100,6 +106,16 @@ export function createRichMarkdownKeyHandler(
|
|||
): (_view: unknown, event: KeyboardEvent) => boolean {
|
||||
return (_view, event) => {
|
||||
const mod = ctx.isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
if (
|
||||
handleRichMarkdownCitationKey({
|
||||
editor: ctx.editorRef.current,
|
||||
event,
|
||||
linkBubbleOwnerId: ctx.linkBubbleOwnerId,
|
||||
onOpen: ctx.openSelectedHtmlSuperscriptLink
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
isMarkdownPreviewFindShortcut(
|
||||
event,
|
||||
|
|
@ -131,29 +147,18 @@ export function createRichMarkdownKeyHandler(
|
|||
return true
|
||||
}
|
||||
|
||||
// Link: Cmd/Ctrl+K — insert or edit a hyperlink.
|
||||
if (mod && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
const ed = ctx.editorRef.current
|
||||
if (!ed) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (ctx.isEditingLinkRef.current) {
|
||||
ctx.setIsEditingLink(false)
|
||||
if (!ed.isActive('link')) {
|
||||
ctx.setLinkBubble(null)
|
||||
}
|
||||
ed.commands.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
const pos = getLinkBubblePosition(ed, ctx.rootRef.current)
|
||||
if (pos) {
|
||||
const href = ed.isActive('link') ? (ed.getAttributes('link').href as string) || '' : ''
|
||||
ctx.setLinkBubble({ href, ...pos })
|
||||
ctx.setIsEditingLink(true)
|
||||
}
|
||||
if (
|
||||
handleRichMarkdownLinkShortcut({
|
||||
editor: ctx.editorRef.current,
|
||||
event,
|
||||
htmlSuperscriptLinkContext: ctx.htmlSuperscriptLinkContext,
|
||||
isEditing: ctx.isEditingLinkRef.current,
|
||||
isMac: ctx.isMac,
|
||||
root: ctx.rootRef.current,
|
||||
setEditing: ctx.setIsEditingLink,
|
||||
setLinkBubble: ctx.setLinkBubble
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ type RichMarkdownLargeTextPasteOptions = {
|
|||
measureYieldAfterCodeUnits?: number
|
||||
yieldToEventLoop?: () => Promise<void>
|
||||
canContinue?: (editor: Editor) => boolean
|
||||
plainTextOverride?: string
|
||||
htmlTextOverride?: string
|
||||
}
|
||||
|
||||
export type RichMarkdownLargeTextPasteResult =
|
||||
|
|
@ -188,8 +190,8 @@ export function handleRichMarkdownLargeTextPaste(
|
|||
return false
|
||||
}
|
||||
|
||||
const text = readPlainText(event)
|
||||
const html = readHtmlText(event)
|
||||
const text = options.plainTextOverride ?? readPlainText(event)
|
||||
const html = options.htmlTextOverride ?? readHtmlText(event)
|
||||
const directMaxBytes = options.directMaxBytes ?? RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES
|
||||
const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES
|
||||
const ownershipMeasurement = measureTextControlPasteByteLength(text, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { toastError, toastSuccess } = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
|
||||
|
||||
describe('copyRichMarkdownLink', () => {
|
||||
const writeClipboardText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(window as unknown as { api: unknown }).api = { ui: { writeClipboardText } }
|
||||
})
|
||||
|
||||
it('reports success only after the clipboard write resolves', async () => {
|
||||
let resolveWrite: (() => void) | undefined
|
||||
writeClipboardText.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve
|
||||
})
|
||||
)
|
||||
const copy = copyRichMarkdownLink('https://example.com')
|
||||
expect(toastSuccess).not.toHaveBeenCalled()
|
||||
resolveWrite?.()
|
||||
await copy
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Copied link')
|
||||
})
|
||||
|
||||
it('reports a rejected clipboard write without throwing', async () => {
|
||||
writeClipboardText.mockRejectedValue(new Error('clipboard unavailable'))
|
||||
await expect(copyRichMarkdownLink('https://example.com')).resolves.toBeUndefined()
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to copy link')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export async function copyRichMarkdownLink(href: string): Promise<void> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(href)
|
||||
toast.success(
|
||||
translate('auto.components.editor.richMarkdownLinkClipboard.copiedLink', 'Copied link')
|
||||
)
|
||||
} catch {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.editor.richMarkdownLinkClipboard.copyLinkFailed',
|
||||
'Failed to copy link'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
|
||||
|
||||
const TEST_KEY = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
function createEditor(content: string): {
|
||||
editor: Editor
|
||||
context: ReturnType<typeof createRichMarkdownHtmlSuperscriptLinkContext>
|
||||
root: HTMLDivElement
|
||||
} {
|
||||
const codec = createRichMarkdownEditorCodec(TEST_KEY)
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = new Editor({
|
||||
element: root,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
return { editor, context, root }
|
||||
}
|
||||
|
||||
function keyEvent(): KeyboardEvent {
|
||||
return {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as KeyboardEvent
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('handleRichMarkdownLinkShortcut', () => {
|
||||
it('opens the html-superscript action bubble for a citation NodeSelection', () => {
|
||||
const source = '<sup><a href="https://example.com">1</a></sup>'
|
||||
const { editor, context, root } = createEditor(`See note ${source}.`)
|
||||
try {
|
||||
let citationPos = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'richMarkdownHtmlSuperscriptLink' && citationPos < 0) {
|
||||
citationPos = pos
|
||||
}
|
||||
})
|
||||
expect(citationPos).toBeGreaterThanOrEqual(0)
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, citationPos))
|
||||
)
|
||||
|
||||
const setLinkBubble = vi.fn()
|
||||
const setEditing = vi.fn()
|
||||
|
||||
const handled = handleRichMarkdownLinkShortcut({
|
||||
editor,
|
||||
event: keyEvent(),
|
||||
htmlSuperscriptLinkContext: context,
|
||||
isEditing: false,
|
||||
isMac: true,
|
||||
root,
|
||||
setEditing,
|
||||
setLinkBubble
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(setEditing).toHaveBeenCalledWith(false)
|
||||
expect(setLinkBubble).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'html-superscript',
|
||||
href: 'https://example.com',
|
||||
label: '1'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens an editable markdown bubble when there is no link selection', () => {
|
||||
const { editor, context, root } = createEditor('plain text')
|
||||
try {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1)))
|
||||
const setLinkBubble = vi.fn()
|
||||
const setEditing = vi.fn()
|
||||
|
||||
handleRichMarkdownLinkShortcut({
|
||||
editor,
|
||||
event: keyEvent(),
|
||||
htmlSuperscriptLinkContext: context,
|
||||
isEditing: false,
|
||||
isMac: true,
|
||||
root,
|
||||
setEditing,
|
||||
setLinkBubble
|
||||
})
|
||||
|
||||
expect(setEditing).toHaveBeenCalledWith(true)
|
||||
expect(setLinkBubble).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'markdown',
|
||||
href: ''
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
createEditableMarkdownLinkBubble,
|
||||
getRichMarkdownSelectionLinkBubble
|
||||
} from './rich-markdown-selected-link-actions'
|
||||
|
||||
export function handleRichMarkdownLinkShortcut({
|
||||
editor,
|
||||
event,
|
||||
htmlSuperscriptLinkContext,
|
||||
isEditing,
|
||||
isMac,
|
||||
root,
|
||||
setEditing,
|
||||
setLinkBubble
|
||||
}: {
|
||||
editor: Editor | null
|
||||
event: KeyboardEvent
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
isEditing: boolean
|
||||
isMac: boolean
|
||||
root: HTMLElement | null
|
||||
setEditing: (editing: boolean) => void
|
||||
setLinkBubble: (bubble: LinkBubbleState | null) => void
|
||||
}): boolean {
|
||||
const modifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
if (!modifier || event.key.toLowerCase() !== 'k') {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (!editor) {
|
||||
return true
|
||||
}
|
||||
if (isEditing) {
|
||||
setEditing(false)
|
||||
if (!editor.isActive('link')) {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
editor.commands.focus()
|
||||
return true
|
||||
}
|
||||
// Why: NodeSelection on an HTML citation still has a bubble position, but
|
||||
// markdown setLink/unsetLink cannot edit that atom — open the citation
|
||||
// action bubble instead of the markdown edit field.
|
||||
const selectionBubble = getRichMarkdownSelectionLinkBubble(
|
||||
editor,
|
||||
root,
|
||||
htmlSuperscriptLinkContext
|
||||
)
|
||||
if (selectionBubble) {
|
||||
setLinkBubble(selectionBubble)
|
||||
setEditing(selectionBubble.kind === 'markdown')
|
||||
return true
|
||||
}
|
||||
const position = getLinkBubblePosition(editor, root)
|
||||
if (position) {
|
||||
setLinkBubble(createEditableMarkdownLinkBubble('', position))
|
||||
setEditing(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import {
|
||||
collapseEmptyListContinuationParagraph,
|
||||
commitEmptyOrderedListMarkerAsText,
|
||||
|
|
@ -10,12 +10,12 @@ import {
|
|||
isSingleEmptyTopLevelOrderedList
|
||||
} from './rich-markdown-list-continuation'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
|
||||
function createEditor(content: object): Editor {
|
||||
// Why: each Editor needs its own marked registry; sharing one module-scoped
|
||||
// extension accumulates tokenizer state across tests.
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions,
|
||||
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
|
||||
content
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
|
||||
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ describe('rich markdown local images', () => {
|
|||
document.body.appendChild(host)
|
||||
const editor = new Editor({
|
||||
element: host,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content: '',
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import type { Slice } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
|
||||
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
|
||||
import { handleRichMarkdownTerminalPathPaste } from './rich-markdown-terminal-path-paste'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
|
||||
|
||||
export type RichMarkdownPasteHandlerArgs = {
|
||||
editor: Editor | null
|
||||
|
|
@ -9,6 +13,8 @@ export type RichMarkdownPasteHandlerArgs = {
|
|||
filePath: string
|
||||
worktreeId: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
slice?: Slice
|
||||
view?: EditorView
|
||||
}
|
||||
|
||||
export function handleRichMarkdownPaste({
|
||||
|
|
@ -16,7 +22,9 @@ export function handleRichMarkdownPaste({
|
|||
event,
|
||||
filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
slice,
|
||||
view
|
||||
}: RichMarkdownPasteHandlerArgs): boolean {
|
||||
if (
|
||||
handleRichMarkdownImagePaste({
|
||||
|
|
@ -30,6 +38,36 @@ export function handleRichMarkdownPaste({
|
|||
return true
|
||||
}
|
||||
|
||||
const sourceOwningStatus = slice ? inspectRichMarkdownSourceOwningSlice(slice) : null
|
||||
if (sourceOwningStatus?.containsSourceOwningNode && slice && view) {
|
||||
if (sourceOwningStatus.canPreserve) {
|
||||
// Why: dispatch directly so the transaction can carry paste/uiEvent
|
||||
// metadata that editor.commands.insertContent would omit.
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.replaceSelection(slice)
|
||||
.setMeta('paste', true)
|
||||
.setMeta('uiEvent', 'paste')
|
||||
.scrollIntoView()
|
||||
)
|
||||
return true
|
||||
}
|
||||
const visibleText = getRichMarkdownVisibleText(slice.content)
|
||||
if (
|
||||
handleRichMarkdownLargeTextPaste(editor, event, {
|
||||
plainTextOverride: visibleText,
|
||||
htmlTextOverride: ''
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (visibleText && editor) {
|
||||
event.preventDefault()
|
||||
editor.commands.insertContent(visibleText)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (handleRichMarkdownTerminalPathPaste(editor, event)) {
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import {
|
|||
} from './rich-markdown-range-bounds'
|
||||
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
|
||||
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
|
||||
import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map'
|
||||
import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
|
||||
export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
|
||||
|
||||
const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24
|
||||
const RICH_MARKDOWN_ANNOTATION_EDGE_PADDING_PX = 8
|
||||
|
|
@ -42,27 +45,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
|
|||
buttonLeft: number
|
||||
}
|
||||
|
||||
export function countRichMarkdownReviewMarkdownLines(value: string): number {
|
||||
if (value.length === 0) {
|
||||
return 1
|
||||
}
|
||||
let lineCount = 1
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const charCode = value.charCodeAt(index)
|
||||
if (charCode === 13) {
|
||||
lineCount += 1
|
||||
if (value.charCodeAt(index + 1) === 10) {
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (charCode === 10) {
|
||||
lineCount += 1
|
||||
}
|
||||
}
|
||||
return lineCount
|
||||
}
|
||||
|
||||
function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
|
||||
return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
|
||||
}
|
||||
|
|
@ -293,7 +275,7 @@ export function getRichMarkdownAnnotationTarget(
|
|||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
const selectedText = window.getSelection()?.toString().trim() ?? ''
|
||||
const selectedText = getRichMarkdownSelectionVisibleText(editor.state)
|
||||
if (!selectedText) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export function countRichMarkdownReviewMarkdownLines(value: string): number {
|
||||
if (value.length === 0) {
|
||||
return 1
|
||||
}
|
||||
let lineCount = 1
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const charCode = value.charCodeAt(index)
|
||||
if (charCode === 13) {
|
||||
lineCount += 1
|
||||
if (value.charCodeAt(index + 1) === 10) {
|
||||
index += 1
|
||||
}
|
||||
} else if (charCode === 10) {
|
||||
lineCount += 1
|
||||
}
|
||||
}
|
||||
return lineCount
|
||||
}
|
||||
|
|
@ -40,13 +40,13 @@ function buildSelectedText(wordCount: number): string {
|
|||
}
|
||||
|
||||
describe('findRichMarkdownSelectedTextRanges', () => {
|
||||
it('matches selected text across normalized whitespace and text nodes', () => {
|
||||
it('matches selected text across adjacent text nodes without inventing whitespace', () => {
|
||||
const editor = editorWithTextNodes([
|
||||
{ isText: true, text: 'alpha' },
|
||||
{ isText: true, text: 'beta' }
|
||||
])
|
||||
|
||||
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alpha\n beta' })).toEqual([
|
||||
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alphabeta' })).toEqual([
|
||||
{ from: 0, to: 9 }
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
|
||||
import { forEachRichMarkdownVisibleTextSegment } from './rich-markdown-visible-text-map'
|
||||
|
||||
type TextPosition = { from: number; to: number } | null
|
||||
|
||||
type NormalizedTextChar = {
|
||||
value: string
|
||||
pos: number | null
|
||||
pos: TextPosition
|
||||
}
|
||||
|
||||
type MatchState = {
|
||||
readonly needle: string
|
||||
readonly prefixTable: number[]
|
||||
recentPositions: (number | null)[]
|
||||
recentPositions: TextPosition[]
|
||||
recentPositionWriteIndex: number
|
||||
matchLength: number
|
||||
positions: (number | null)[] | null
|
||||
positions: TextPosition[] | null
|
||||
}
|
||||
|
||||
type NormalizationState = {
|
||||
previousWasWhitespace: boolean
|
||||
emittedAnyText: boolean
|
||||
}
|
||||
|
||||
export function findRichMarkdownSelectedTextRanges({
|
||||
|
|
@ -45,31 +47,32 @@ export function findRichMarkdownSelectedTextRanges({
|
|||
positions: null
|
||||
}
|
||||
const normalizationState: NormalizationState = {
|
||||
previousWasWhitespace: false,
|
||||
emittedAnyText: false
|
||||
previousWasWhitespace: false
|
||||
}
|
||||
|
||||
// Why: review text can come from large pasted selections; stream the editor
|
||||
// text instead of building per-character haystack/needle arrays.
|
||||
editor.state.doc.nodesBetween(from ?? 0, to ?? editor.state.doc.content.size, (node, pos) => {
|
||||
if (matchState.positions || !node.isText) {
|
||||
return
|
||||
forEachRichMarkdownVisibleTextSegment(
|
||||
editor.state.doc,
|
||||
from ?? 0,
|
||||
to ?? editor.state.doc.content.size,
|
||||
(segment) => {
|
||||
for (let index = 0; index < segment.text.length && !matchState.positions; index += 1) {
|
||||
processRawTextChar(
|
||||
{
|
||||
value: segment.text.charAt(index),
|
||||
pos:
|
||||
segment.kind === 'separator'
|
||||
? null
|
||||
: segment.kind === 'read-only-atom'
|
||||
? { from: segment.from, to: segment.to }
|
||||
: { from: segment.from + index, to: segment.from + index + 1 }
|
||||
},
|
||||
normalizationState,
|
||||
matchState
|
||||
)
|
||||
}
|
||||
return !matchState.positions
|
||||
}
|
||||
const nodeText = node.text
|
||||
if (!nodeText) {
|
||||
return
|
||||
}
|
||||
if (normalizationState.emittedAnyText) {
|
||||
processRawTextChar({ value: ' ', pos: null }, normalizationState, matchState)
|
||||
}
|
||||
for (let index = 0; index < nodeText.length && !matchState.positions; index += 1) {
|
||||
processRawTextChar(
|
||||
{ value: nodeText.charAt(index), pos: pos + index },
|
||||
normalizationState,
|
||||
matchState
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return matchState.positions ? positionsToRanges(matchState.positions) : []
|
||||
}
|
||||
|
|
@ -101,7 +104,6 @@ function processRawTextChar(
|
|||
if (isRichMarkdownWhitespace(code)) {
|
||||
if (!normalizationState.previousWasWhitespace) {
|
||||
processNormalizedTextChar({ value: ' ', pos: char.pos }, matchState)
|
||||
normalizationState.emittedAnyText = true
|
||||
}
|
||||
normalizationState.previousWasWhitespace = true
|
||||
return
|
||||
|
|
@ -109,7 +111,6 @@ function processRawTextChar(
|
|||
|
||||
processNormalizedTextChar(char, matchState)
|
||||
normalizationState.previousWasWhitespace = false
|
||||
normalizationState.emittedAnyText = true
|
||||
}
|
||||
|
||||
function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchState): void {
|
||||
|
|
@ -128,7 +129,7 @@ function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchSt
|
|||
}
|
||||
}
|
||||
|
||||
function recordRecentPosition(pos: number | null, matchState: MatchState): void {
|
||||
function recordRecentPosition(pos: TextPosition, matchState: MatchState): void {
|
||||
if (matchState.recentPositions.length < matchState.needle.length) {
|
||||
matchState.recentPositions.push(pos)
|
||||
matchState.recentPositionWriteIndex =
|
||||
|
|
@ -141,8 +142,8 @@ function recordRecentPosition(pos: number | null, matchState: MatchState): void
|
|||
(matchState.recentPositionWriteIndex + 1) % matchState.needle.length
|
||||
}
|
||||
|
||||
function readRecentPositions(matchState: MatchState): (number | null)[] {
|
||||
const positions: (number | null)[] = []
|
||||
function readRecentPositions(matchState: MatchState): TextPosition[] {
|
||||
const positions: TextPosition[] = []
|
||||
for (let index = 0; index < matchState.needle.length; index += 1) {
|
||||
const bufferIndex = (matchState.recentPositionWriteIndex + index) % matchState.needle.length
|
||||
positions.push(matchState.recentPositions[bufferIndex] ?? null)
|
||||
|
|
@ -168,26 +169,26 @@ function buildPrefixTable(value: string): number[] {
|
|||
return table
|
||||
}
|
||||
|
||||
function positionsToRanges(positions: (number | null)[]): RichMarkdownAnnotationHighlightRange[] {
|
||||
function positionsToRanges(positions: TextPosition[]): RichMarkdownAnnotationHighlightRange[] {
|
||||
const ranges: RichMarkdownAnnotationHighlightRange[] = []
|
||||
let rangeFrom: number | null = null
|
||||
let rangeTo: number | null = null
|
||||
for (const pos of positions) {
|
||||
if (pos === null) {
|
||||
for (const position of positions) {
|
||||
if (position === null) {
|
||||
continue
|
||||
}
|
||||
if (rangeFrom === null || rangeTo === null) {
|
||||
rangeFrom = pos
|
||||
rangeTo = pos + 1
|
||||
rangeFrom = position.from
|
||||
rangeTo = position.to
|
||||
continue
|
||||
}
|
||||
if (pos === rangeTo) {
|
||||
rangeTo += 1
|
||||
if (position.from <= rangeTo) {
|
||||
rangeTo = Math.max(rangeTo, position.to)
|
||||
continue
|
||||
}
|
||||
ranges.push({ from: rangeFrom, to: rangeTo })
|
||||
rangeFrom = pos
|
||||
rangeTo = pos + 1
|
||||
rangeFrom = position.from
|
||||
rangeTo = position.to
|
||||
}
|
||||
if (rangeFrom !== null && rangeTo !== null) {
|
||||
ranges.push({ from: rangeFrom, to: rangeTo })
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ import { findRichMarkdownSearchMatches } from './rich-markdown-search'
|
|||
|
||||
function docFromText(text: string, pos = 0): ProseMirrorNode {
|
||||
return {
|
||||
descendants(callback: (node: { isText: boolean; text?: string }, pos: number) => void) {
|
||||
nodeSize: pos + text.length + 2,
|
||||
content: { size: pos + text.length },
|
||||
nodesBetween(
|
||||
_from: number,
|
||||
_to: number,
|
||||
callback: (node: { isText: boolean; text?: string }, pos: number) => void
|
||||
) {
|
||||
callback({ isText: true, text }, pos)
|
||||
}
|
||||
} as unknown as ProseMirrorNode
|
||||
|
|
|
|||
|
|
@ -6,12 +6,20 @@ import {
|
|||
isMarkdownPreviewSearchQueryTooLarge,
|
||||
type TextMatchOptions
|
||||
} from './markdown-preview-search'
|
||||
import {
|
||||
createRichMarkdownVisibleTextMap,
|
||||
type RichMarkdownVisibleTextSegment
|
||||
} from './rich-markdown-visible-text-map'
|
||||
|
||||
export type RichMarkdownSearchMatch = {
|
||||
from: number
|
||||
to: number
|
||||
touchesReadOnlyAtom?: boolean
|
||||
decorationRanges?: { from: number; to: number; kind: 'inline' | 'node' }[]
|
||||
}
|
||||
|
||||
export type RichMarkdownSearchStats = { segmentVisits: number }
|
||||
|
||||
type RichMarkdownSearchState = {
|
||||
activeIndex: number
|
||||
decorations: DecorationSet
|
||||
|
|
@ -31,7 +39,8 @@ export const richMarkdownSearchPluginKey = new PluginKey<RichMarkdownSearchState
|
|||
export function findRichMarkdownSearchMatches(
|
||||
doc: ProseMirrorNode,
|
||||
query: string,
|
||||
options?: TextMatchOptions
|
||||
options?: TextMatchOptions,
|
||||
stats?: RichMarkdownSearchStats
|
||||
): RichMarkdownSearchMatch[] {
|
||||
if (!query) {
|
||||
return []
|
||||
|
|
@ -41,28 +50,80 @@ export function findRichMarkdownSearchMatches(
|
|||
}
|
||||
|
||||
const matches: RichMarkdownSearchMatch[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) {
|
||||
return
|
||||
const visibleMap = createRichMarkdownVisibleTextMap(doc)
|
||||
const ranges = findTextMatchRanges(visibleMap.text, query, options)
|
||||
let segmentIndex = 0
|
||||
for (const range of ranges) {
|
||||
while (
|
||||
segmentIndex < visibleMap.segments.length &&
|
||||
visibleMap.segments[segmentIndex]!.visibleTo <= range.start
|
||||
) {
|
||||
if (stats) {
|
||||
stats.segmentVisits += 1
|
||||
}
|
||||
segmentIndex += 1
|
||||
}
|
||||
|
||||
const text = node.text ?? ''
|
||||
if (!text.trim()) {
|
||||
return
|
||||
const firstSegmentIndex = segmentIndex
|
||||
let lastSegmentIndex = firstSegmentIndex - 1
|
||||
let touchesReadOnlyAtom = false
|
||||
let segmentsAreContiguous = true
|
||||
let touchesSeparator = false
|
||||
while (
|
||||
lastSegmentIndex + 1 < visibleMap.segments.length &&
|
||||
visibleMap.segments[lastSegmentIndex + 1]!.visibleFrom < range.end
|
||||
) {
|
||||
lastSegmentIndex += 1
|
||||
if (stats) {
|
||||
stats.segmentVisits += 1
|
||||
}
|
||||
touchesReadOnlyAtom ||= visibleMap.segments[lastSegmentIndex]!.kind === 'read-only-atom'
|
||||
touchesSeparator ||= visibleMap.segments[lastSegmentIndex]!.kind === 'separator'
|
||||
const previous = visibleMap.segments[lastSegmentIndex - 1]
|
||||
const current = visibleMap.segments[lastSegmentIndex]!
|
||||
if (lastSegmentIndex > firstSegmentIndex && previous?.to !== current.from) {
|
||||
segmentsAreContiguous = false
|
||||
}
|
||||
}
|
||||
|
||||
const ranges = findTextMatchRanges(text, query, options)
|
||||
for (const range of ranges) {
|
||||
matches.push({
|
||||
from: pos + range.start,
|
||||
to: pos + range.end
|
||||
})
|
||||
const first = visibleMap.segments[firstSegmentIndex]
|
||||
const last = visibleMap.segments[lastSegmentIndex]
|
||||
if (!first || !last || !segmentsAreContiguous || touchesSeparator) {
|
||||
continue
|
||||
}
|
||||
})
|
||||
const from = mapSegmentStart(first, range.start)
|
||||
const to = mapSegmentEnd(last, range.end)
|
||||
matches.push(
|
||||
touchesReadOnlyAtom
|
||||
? {
|
||||
from,
|
||||
to,
|
||||
touchesReadOnlyAtom: true,
|
||||
decorationRanges: visibleMap.segments
|
||||
.slice(firstSegmentIndex, lastSegmentIndex + 1)
|
||||
.map((segment) => ({
|
||||
from: mapSegmentStart(segment, range.start),
|
||||
to: mapSegmentEnd(segment, range.end),
|
||||
kind: segment.kind === 'text' ? ('inline' as const) : ('node' as const)
|
||||
}))
|
||||
}
|
||||
: { from, to }
|
||||
)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function mapSegmentStart(segment: RichMarkdownVisibleTextSegment, visibleFrom: number): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.from + Math.max(0, visibleFrom - segment.visibleFrom)
|
||||
: segment.from
|
||||
}
|
||||
|
||||
function mapSegmentEnd(segment: RichMarkdownVisibleTextSegment, visibleTo: number): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.from + Math.min(segment.text.length, visibleTo - segment.visibleFrom)
|
||||
: segment.to
|
||||
}
|
||||
|
||||
export function createRichMarkdownSearchPlugin(): Plugin<RichMarkdownSearchState> {
|
||||
return new Plugin<RichMarkdownSearchState>({
|
||||
key: richMarkdownSearchPluginKey,
|
||||
|
|
@ -126,11 +187,18 @@ function buildSearchDecorationsFromMatches(
|
|||
return DecorationSet.empty
|
||||
}
|
||||
|
||||
const decorations = matches.map((match, index) =>
|
||||
Decoration.inline(match.from, match.to, {
|
||||
class: 'rich-markdown-search-match',
|
||||
'data-active': index === activeIndex ? 'true' : undefined
|
||||
})
|
||||
const decorations = matches.flatMap((match, index) =>
|
||||
(match.decorationRanges ?? [{ from: match.from, to: match.to, kind: 'inline' as const }]).map(
|
||||
(range) => {
|
||||
const attrs = {
|
||||
class: 'rich-markdown-search-match',
|
||||
'data-active': index === activeIndex ? 'true' : undefined
|
||||
}
|
||||
return range.kind === 'node'
|
||||
? Decoration.node(range.from, range.to, attrs)
|
||||
: Decoration.inline(range.from, range.to, attrs)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
|
||||
import type { ActivateMarkdownLink } from './rich-markdown-editor-click-routing'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function createEditableMarkdownLinkBubble(
|
||||
href: string,
|
||||
position: Pick<LinkBubbleState, 'left' | 'top'>
|
||||
): LinkBubbleState {
|
||||
return {
|
||||
kind: 'markdown',
|
||||
href,
|
||||
openEnabled: Boolean(href),
|
||||
copyEnabled: Boolean(href),
|
||||
...position
|
||||
}
|
||||
}
|
||||
|
||||
export function getRichMarkdownSelectionLinkBubble(
|
||||
editor: Editor,
|
||||
root: HTMLElement | null,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
): LinkBubbleState | null {
|
||||
const position = getLinkBubblePosition(editor, root)
|
||||
if (!position) {
|
||||
return null
|
||||
}
|
||||
if (editor.isActive('link')) {
|
||||
return createBubble(
|
||||
'markdown',
|
||||
String(editor.getAttributes('link').href ?? ''),
|
||||
position,
|
||||
context
|
||||
)
|
||||
}
|
||||
const selection = editor.state.selection
|
||||
if (
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return createBubble(
|
||||
'html-superscript',
|
||||
String(selection.node.attrs.href ?? ''),
|
||||
position,
|
||||
context,
|
||||
String(selection.node.attrs.label ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
export function getSelectedHtmlSuperscriptLinkStatus(
|
||||
editor: Editor | null,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
): { href: string; label: string; openEnabled: boolean } | null {
|
||||
const selection = editor?.state.selection
|
||||
if (
|
||||
!editor ||
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const href = String(selection.node.attrs.href ?? '')
|
||||
return {
|
||||
href,
|
||||
label: String(selection.node.attrs.label ?? ''),
|
||||
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSelectedHtmlSuperscriptLinkStatus(
|
||||
status: NonNullable<ReturnType<typeof getSelectedHtmlSuperscriptLinkStatus>>
|
||||
): string {
|
||||
const label =
|
||||
status.label ||
|
||||
translate('auto.components.editor.RichMarkdownEditor.citationFallbackLabel', 'Citation')
|
||||
if (status.openEnabled) {
|
||||
return translate(
|
||||
'auto.components.editor.RichMarkdownEditor.citationLinkAvailable',
|
||||
'{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.',
|
||||
{ value0: label, value1: status.href }
|
||||
)
|
||||
}
|
||||
const actionHint = status.href
|
||||
? translate(
|
||||
'auto.components.editor.RichMarkdownEditor.tabForCitationActions',
|
||||
'Tab for available actions.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.RichMarkdownEditor.noCitationActions',
|
||||
'No link actions are available.'
|
||||
)
|
||||
return translate(
|
||||
'auto.components.editor.RichMarkdownEditor.citationLinkUnavailable',
|
||||
'{{value0}}, citation link unavailable. {{value1}}',
|
||||
{ value0: label, value1: actionHint }
|
||||
)
|
||||
}
|
||||
|
||||
export function openSelectedHtmlSuperscriptLink({
|
||||
activateMarkdownLink,
|
||||
context,
|
||||
editor,
|
||||
root,
|
||||
runtimeEnvironmentId
|
||||
}: {
|
||||
activateMarkdownLink: ActivateMarkdownLink
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
editor: Editor | null
|
||||
root: HTMLElement | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
}): boolean {
|
||||
const selection = editor?.state.selection
|
||||
if (
|
||||
!editor ||
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const href = String(selection.node.attrs.href ?? '')
|
||||
const snapshot = context.getSnapshot()
|
||||
if (!classifyHtmlSuperscriptLinkAction(href, snapshot)) {
|
||||
return true
|
||||
}
|
||||
if (href.startsWith('#')) {
|
||||
scrollToAnchorInEditor(root, href.slice(1))
|
||||
return true
|
||||
}
|
||||
void activateMarkdownLink(href, {
|
||||
sourceFilePath: snapshot.sourceFilePath,
|
||||
worktreeId: snapshot.worktreeId,
|
||||
worktreeRoot: snapshot.worktreeRoot,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner: snapshot.sourceOwner
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function createBubble(
|
||||
kind: LinkBubbleState['kind'],
|
||||
href: string,
|
||||
position: Pick<LinkBubbleState, 'left' | 'top'>,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext,
|
||||
label?: string
|
||||
): LinkBubbleState {
|
||||
return {
|
||||
kind,
|
||||
href,
|
||||
label,
|
||||
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot()),
|
||||
copyEnabled: Boolean(href),
|
||||
...position
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function showRichMarkdownSourceOwningCutLimitError(): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.editor.richMarkdownSourceOwningCutFeedback.selectLessContent',
|
||||
'Select less content or use code mode to cut preserved HTML citations.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import type { Slice } from '@tiptap/pm/model'
|
||||
import {
|
||||
getRichMarkdownLeafVisibleText,
|
||||
isRichMarkdownVisibleBlockStart
|
||||
} from './rich-markdown-visible-text-map'
|
||||
|
||||
export const RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT = 256 * 1024
|
||||
export const RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT = 256
|
||||
|
||||
export type RichMarkdownSourceOwningSliceStatus = {
|
||||
containsSourceOwningNode: boolean
|
||||
canPreserve: boolean
|
||||
}
|
||||
|
||||
export function inspectRichMarkdownSourceOwningSlice(
|
||||
slice: Slice
|
||||
): RichMarkdownSourceOwningSliceStatus {
|
||||
let sourceBytes = 0
|
||||
let visibleBytes = 0
|
||||
let nodeCount = 0
|
||||
let containsSourceOwningNode = false
|
||||
let canPreserve = true
|
||||
let sawVisibleBlock = false
|
||||
|
||||
slice.content.descendants((node, pos, parent, index) => {
|
||||
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
|
||||
if (canPreserve && startsVisibleBlock) {
|
||||
if (sawVisibleBlock) {
|
||||
const separator = addUtf8BytesWithinLimit(
|
||||
visibleBytes,
|
||||
'\n',
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
)
|
||||
visibleBytes = separator.byteLength
|
||||
canPreserve = !separator.exceeded
|
||||
}
|
||||
sawVisibleBlock = true
|
||||
}
|
||||
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
|
||||
containsSourceOwningNode = true
|
||||
if (!canPreserve) {
|
||||
return false
|
||||
}
|
||||
nodeCount += 1
|
||||
const sourceMeasurement = addUtf8BytesWithinLimit(
|
||||
sourceBytes,
|
||||
String(node.attrs.source ?? ''),
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
)
|
||||
sourceBytes = sourceMeasurement.byteLength
|
||||
canPreserve =
|
||||
!sourceMeasurement.exceeded && nodeCount <= RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT
|
||||
if (!canPreserve) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (!canPreserve) {
|
||||
// Keep walking only far enough to detect a later source-owning atom.
|
||||
return true
|
||||
}
|
||||
const visible = node.isText
|
||||
? (node.text ?? '')
|
||||
: node.isLeaf
|
||||
? getRichMarkdownLeafVisibleText(node, pos, parent, index)
|
||||
: ''
|
||||
if (visible) {
|
||||
const measurement = addUtf8BytesWithinLimit(
|
||||
visibleBytes,
|
||||
visible,
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
)
|
||||
visibleBytes = measurement.byteLength
|
||||
canPreserve = !measurement.exceeded
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return { containsSourceOwningNode, canPreserve }
|
||||
}
|
||||
|
||||
function addUtf8BytesWithinLimit(
|
||||
current: number,
|
||||
value: string,
|
||||
limit: number
|
||||
): { byteLength: number; exceeded: boolean } {
|
||||
const remaining = limit - current
|
||||
if (value.length > remaining) {
|
||||
return { byteLength: limit + 1, exceeded: true }
|
||||
}
|
||||
let byteLength = current
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codePoint = value.codePointAt(index) ?? 0
|
||||
byteLength += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4
|
||||
if (byteLength > limit) {
|
||||
return { byteLength, exceeded: true }
|
||||
}
|
||||
if (codePoint > 0xffff) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return { byteLength, exceeded: false }
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { marked } from 'marked'
|
||||
import { createTiptapMarkedFacade } from './tiptap-marked-facade'
|
||||
|
||||
export type RichMarkdownSourceKind =
|
||||
| 'literal'
|
||||
| 'inline-html'
|
||||
| 'block-html'
|
||||
| 'document-link'
|
||||
| 'html-superscript-link'
|
||||
|
||||
const TRANSPORT_PREFIX = '[[ORCA_RICH_MD:'
|
||||
const TRANSPORT_SUFFIX = ']]'
|
||||
const KEY_PATTERN = /^[a-f0-9]{32}$/
|
||||
const TRANSPORT_BODY_PATTERN =
|
||||
/^ORCA_RICH_MD:[a-f0-9]{32}:(?:literal|inline-html|block-html|document-link|html-superscript-link):/
|
||||
const LEGACY_PREFIXES = ['ORCA_RAW_HTML_INLINE:', 'ORCA_RAW_HTML_BLOCK:', 'ORCA_DOC_LINK:'] as const
|
||||
|
||||
export type RichMarkdownSourceTransport = {
|
||||
readonly key: string
|
||||
readonly authoredPrefix: string
|
||||
create: (kind: RichMarkdownSourceKind, value: string) => string
|
||||
match: (source: string, kind: RichMarkdownSourceKind) => { raw: string; value: string } | null
|
||||
startFor: (kind: RichMarkdownSourceKind) => string
|
||||
}
|
||||
|
||||
export function isLegacyRichMarkdownTransportBody(value: string): boolean {
|
||||
return LEGACY_PREFIXES.some((prefix) => value.startsWith(prefix))
|
||||
}
|
||||
|
||||
export function isReservedRichMarkdownTransportBody(value: string): boolean {
|
||||
// Why: a foreign editor's valid keyed envelope is authored text here, not
|
||||
// a document link whose target happens to resemble transport metadata.
|
||||
return isLegacyRichMarkdownTransportBody(value) || TRANSPORT_BODY_PATTERN.test(value)
|
||||
}
|
||||
|
||||
export type RichMarkdownEditorCodec = {
|
||||
transport: RichMarkdownSourceTransport
|
||||
marked: typeof marked
|
||||
}
|
||||
|
||||
export function createRichMarkdownEditorCodec(key = createCodecKey()): RichMarkdownEditorCodec {
|
||||
return {
|
||||
transport: createRichMarkdownSourceTransport(key),
|
||||
marked: createTiptapMarkedFacade()
|
||||
}
|
||||
}
|
||||
|
||||
export function createRichMarkdownSourceTransport(key: string): RichMarkdownSourceTransport {
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
throw new Error('Rich Markdown transport keys must be 128-bit lowercase hex values')
|
||||
}
|
||||
const authoredPrefix = `${TRANSPORT_PREFIX}${key}:`
|
||||
const startFor = (kind: RichMarkdownSourceKind): string => `${authoredPrefix}${kind}:`
|
||||
|
||||
return {
|
||||
key,
|
||||
authoredPrefix,
|
||||
startFor,
|
||||
create: (kind, value) => `${startFor(kind)}${encodeURIComponent(value)}${TRANSPORT_SUFFIX}`,
|
||||
match: (source, kind) => {
|
||||
const prefix = startFor(kind)
|
||||
if (!source.startsWith(prefix)) {
|
||||
return null
|
||||
}
|
||||
const endIndex = source.indexOf(TRANSPORT_SUFFIX, prefix.length)
|
||||
if (endIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const raw = source.slice(0, endIndex + TRANSPORT_SUFFIX.length)
|
||||
try {
|
||||
return {
|
||||
raw,
|
||||
value: decodeURIComponent(source.slice(prefix.length, endIndex))
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCodecKey(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
|
@ -3,20 +3,20 @@ import { Editor } from '@tiptap/core'
|
|||
import StarterKit from '@tiptap/starter-kit'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
|
||||
|
||||
const extensions = [
|
||||
StarterKit,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown.configure({ markedOptions: { gfm: true } })
|
||||
]
|
||||
|
||||
function createEditor(content: object): Editor {
|
||||
// Why: each Editor needs its own marked registry; sharing one module-scoped
|
||||
// extension accumulates tokenizer state across tests.
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
],
|
||||
content
|
||||
})
|
||||
}
|
||||
|
|
@ -74,6 +74,18 @@ function createContext(editor: Editor): KeyHandlerContext {
|
|||
typedEmptyOrderedListMarkerRef: { current: false },
|
||||
flushPendingSerialization: vi.fn(),
|
||||
openSearchRef: { current: vi.fn() },
|
||||
linkBubbleOwnerId: 'test-owner',
|
||||
htmlSuperscriptLinkContext: {
|
||||
getSnapshot: () => ({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' as const },
|
||||
version: 0
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
update: () => {}
|
||||
},
|
||||
setIsEditingLink: vi.fn(),
|
||||
setLinkBubble: vi.fn(),
|
||||
setSelectedCommandIndex: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import type { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
|
||||
type RichMarkdownVisibleTextContainer = ProseMirrorNode | Fragment
|
||||
|
||||
export type RichMarkdownVisibleTextSegment = {
|
||||
kind: 'text' | 'read-only-atom' | 'separator'
|
||||
text: string
|
||||
from: number
|
||||
to: number
|
||||
visibleFrom: number
|
||||
visibleTo: number
|
||||
}
|
||||
|
||||
export type RichMarkdownVisibleTextMap = {
|
||||
text: string
|
||||
segments: RichMarkdownVisibleTextSegment[]
|
||||
}
|
||||
|
||||
export function createRichMarkdownVisibleTextMap(
|
||||
doc: RichMarkdownVisibleTextContainer,
|
||||
from = 0,
|
||||
to = getContainerContentSize(doc)
|
||||
): RichMarkdownVisibleTextMap {
|
||||
const segments: RichMarkdownVisibleTextSegment[] = []
|
||||
let text = ''
|
||||
forEachRichMarkdownVisibleTextSegment(doc, from, to, (segment) => {
|
||||
const visibleFrom = text.length
|
||||
text += segment.text
|
||||
segments.push({ ...segment, visibleFrom, visibleTo: text.length })
|
||||
return true
|
||||
})
|
||||
return { text, segments }
|
||||
}
|
||||
|
||||
export function getRichMarkdownVisibleText(
|
||||
doc: RichMarkdownVisibleTextContainer,
|
||||
from = 0,
|
||||
to = getContainerContentSize(doc)
|
||||
): string {
|
||||
return createRichMarkdownVisibleTextMap(doc, from, to).text
|
||||
}
|
||||
|
||||
export function getRichMarkdownSelectionVisibleText(state: {
|
||||
doc: ProseMirrorNode
|
||||
selection: { from: number; to: number }
|
||||
}): string {
|
||||
return getRichMarkdownVisibleText(state.doc, state.selection.from, state.selection.to).trim()
|
||||
}
|
||||
|
||||
/** Why: shared block-boundary heuristic for visible-text maps and slice byte limits. */
|
||||
export function isRichMarkdownVisibleBlockStart(node: ProseMirrorNode): boolean {
|
||||
return node.isTextblock || (node.isBlock && node.isLeaf)
|
||||
}
|
||||
|
||||
export function forEachRichMarkdownVisibleTextSegment(
|
||||
doc: RichMarkdownVisibleTextContainer,
|
||||
from: number,
|
||||
to: number,
|
||||
visit: (segment: Omit<RichMarkdownVisibleTextSegment, 'visibleFrom' | 'visibleTo'>) => boolean
|
||||
): void {
|
||||
let stopped = false
|
||||
let sawVisibleBlock = false
|
||||
const inspect = (
|
||||
node: ProseMirrorNode,
|
||||
pos: number,
|
||||
parent: ProseMirrorNode | null = null,
|
||||
index = 0
|
||||
) => {
|
||||
if (stopped) {
|
||||
return false
|
||||
}
|
||||
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
|
||||
if (startsVisibleBlock) {
|
||||
if (sawVisibleBlock) {
|
||||
stopped = !visit({ kind: 'separator', text: '\n', from: pos, to: pos })
|
||||
if (stopped) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
sawVisibleBlock = true
|
||||
if (node.isTextblock) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
let visible = ''
|
||||
let segmentFrom = pos
|
||||
let segmentTo = pos + node.nodeSize
|
||||
let kind: RichMarkdownVisibleTextSegment['kind'] = 'text'
|
||||
if (node.isText) {
|
||||
const source = node.text ?? ''
|
||||
const startOffset = Math.max(0, from - pos)
|
||||
const endOffset = Math.min(source.length, to - pos)
|
||||
if (endOffset <= startOffset) {
|
||||
return
|
||||
}
|
||||
visible = source.slice(startOffset, endOffset)
|
||||
segmentFrom = pos + startOffset
|
||||
segmentTo = pos + endOffset
|
||||
} else if (node.isLeaf) {
|
||||
visible = getRichMarkdownLeafVisibleText(node, pos, parent, index)
|
||||
kind = node.isAtom ? 'read-only-atom' : 'text'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
stopped = !visit({
|
||||
kind,
|
||||
text: visible,
|
||||
from: segmentFrom,
|
||||
to: segmentTo
|
||||
})
|
||||
return !stopped
|
||||
}
|
||||
doc.nodesBetween(from, to, inspect)
|
||||
}
|
||||
|
||||
export function getRichMarkdownLeafVisibleText(
|
||||
node: ProseMirrorNode,
|
||||
pos: number,
|
||||
parent: ProseMirrorNode | null,
|
||||
index: number
|
||||
): string {
|
||||
return (
|
||||
node.type.spec.toText?.({ node, pos, parent, index }) ?? node.type.spec.leafText?.(node) ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function getContainerContentSize(container: RichMarkdownVisibleTextContainer): number {
|
||||
return 'nodeSize' in container ? container.content.size : container.size
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { DOMSerializer } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
|
||||
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
|
||||
|
||||
/**
|
||||
* Why: a paragraph that word-wraps across multiple screen lines should be cut
|
||||
|
|
@ -85,14 +86,15 @@ export function cutVisualLine(
|
|||
}
|
||||
event.preventDefault()
|
||||
|
||||
const lineText = view.state.doc.textBetween(lineRange.from, lineRange.to, '')
|
||||
const lineText = createRichMarkdownVisibleTextMap(
|
||||
view.state.doc,
|
||||
lineRange.from,
|
||||
lineRange.to
|
||||
).text
|
||||
const slice = view.state.doc.slice(lineRange.from, lineRange.to)
|
||||
const serializer = DOMSerializer.fromSchema(view.state.schema)
|
||||
const fragment = serializer.serializeFragment(slice.content)
|
||||
const div = document.createElement('div')
|
||||
div.appendChild(fragment)
|
||||
clipboardEvent.clipboardData.setData('text/html', div.innerHTML)
|
||||
clipboardEvent.clipboardData.setData('text/plain', lineText)
|
||||
if (!writeRichMarkdownSliceToClipboard(clipboardEvent.clipboardData, view, slice, lineText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
let tr = view.state.tr.delete(lineRange.from, lineRange.to)
|
||||
const clampedPos = Math.max(0, Math.min(lineRange.from, tr.doc.content.size))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import {
|
||||
Hooks,
|
||||
Lexer,
|
||||
Marked,
|
||||
Parser,
|
||||
Renderer,
|
||||
TextRenderer,
|
||||
Tokenizer,
|
||||
getDefaults,
|
||||
type MarkedOptions,
|
||||
type Token,
|
||||
type TokensList,
|
||||
marked
|
||||
} from 'marked'
|
||||
|
||||
export function createTiptapMarkedFacade(): typeof marked {
|
||||
const registry = new Marked()
|
||||
|
||||
// Why: Tiptap 3.22.5 registers on the injected instance but parses with
|
||||
// `new instance.Lexer()`, so the constructor must retain the private registry.
|
||||
class RegistryLexer extends Lexer {
|
||||
constructor(options?: MarkedOptions) {
|
||||
super({
|
||||
...registry.defaults,
|
||||
...options,
|
||||
extensions: registry.defaults.extensions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const parser = (tokens: Token[], options?: MarkedOptions) => registry.parser(tokens, options)
|
||||
const lexer = (src: string, options?: MarkedOptions): TokensList =>
|
||||
new RegistryLexer(options).lex(src)
|
||||
const facade = new Proxy(marked, {
|
||||
apply: (_target, _thisArg, args) => Reflect.apply(registry.parse, registry, args),
|
||||
get: (target, property, receiver) => {
|
||||
switch (property) {
|
||||
case 'defaults':
|
||||
return registry.defaults
|
||||
case 'getDefaults':
|
||||
return getDefaults
|
||||
case 'Lexer':
|
||||
return RegistryLexer
|
||||
case 'Parser':
|
||||
return Parser
|
||||
case 'Renderer':
|
||||
return Renderer
|
||||
case 'TextRenderer':
|
||||
return TextRenderer
|
||||
case 'Tokenizer':
|
||||
return Tokenizer
|
||||
case 'Hooks':
|
||||
return Hooks
|
||||
case 'parse':
|
||||
return facade
|
||||
case 'parseInline':
|
||||
return registry.parseInline
|
||||
case 'parser':
|
||||
return parser
|
||||
case 'lexer':
|
||||
return lexer
|
||||
case 'walkTokens':
|
||||
return registry.walkTokens.bind(registry)
|
||||
case 'use':
|
||||
return (...extensions: Parameters<typeof registry.use>) => {
|
||||
registry.use(...extensions)
|
||||
return facade
|
||||
}
|
||||
case 'setOptions':
|
||||
case 'options':
|
||||
return (options: MarkedOptions) => {
|
||||
registry.setOptions(options)
|
||||
return facade
|
||||
}
|
||||
default:
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
}
|
||||
}) satisfies typeof marked
|
||||
|
||||
return facade
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
const activateMarkdownLinkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (
|
||||
selector: (state: { activateMarkdownLink: typeof activateMarkdownLinkMock }) => unknown
|
||||
) => selector({ activateMarkdownLink: activateMarkdownLinkMock })
|
||||
}))
|
||||
|
||||
import { useLinkBubble } from './useLinkBubble'
|
||||
|
||||
describe('useLinkBubble owner hydration', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
activateMarkdownLinkMock.mockReset()
|
||||
})
|
||||
|
||||
it.each<HttpLinkSourceOwner>([{ kind: 'local' }, { kind: 'ssh', connectionId: 'ssh-1' }])(
|
||||
'refreshes an open Markdown bubble when ownership becomes $kind',
|
||||
(sourceOwner) => {
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
|
||||
function Harness(): React.JSX.Element {
|
||||
const rootRef = useRef<HTMLElement>(null)
|
||||
const [bubble, setBubble] = useState<LinkBubbleState | null>({
|
||||
kind: 'markdown',
|
||||
href: 'https://example.com',
|
||||
openEnabled: false,
|
||||
copyEnabled: true,
|
||||
left: 0,
|
||||
top: 0
|
||||
})
|
||||
const { handleLinkOpen } = useLinkBubble(null, rootRef, bubble, setBubble, () => {}, {
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
htmlSuperscriptLinkContext: context
|
||||
})
|
||||
return (
|
||||
<button data-enabled={String(bubble?.openEnabled)} onClick={handleLinkOpen}>
|
||||
{bubble?.href}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => root.render(<Harness />))
|
||||
const button = container.querySelector('button')
|
||||
if (!button) {
|
||||
throw new Error('Expected link action')
|
||||
}
|
||||
expect(button.dataset.enabled).toBe('false')
|
||||
act(() => button.click())
|
||||
expect(activateMarkdownLinkMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
context.update({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner
|
||||
})
|
||||
})
|
||||
expect(button.dataset.enabled).toBe('true')
|
||||
expect(button.textContent).toBe('https://example.com')
|
||||
|
||||
act(() => button.click())
|
||||
expect(activateMarkdownLinkMock).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({ sourceOwner })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { useAppStore } from '@/store'
|
||||
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
|
||||
|
||||
/**
|
||||
* Extracts link-editing action handlers from the editor component to reduce
|
||||
|
|
@ -21,14 +26,30 @@ export function useLinkBubble(
|
|||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}
|
||||
): {
|
||||
handleLinkSave: (href: string) => void
|
||||
handleLinkRemove: () => void
|
||||
handleLinkEditCancel: () => void
|
||||
handleLinkOpen: () => void
|
||||
handleLinkCopy: () => void
|
||||
toggleLinkFromToolbar: () => void
|
||||
} {
|
||||
const citationContextSnapshot = useSyncExternalStore(
|
||||
linkContext.htmlSuperscriptLinkContext.subscribe,
|
||||
linkContext.htmlSuperscriptLinkContext.getSnapshot,
|
||||
linkContext.htmlSuperscriptLinkContext.getSnapshot
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!linkBubble) {
|
||||
return
|
||||
}
|
||||
const openEnabled = classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
|
||||
if (openEnabled !== linkBubble.openEnabled) {
|
||||
setLinkBubble({ ...linkBubble, openEnabled })
|
||||
}
|
||||
}, [citationContextSnapshot, linkBubble, setLinkBubble])
|
||||
const startLinkEdit = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
|
|
@ -38,7 +59,13 @@ export function useLinkBubble(
|
|||
const href = editor.isActive('link')
|
||||
? (editor.getAttributes('link').href as string) || ''
|
||||
: ''
|
||||
setLinkBubble({ href, ...pos })
|
||||
setLinkBubble({
|
||||
kind: 'markdown',
|
||||
href,
|
||||
openEnabled: Boolean(href),
|
||||
copyEnabled: Boolean(href),
|
||||
...pos
|
||||
})
|
||||
setIsEditingLink(true)
|
||||
}
|
||||
}, [editor, rootRef, setLinkBubble, setIsEditingLink])
|
||||
|
|
@ -99,7 +126,11 @@ export function useLinkBubble(
|
|||
const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink)
|
||||
|
||||
const handleLinkOpen = useCallback(() => {
|
||||
if (!linkBubble?.href) {
|
||||
if (
|
||||
!linkBubble?.href ||
|
||||
!linkBubble.openEnabled ||
|
||||
!classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (linkBubble.href.startsWith('#')) {
|
||||
|
|
@ -110,11 +141,14 @@ export function useLinkBubble(
|
|||
sourceFilePath: linkContext.sourceFilePath,
|
||||
worktreeId: linkContext.worktreeId,
|
||||
worktreeRoot: linkContext.worktreeRoot,
|
||||
runtimeEnvironmentId: linkContext.runtimeEnvironmentId
|
||||
runtimeEnvironmentId: linkContext.runtimeEnvironmentId,
|
||||
sourceOwner: citationContextSnapshot.sourceOwner
|
||||
})
|
||||
}, [
|
||||
activateMarkdownLink,
|
||||
citationContextSnapshot,
|
||||
linkBubble?.href,
|
||||
linkBubble?.openEnabled,
|
||||
linkContext.sourceFilePath,
|
||||
linkContext.worktreeId,
|
||||
linkContext.worktreeRoot,
|
||||
|
|
@ -122,6 +156,13 @@ export function useLinkBubble(
|
|||
rootRef
|
||||
])
|
||||
|
||||
const handleLinkCopy = useCallback(() => {
|
||||
if (!linkBubble?.href || !linkBubble.copyEnabled) {
|
||||
return
|
||||
}
|
||||
void copyRichMarkdownLink(linkBubble.href)
|
||||
}, [linkBubble?.copyEnabled, linkBubble?.href])
|
||||
|
||||
const toggleLinkFromToolbar = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
|
|
@ -139,6 +180,7 @@ export function useLinkBubble(
|
|||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
handleLinkCopy,
|
||||
toggleLinkFromToolbar
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,21 @@ import {
|
|||
type EditorConfigParams
|
||||
} from './rich-markdown-editor-config'
|
||||
|
||||
const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true })
|
||||
|
||||
export function useRichMarkdownEditorInstance(params: EditorConfigParams): Editor | null {
|
||||
const extensions = useMemo(
|
||||
() =>
|
||||
createRichMarkdownExtensions({
|
||||
codec: params.codec,
|
||||
includePlaceholder: true,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: params.htmlSuperscriptLinkContext
|
||||
}),
|
||||
[params.codec, params.htmlSuperscriptLinkContext]
|
||||
)
|
||||
const editor = useEditor(
|
||||
useMemo(
|
||||
() => ({
|
||||
extensions: richMarkdownExtensions,
|
||||
extensions,
|
||||
...createRichMarkdownEditorConfig(params)
|
||||
}),
|
||||
// Dependencies are the same as the params object keys
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import {
|
|||
setRichMarkdownImageResolverContext,
|
||||
type RichMarkdownImageResolverSettings
|
||||
} from './rich-markdown-image-context'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
type RichMarkdownProgrammaticSyncOptions = {
|
||||
codec: RichMarkdownEditorCodec
|
||||
content: string
|
||||
docLinkMenuSetter: Dispatch<SetStateAction<DocLinkMenuState | null>>
|
||||
editor: Editor | null
|
||||
|
|
@ -36,6 +38,7 @@ type RichMarkdownEditorStorage = {
|
|||
}
|
||||
|
||||
export function useRichMarkdownProgrammaticSync({
|
||||
codec,
|
||||
content,
|
||||
docLinkMenuSetter,
|
||||
editor,
|
||||
|
|
@ -103,7 +106,7 @@ export function useRichMarkdownProgrammaticSync({
|
|||
}
|
||||
isApplyingProgrammaticUpdateRef.current = true
|
||||
try {
|
||||
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef)
|
||||
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec)
|
||||
} finally {
|
||||
isApplyingProgrammaticUpdateRef.current = false
|
||||
}
|
||||
|
|
@ -111,6 +114,7 @@ export function useRichMarkdownProgrammaticSync({
|
|||
syncDocLinkMenu(editor, rootRef.current, docLinkMenuSetter)
|
||||
}, [
|
||||
content,
|
||||
codec,
|
||||
docLinkMenuSetter,
|
||||
editor,
|
||||
fileId,
|
||||
|
|
@ -124,15 +128,19 @@ export function useRichMarkdownProgrammaticSync({
|
|||
function applyExternalRichMarkdownContent(
|
||||
editor: Editor,
|
||||
content: string,
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>,
|
||||
codec: RichMarkdownEditorCodec
|
||||
): void {
|
||||
try {
|
||||
const hadFocus = editor.isFocused
|
||||
const { from: prevFrom, to: prevTo } = editor.state.selection
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
editor.commands.setContent(
|
||||
encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
|
||||
{
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
}
|
||||
)
|
||||
// Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from
|
||||
// external content, matching onCreate's single-paragraph reflow behavior.
|
||||
normalizeEmptyListItems(editor)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ export function useRichMarkdownSearch({
|
|||
})
|
||||
}, [editor, isSearchOpen, matchCase, searchQuery, wholeWord])
|
||||
|
||||
// Why: mirror the guard used by replaceCurrentMatch/replaceAllMatches so the
|
||||
// disabled state never disagrees with what a click will actually do during the
|
||||
// debounce window when live matches diverge from the highlight set.
|
||||
const replaceDisabled = getLiveMatches().some((match) => match.touchesReadOnlyAtom)
|
||||
|
||||
// Clamp the user-controlled index to the valid range on every render.
|
||||
// No state update needed — this is a pure derivation.
|
||||
const activeMatchIndex =
|
||||
|
|
@ -160,7 +165,7 @@ export function useRichMarkdownSearch({
|
|||
const liveActiveMatchIndex =
|
||||
activeMatchIndex >= 0 && activeMatchIndex < liveMatches.length ? activeMatchIndex : 0
|
||||
const match = liveMatches[liveActiveMatchIndex]
|
||||
if (!match) {
|
||||
if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) {
|
||||
return
|
||||
}
|
||||
// Why: removing the active match shifts the next match into the same index,
|
||||
|
|
@ -173,7 +178,10 @@ export function useRichMarkdownSearch({
|
|||
return
|
||||
}
|
||||
const liveMatches = getLiveMatches()
|
||||
if (liveMatches.length === 0) {
|
||||
if (
|
||||
liveMatches.length === 0 ||
|
||||
liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tr = editor.state.tr
|
||||
|
|
@ -344,6 +352,7 @@ export function useRichMarkdownSearch({
|
|||
matchCase,
|
||||
matchCount,
|
||||
replaceQuery,
|
||||
replaceDisabled,
|
||||
searchQuery,
|
||||
searchInputRef,
|
||||
wholeWord
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
export function resolveRichMarkdownWorktreeRoot(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): string | null {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
return workspaceScope?.type === 'folder'
|
||||
? (state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId)
|
||||
?.folderPath ?? null)
|
||||
: (getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null)
|
||||
}
|
||||
|
||||
export function useRichMarkdownSuperscriptLinkSetup({
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
worktreeId
|
||||
}: {
|
||||
filePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
worktreeId: string
|
||||
}) {
|
||||
const worktreeRoot = useAppStore((state) => resolveRichMarkdownWorktreeRoot(state, worktreeId))
|
||||
const runtimeId = runtimeEnvironmentId?.trim()
|
||||
const connectionIdSelector = useMemo(() => {
|
||||
return createConnectionIdForFileSelector(worktreeId, filePath, { skip: Boolean(runtimeId) })
|
||||
}, [filePath, runtimeId, worktreeId])
|
||||
const connectionId = useAppStore(connectionIdSelector)
|
||||
const sourceOwner = useMemo<HttpLinkSourceOwner>(() => {
|
||||
if (runtimeId) {
|
||||
return { kind: 'runtime', runtimeEnvironmentId: runtimeId }
|
||||
}
|
||||
if (connectionId === undefined) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
return connectionId === null ? { kind: 'local' } : { kind: 'ssh', connectionId }
|
||||
}, [connectionId, runtimeId])
|
||||
const [codec] = useState(createRichMarkdownEditorCodec)
|
||||
const [context] = useState(() =>
|
||||
createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
sourceOwner
|
||||
})
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
context.update({ sourceFilePath: filePath, worktreeId, worktreeRoot, sourceOwner })
|
||||
}, [context, filePath, sourceOwner, worktreeId, worktreeRoot])
|
||||
return { codec, htmlSuperscriptLinkContext: context, worktreeRoot }
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
type LinkBubbleState
|
||||
} from '@/components/editor/RichMarkdownLinkBubble'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
|
||||
import { createRichMarkdownEditorCodec } from '@/components/editor/rich-markdown-source-transport'
|
||||
import { createEditableMarkdownLinkBubble } from '@/components/editor/rich-markdown-selected-link-actions'
|
||||
import { copyRichMarkdownLink } from '@/components/editor/rich-markdown-link-clipboard'
|
||||
import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize'
|
||||
import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane'
|
||||
import {
|
||||
|
|
@ -71,6 +74,7 @@ export function GitHubMarkdownComposer({
|
|||
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(null)
|
||||
const [isEditingLink, setIsEditingLink] = useState(false)
|
||||
const isTabbed = layout === 'tabbed'
|
||||
const codec = useMemo(() => createRichMarkdownEditorCodec(), [])
|
||||
|
||||
const {
|
||||
imageUrl,
|
||||
|
|
@ -89,13 +93,13 @@ export function GitHubMarkdownComposer({
|
|||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...createRichMarkdownExtensions(),
|
||||
...createRichMarkdownExtensions({ codec }),
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder
|
||||
})
|
||||
],
|
||||
[placeholder]
|
||||
[codec, placeholder]
|
||||
)
|
||||
|
||||
const openLinkEditor = useCallback(() => {
|
||||
|
|
@ -109,7 +113,7 @@ export function GitHubMarkdownComposer({
|
|||
return
|
||||
}
|
||||
const href = editor.isActive('link') ? String(editor.getAttributes('link').href ?? '') : ''
|
||||
setLinkBubble({ href, ...position })
|
||||
setLinkBubble(createEditableMarkdownLinkBubble(href, position))
|
||||
setIsEditingLink(true)
|
||||
}, [])
|
||||
|
||||
|
|
@ -117,7 +121,7 @@ export function GitHubMarkdownComposer({
|
|||
immediatelyRender: false,
|
||||
extensions,
|
||||
editable: !disabled,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
|
|
@ -177,10 +181,12 @@ export function GitHubMarkdownComposer({
|
|||
if (nextEditor.isActive('link')) {
|
||||
const position = getLinkBubblePosition(nextEditor, rootRef.current)
|
||||
if (position) {
|
||||
setLinkBubble({
|
||||
href: String(nextEditor.getAttributes('link').href ?? ''),
|
||||
...position
|
||||
})
|
||||
setLinkBubble(
|
||||
createEditableMarkdownLinkBubble(
|
||||
String(nextEditor.getAttributes('link').href ?? ''),
|
||||
position
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +230,7 @@ export function GitHubMarkdownComposer({
|
|||
}
|
||||
applyingExternalValueRef.current = true
|
||||
try {
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
|
|
@ -233,7 +239,7 @@ export function GitHubMarkdownComposer({
|
|||
} finally {
|
||||
applyingExternalValueRef.current = false
|
||||
}
|
||||
}, [editor, value])
|
||||
}, [codec, editor, value])
|
||||
|
||||
const handleLinkSave = useCallback((href: string) => {
|
||||
const editor = editorRef.current
|
||||
|
|
@ -403,6 +409,7 @@ export function GitHubMarkdownComposer({
|
|||
editorRef.current?.commands.focus()
|
||||
}}
|
||||
onOpen={handleLinkOpen}
|
||||
onCopy={() => void copyRichMarkdownLink(linkBubble.href)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11605,7 +11605,8 @@
|
|||
"1c99b726e0": "Remove link",
|
||||
"cdfe166f6f": "Edit link",
|
||||
"bfc813e909": "Open link",
|
||||
"7b0b945fdc": "Paste or type a link…"
|
||||
"7b0b945fdc": "Paste or type a link…",
|
||||
"copyLink": "Copy link"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "This note",
|
||||
|
|
@ -11632,7 +11633,8 @@
|
|||
"68d090241d": "Match whole word",
|
||||
"fd97c7e585": "Replace",
|
||||
"44682b4159": "Replace in rich markdown editor",
|
||||
"c2884f5e95": "Replace all"
|
||||
"c2884f5e95": "Replace all",
|
||||
"preservedRichContentReadOnly": "Preserved rich content is read-only in rich mode."
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "No blocks found",
|
||||
|
|
@ -11849,6 +11851,24 @@
|
|||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.",
|
||||
"citationFallbackLabel": "Citation",
|
||||
"citationLinkUnavailable": "{{value0}}, citation link unavailable. {{value1}}",
|
||||
"tabForCitationActions": "Tab for available actions.",
|
||||
"noCitationActions": "No link actions are available."
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}, link to {{value1}}",
|
||||
"unavailableAriaLabel": "{{value0}}, citation link unavailable"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "Copied link",
|
||||
"copyLinkFailed": "Failed to copy link"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "Select less content or use code mode to cut preserved HTML citations."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11605,7 +11605,8 @@
|
|||
"1c99b726e0": "Quitar enlace",
|
||||
"cdfe166f6f": "Editar enlace",
|
||||
"bfc813e909": "Abrir enlace",
|
||||
"7b0b945fdc": "Pega o escribe un enlace…"
|
||||
"7b0b945fdc": "Pega o escribe un enlace…",
|
||||
"copyLink": "Copiar enlace"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "Esta nota",
|
||||
|
|
@ -11632,7 +11633,8 @@
|
|||
"68d090241d": "Coincidir con toda la palabra",
|
||||
"fd97c7e585": "Reemplazar",
|
||||
"44682b4159": "Reemplazar en el editor de markdown enriquecido",
|
||||
"c2884f5e95": "Reemplazar todo"
|
||||
"c2884f5e95": "Reemplazar todo",
|
||||
"preservedRichContentReadOnly": "El contenido enriquecido conservado es de solo lectura en el modo enriquecido."
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "No se encontraron bloques",
|
||||
|
|
@ -11849,6 +11851,24 @@
|
|||
"3fa2b8d417": "Recargar desde disco",
|
||||
"a95d02c644": "Mantener mis ediciones",
|
||||
"2c8f1e07b9": "Cargando comparación..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}, enlace a {{value1}}. Pulsa Intro para abrirlo o Tab para las acciones del enlace.",
|
||||
"citationFallbackLabel": "Cita",
|
||||
"citationLinkUnavailable": "{{value0}}, enlace de cita no disponible. {{value1}}",
|
||||
"tabForCitationActions": "Pulsa Tab para ver las acciones disponibles.",
|
||||
"noCitationActions": "No hay acciones de enlace disponibles."
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}, enlace a {{value1}}",
|
||||
"unavailableAriaLabel": "{{value0}}, enlace de cita no disponible"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "Enlace copiado",
|
||||
"copyLinkFailed": "No se pudo copiar el enlace"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "Selecciona menos contenido o usa el modo de código para cortar citas HTML conservadas."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11605,7 +11605,8 @@
|
|||
"1c99b726e0": "リンクを削除する",
|
||||
"cdfe166f6f": "リンクを編集",
|
||||
"bfc813e909": "リンクを開く",
|
||||
"7b0b945fdc": "リンクを貼り付けるか入力してください…"
|
||||
"7b0b945fdc": "リンクを貼り付けるか入力してください…",
|
||||
"copyLink": "リンクをコピー"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "このメモ",
|
||||
|
|
@ -11632,7 +11633,8 @@
|
|||
"68d090241d": "単語全体に一致",
|
||||
"fd97c7e585": "置換",
|
||||
"44682b4159": "リッチ markdown エディタで置換",
|
||||
"c2884f5e95": "すべて置換"
|
||||
"c2884f5e95": "すべて置換",
|
||||
"preservedRichContentReadOnly": "保持されたリッチコンテンツはリッチモードでは読み取り専用です。"
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "ブロックが見つかりませんでした",
|
||||
|
|
@ -11849,6 +11851,24 @@
|
|||
"3fa2b8d417": "ディスクから再読み込み",
|
||||
"a95d02c644": "編集内容を保持",
|
||||
"2c8f1e07b9": "比較を読み込み中..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}、{{value1}}へのリンク。Enterキーで開くか、Tabキーでリンク操作に移動します。",
|
||||
"citationFallbackLabel": "引用",
|
||||
"citationLinkUnavailable": "{{value0}}、引用リンクは利用できません。{{value1}}",
|
||||
"tabForCitationActions": "Tabキーで利用可能な操作に移動します。",
|
||||
"noCitationActions": "利用できるリンク操作はありません。"
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}、{{value1}}へのリンク",
|
||||
"unavailableAriaLabel": "{{value0}}、引用リンクは利用できません"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "リンクをコピーしました",
|
||||
"copyLinkFailed": "リンクをコピーできませんでした"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "保持されたHTML引用を切り取るには、選択範囲を小さくするかコードモードを使用してください。"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11605,7 +11605,8 @@
|
|||
"1c99b726e0": "링크 삭제",
|
||||
"cdfe166f6f": "링크 수정",
|
||||
"bfc813e909": "링크 열기",
|
||||
"7b0b945fdc": "링크를 붙여넣거나 입력하세요…"
|
||||
"7b0b945fdc": "링크를 붙여넣거나 입력하세요…",
|
||||
"copyLink": "링크 복사"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "이 메모",
|
||||
|
|
@ -11632,7 +11633,8 @@
|
|||
"68d090241d": "전체 단어 일치",
|
||||
"fd97c7e585": "바꾸기",
|
||||
"44682b4159": "리치 markdown 편집기에서 바꾸기",
|
||||
"c2884f5e95": "모두 바꾸기"
|
||||
"c2884f5e95": "모두 바꾸기",
|
||||
"preservedRichContentReadOnly": "보존된 리치 콘텐츠는 리치 모드에서 읽기 전용입니다."
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "블록을 찾을 수 없습니다.",
|
||||
|
|
@ -11849,6 +11851,24 @@
|
|||
"3fa2b8d417": "디스크에서 다시 로드",
|
||||
"a95d02c644": "편집 내용 유지",
|
||||
"2c8f1e07b9": "비교 로딩 중..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}, {{value1}} 링크. Enter 키로 열거나 Tab 키로 링크 작업으로 이동하세요.",
|
||||
"citationFallbackLabel": "인용",
|
||||
"citationLinkUnavailable": "{{value0}}, 인용 링크를 사용할 수 없습니다. {{value1}}",
|
||||
"tabForCitationActions": "Tab 키로 사용 가능한 작업으로 이동하세요.",
|
||||
"noCitationActions": "사용 가능한 링크 작업이 없습니다."
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}, {{value1}} 링크",
|
||||
"unavailableAriaLabel": "{{value0}}, 인용 링크를 사용할 수 없음"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "링크를 복사했습니다",
|
||||
"copyLinkFailed": "링크를 복사하지 못했습니다"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "보존된 HTML 인용을 잘라내려면 더 적은 콘텐츠를 선택하거나 코드 모드를 사용하세요."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11605,7 +11605,8 @@
|
|||
"1c99b726e0": "删除链接",
|
||||
"cdfe166f6f": "编辑链接",
|
||||
"bfc813e909": "打开链接",
|
||||
"7b0b945fdc": "粘贴或输入链接..."
|
||||
"7b0b945fdc": "粘贴或输入链接...",
|
||||
"copyLink": "复制链接"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "此注",
|
||||
|
|
@ -11632,7 +11633,8 @@
|
|||
"68d090241d": "匹配整个单词",
|
||||
"fd97c7e585": "替换",
|
||||
"44682b4159": "在 Markdown 编辑器中替换",
|
||||
"c2884f5e95": "全部替换"
|
||||
"c2884f5e95": "全部替换",
|
||||
"preservedRichContentReadOnly": "保留的富文本内容在富文本模式下为只读。"
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "没有找到块",
|
||||
|
|
@ -11849,6 +11851,24 @@
|
|||
"3fa2b8d417": "从磁盘重新加载",
|
||||
"a95d02c644": "保留我的编辑",
|
||||
"2c8f1e07b9": "正在加载比较..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}},链接到 {{value1}}。按 Enter 打开,或按 Tab 移至链接操作。",
|
||||
"citationFallbackLabel": "引用",
|
||||
"citationLinkUnavailable": "{{value0}},引用链接不可用。{{value1}}",
|
||||
"tabForCitationActions": "按 Tab 移至可用操作。",
|
||||
"noCitationActions": "没有可用的链接操作。"
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}},链接到 {{value1}}",
|
||||
"unavailableAriaLabel": "{{value0}},引用链接不可用"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "已复制链接",
|
||||
"copyLinkFailed": "复制链接失败"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "请选择较少的内容,或使用代码模式剪切保留的 HTML 引用。"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from './connection-context'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { createConnectionIdForFileSelector } from './connection-owner-resolution'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
|
|
@ -581,4 +582,24 @@ describe('getConnectionIdFromState', () => {
|
|||
|
||||
expect(getConnectionIdFromState(state, null)).toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes a retained file-owner selector when ownership slices hydrate', () => {
|
||||
const selector = createConnectionIdForFileSelector(
|
||||
'repo-ssh::/home/neil/repo-feature',
|
||||
'/home/neil/repo-feature/README.md'
|
||||
)
|
||||
const unresolved: ConnectionContextState = {
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
}
|
||||
expect(selector(unresolved)).toBeUndefined()
|
||||
|
||||
const hydrated: ConnectionContextState = {
|
||||
...unresolved,
|
||||
repos: [makeRepo({ id: 'repo-ssh', connectionId: 'ssh-hydrated' })]
|
||||
}
|
||||
expect(selector(hydrated)).toBe('ssh-hydrated')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../shared/cross-platform-path'
|
||||
import {
|
||||
getFolderWorkspaceCandidateRepos,
|
||||
getFolderWorkspaceConnectionId
|
||||
} from './folder-workspace-connection'
|
||||
getConnectionIdForFileFromState,
|
||||
getConnectionIdFromState
|
||||
} from './connection-owner-resolution'
|
||||
|
||||
export { getConnectionIdFromState } from './connection-owner-resolution'
|
||||
|
||||
/**
|
||||
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
|
||||
|
|
@ -22,33 +16,6 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
|
|||
return getConnectionIdFromState(useAppStore.getState(), worktreeId)
|
||||
}
|
||||
|
||||
export function getConnectionIdFromState(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
|
||||
worktreeId: string | null
|
||||
): string | null | undefined {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return null
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
|
||||
}
|
||||
// Why: retained Zustand selectors call this on unrelated writes; reuse the
|
||||
// immutable-slice indexes instead of flattening every worktree each time.
|
||||
const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
|
||||
// Why: SSH worktrees can be restored from session IDs before relay discovery
|
||||
// repopulates worktreesByRepo. The composite ID still carries the repo ID.
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = getIndexedRepoMap(state.repos).get(repoId)
|
||||
if (!repo) {
|
||||
return undefined
|
||||
}
|
||||
return repo.connectionId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when we can determine the owning host (local vs. a specific SSH target)
|
||||
* for a worktree. False means the backing repo has not landed in the store yet
|
||||
|
|
@ -75,41 +42,5 @@ export function getConnectionIdForFile(
|
|||
worktreeId: string | null,
|
||||
filePath: string
|
||||
): string | null | undefined {
|
||||
const connectionId = getConnectionId(worktreeId)
|
||||
if (connectionId !== undefined || !worktreeId) {
|
||||
return connectionId
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type !== 'folder') {
|
||||
return undefined
|
||||
}
|
||||
// Why: mixed local/SSH folder workspaces cannot pick one owner globally, but
|
||||
// a concrete file path can still belong unambiguously to a child repo.
|
||||
const state = useAppStore.getState()
|
||||
const candidateRepos = getFolderWorkspaceCandidateRepos(
|
||||
state,
|
||||
parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
return resolveConnectionIdForRepoPath(candidateRepos, filePath)
|
||||
}
|
||||
|
||||
function resolveConnectionIdForRepoPath(
|
||||
repos: readonly { path: string; connectionId?: string | null }[],
|
||||
filePath: string
|
||||
): string | null | undefined {
|
||||
const matchingRepos = repos
|
||||
.filter((repo) => isPathInsideOrEqual(repo.path, filePath))
|
||||
.map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
|
||||
.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)
|
||||
const longestPathLength = matchingRepos[0]?.normalizedPath.length
|
||||
if (!longestPathLength) {
|
||||
return undefined
|
||||
}
|
||||
// Why: containment normalizes separators/trailing slashes; ambiguity checks
|
||||
// need the same representation or equal repo roots can be hidden.
|
||||
const bestMatches = matchingRepos.filter(
|
||||
(candidate) => candidate.normalizedPath.length === longestPathLength
|
||||
)
|
||||
const connectionIds = new Set(bestMatches.map(({ repo }) => repo.connectionId ?? null))
|
||||
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
|
||||
return getConnectionIdForFileFromState(useAppStore.getState(), worktreeId, filePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import type { AppState } from '@/store/types'
|
||||
import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../shared/cross-platform-path'
|
||||
import {
|
||||
getFolderWorkspaceCandidateRepos,
|
||||
getFolderWorkspaceConnectionId
|
||||
} from './folder-workspace-connection'
|
||||
|
||||
type ConnectionOwnerState = Pick<
|
||||
AppState,
|
||||
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
|
||||
>
|
||||
|
||||
export function createConnectionIdForFileSelector(
|
||||
worktreeId: string | null,
|
||||
filePath: string,
|
||||
{ skip = false }: { skip?: boolean } = {}
|
||||
): (state: ConnectionOwnerState) => string | null | undefined {
|
||||
let previousSlices: ConnectionOwnerState | null = null
|
||||
let previousResult: string | null | undefined
|
||||
return (state) => {
|
||||
if (skip) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
previousSlices?.folderWorkspaces === state.folderWorkspaces &&
|
||||
previousSlices.projectGroups === state.projectGroups &&
|
||||
previousSlices.repos === state.repos &&
|
||||
previousSlices.worktreesByRepo === state.worktreesByRepo
|
||||
) {
|
||||
return previousResult
|
||||
}
|
||||
previousSlices = {
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
projectGroups: state.projectGroups,
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo
|
||||
}
|
||||
previousResult = getConnectionIdForFileFromState(state, worktreeId, filePath)
|
||||
return previousResult
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectionIdFromState(
|
||||
state: ConnectionOwnerState,
|
||||
worktreeId: string | null
|
||||
): string | null | undefined {
|
||||
if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return null
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
|
||||
}
|
||||
// Why: owner resolution runs from retained Zustand selectors, so unrelated
|
||||
// store writes must not flatten every worktree or scan every repository.
|
||||
const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = getIndexedRepoMap(state.repos).get(repoId)
|
||||
return repo ? (repo.connectionId ?? null) : undefined
|
||||
}
|
||||
|
||||
export function getConnectionIdForFileFromState(
|
||||
state: ConnectionOwnerState,
|
||||
worktreeId: string | null,
|
||||
filePath: string
|
||||
): string | null | undefined {
|
||||
const connectionId = getConnectionIdFromState(state, worktreeId)
|
||||
if (connectionId !== undefined || !worktreeId) {
|
||||
return connectionId
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type !== 'folder') {
|
||||
return undefined
|
||||
}
|
||||
const candidateRepos = getFolderWorkspaceCandidateRepos(
|
||||
state,
|
||||
parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
const matchingRepos = candidateRepos
|
||||
.filter((repo) => isPathInsideOrEqual(repo.path, filePath))
|
||||
.map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
|
||||
.sort((left, right) => right.normalizedPath.length - left.normalizedPath.length)
|
||||
const longestPathLength = matchingRepos[0]?.normalizedPath.length
|
||||
if (!longestPathLength) {
|
||||
return undefined
|
||||
}
|
||||
const connectionIds = new Set(
|
||||
matchingRepos
|
||||
.filter((candidate) => candidate.normalizedPath.length === longestPathLength)
|
||||
.map(({ repo }) => repo.connectionId ?? null)
|
||||
)
|
||||
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
|
||||
}
|
||||
|
|
@ -32,12 +32,14 @@ const storeState = {
|
|||
allWorktrees: vi.fn(
|
||||
() => [] as { id: string; projectId?: string; repoId?: string; displayName?: string }[]
|
||||
),
|
||||
workspacePortScan: null as { result: WorkspacePortScanResult } | null
|
||||
workspacePortScan: null as { result: WorkspacePortScanResult } | null,
|
||||
workspacePortScansByKey: {} as Record<string, WorkspacePortScanResult>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeState.settings = undefined
|
||||
storeState.workspacePortScansByKey = {}
|
||||
registerHttpLinkStoreAccessor(() => storeState)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
|
|
@ -110,6 +112,106 @@ describe('openHttpLink', () => {
|
|||
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors an explicit local document owner despite an unrelated active runtime', () => {
|
||||
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-other' }
|
||||
|
||||
openHttpLink('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes explicit runtime and SSH document owners to the exact system URL', () => {
|
||||
storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true }
|
||||
|
||||
openHttpLink('http://localhost:5180/runtime', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }
|
||||
})
|
||||
openHttpLink('http://localhost:5180/ssh', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
|
||||
})
|
||||
|
||||
expect(openUrlMock).toHaveBeenNthCalledWith(1, 'http://localhost:5180/runtime')
|
||||
expect(openUrlMock).toHaveBeenNthCalledWith(2, 'http://localhost:5180/ssh')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels explicit local links from the local scan instead of a merged remote port', async () => {
|
||||
storeState.settings = {
|
||||
openLinksInApp: true,
|
||||
activeRuntimeEnvironmentId: 'env-other',
|
||||
localhostWorktreeLabelsEnabled: true
|
||||
}
|
||||
storeState.repos = [
|
||||
{ id: 'repo-local', displayName: 'Local' },
|
||||
{ id: 'repo-remote', displayName: 'Remote' }
|
||||
]
|
||||
storeState.worktreesByRepo = {
|
||||
'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }],
|
||||
'repo-remote': [{ id: 'wt-remote', projectId: 'repo-remote' }]
|
||||
}
|
||||
const port = (repoId: string, worktreeId: string, path: string) => ({
|
||||
id: `tcp:5180:${worktreeId}`,
|
||||
kind: 'workspace' as const,
|
||||
port: 5180,
|
||||
protocol: 'http' as const,
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: 'localhost',
|
||||
owner: {
|
||||
repoId,
|
||||
worktreeId,
|
||||
displayName: worktreeId,
|
||||
path,
|
||||
confidence: 'cwd' as const
|
||||
}
|
||||
})
|
||||
storeState.workspacePortScan = {
|
||||
result: {
|
||||
platform: 'darwin',
|
||||
scannedAt: 2,
|
||||
ports: [port('repo-remote', 'wt-remote', '/remote')]
|
||||
}
|
||||
}
|
||||
storeState.workspacePortScansByKey = {
|
||||
'local:all': {
|
||||
platform: 'darwin',
|
||||
scannedAt: 1,
|
||||
ports: [port('repo-local', 'wt-local', '/local')]
|
||||
}
|
||||
}
|
||||
registerLocalhostLabelMock.mockResolvedValue({ url: 'http://wt-local.orca.localhost:60016/' })
|
||||
|
||||
openHttpLink('http://localhost:5180/', {
|
||||
worktreeId: 'wt-local',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(registerLocalhostLabelMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repoId: 'repo-local', worktreeId: 'wt-local' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps unresolved document ownership non-actionable', () => {
|
||||
storeState.settings = { openLinksInApp: true }
|
||||
|
||||
openHttpLink('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes to the system browser when no worktree id is provided', () => {
|
||||
storeState.settings = { openLinksInApp: true }
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,15 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor
|
|||
export type OpenHttpLinkOptions = {
|
||||
worktreeId?: string | null
|
||||
forceSystemBrowser?: boolean
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
}
|
||||
|
||||
export type HttpLinkSourceOwner =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'runtime'; runtimeEnvironmentId: string }
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
type StoreAccessor = () => {
|
||||
settings?: Partial<
|
||||
Pick<
|
||||
|
|
@ -25,6 +32,7 @@ type StoreAccessor = () => {
|
|||
worktreesByRepo?: Record<string, LocalhostLinkWorktree[]>
|
||||
allWorktrees?: () => LocalhostLinkWorktree[]
|
||||
workspacePortScan?: { result: WorkspacePortScanResult } | null
|
||||
workspacePortScansByKey?: Record<string, WorkspacePortScanResult>
|
||||
}
|
||||
|
||||
type LocalhostLinkRepo = {
|
||||
|
|
@ -56,11 +64,15 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void {
|
|||
// branch). Shift+Cmd/Ctrl is the escape hatch: callers pass forceSystemBrowser
|
||||
// to bypass the setting entirely.
|
||||
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
|
||||
const { worktreeId, forceSystemBrowser } = opts
|
||||
const { worktreeId, forceSystemBrowser, sourceOwner } = opts
|
||||
if (sourceOwner?.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const state = storeAccessor?.()
|
||||
const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim())
|
||||
const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive
|
||||
const routeToOrca =
|
||||
!remoteRuntimeActive &&
|
||||
sourceIsLocal &&
|
||||
!forceSystemBrowser &&
|
||||
Boolean(worktreeId) &&
|
||||
state?.settings?.openLinksInApp === true
|
||||
|
|
@ -75,7 +87,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
|||
// to the global activeWorktreeId deselects the real repo workspace.
|
||||
state.setActiveWorktree(worktreeId)
|
||||
}
|
||||
const localhostRoute = localhostLabelRouteForTerminalLink(url, state)
|
||||
const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner)
|
||||
if (!localhostRoute) {
|
||||
state.createBrowserTab(worktreeId, url, { activate: true })
|
||||
return
|
||||
|
|
@ -86,7 +98,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
|||
return
|
||||
}
|
||||
|
||||
const localhostRoute = state ? localhostLabelRouteForTerminalLink(url, state) : null
|
||||
const localhostRoute = state ? localhostLabelRouteForHttpLink(url, state, sourceOwner) : null
|
||||
if (!localhostRoute) {
|
||||
void window.api.shell.openUrl(url)
|
||||
return
|
||||
|
|
@ -96,6 +108,24 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
|||
})
|
||||
}
|
||||
|
||||
function localhostLabelRouteForHttpLink(
|
||||
url: string,
|
||||
state: ReturnType<StoreAccessor>,
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
): LocalhostWorktreeLabelRoute | null {
|
||||
if (sourceOwner && sourceOwner.kind !== 'local') {
|
||||
return null
|
||||
}
|
||||
if (!sourceOwner && state.settings?.activeRuntimeEnvironmentId?.trim()) {
|
||||
return null
|
||||
}
|
||||
const sourceScan =
|
||||
sourceOwner?.kind === 'local'
|
||||
? (state.workspacePortScansByKey?.['local:all'] ?? null)
|
||||
: undefined
|
||||
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
|
||||
}
|
||||
|
||||
export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise<string | null> {
|
||||
const state = storeAccessor?.()
|
||||
if (!state) {
|
||||
|
|
@ -128,11 +158,13 @@ async function openLabeledLocalhostLink(
|
|||
|
||||
function localhostLabelRouteForTerminalLink(
|
||||
rawUrl: string,
|
||||
state: ReturnType<StoreAccessor>
|
||||
state: ReturnType<StoreAccessor>,
|
||||
ignoreActiveRuntime = false,
|
||||
sourceScan?: WorkspacePortScanResult | null
|
||||
): LocalhostWorktreeLabelRoute | null {
|
||||
if (
|
||||
state.settings?.localhostWorktreeLabelsEnabled !== true ||
|
||||
state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
(!ignoreActiveRuntime && state.settings?.activeRuntimeEnvironmentId?.trim())
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -142,7 +174,8 @@ function localhostLabelRouteForTerminalLink(
|
|||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
const port = findWorkspacePortByNumber(state.workspacePortScan?.result, Number(parsed.port))
|
||||
const scan = sourceScan === undefined ? state.workspacePortScan?.result : sourceScan
|
||||
const port = findWorkspacePortByNumber(scan, Number(parsed.port))
|
||||
if (!port) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ function createEditorStore(): StoreApi<AppState> {
|
|||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
|
|
@ -62,6 +66,10 @@ function createEditorTabsStore(): StoreApi<AppState> {
|
|||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
...createTabsSlice(...(args as Parameters<typeof createTabsSlice>)),
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
|
|
@ -4003,6 +4011,47 @@ describe('createEditorSlice activateMarkdownLink', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('rejects ambiguous same-path owner fallback and honors an explicit source owner', async () => {
|
||||
const store = createEditorStore()
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/docs/note.md',
|
||||
relativePath: 'docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: 'env-source',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/docs/note.md',
|
||||
relativePath: 'docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
},
|
||||
{ suppressActiveRuntimeFallback: true }
|
||||
)
|
||||
|
||||
await store.getState().activateMarkdownLink('https://example.com', {
|
||||
sourceFilePath: '/repo/docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
})
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
|
||||
await store.getState().activateMarkdownLink('https://example.com', {
|
||||
sourceFilePath: '/repo/docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
})
|
||||
|
||||
it('stats SSH markdown links through the source worktree connection before opening', async () => {
|
||||
const store = createEditorStore()
|
||||
pathExistsMock.mockResolvedValue(true)
|
||||
|
|
@ -4268,11 +4317,38 @@ describe('createEditorSlice activateMarkdownLink', () => {
|
|||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
})
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', { worktreeId: 'wt-1' })
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().openFiles).toEqual([])
|
||||
})
|
||||
|
||||
it('does not rescan legacy owner state when the source owner is explicit', async () => {
|
||||
const store = createEditorStore()
|
||||
for (const key of ['openFiles', 'repos', 'worktreesByRepo', 'folderWorkspaces'] as const) {
|
||||
Object.defineProperty(store.getState(), key, {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new Error(`explicit owner must not read ${key}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await store.getState().activateMarkdownLink('https://example.com', {
|
||||
sourceFilePath: '/repo/docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens in-worktree file links in Orca', async () => {
|
||||
const store = createEditorStore()
|
||||
await store.getState().activateMarkdownLink('./image.png', {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
getCheckRunDetailsTabLabel,
|
||||
type OpenCheckRunDetailsState
|
||||
} from '@/components/editor/check-run-details-tab'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import type {
|
||||
|
|
@ -500,6 +501,7 @@ export type EditorSlice = {
|
|||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
}
|
||||
) => Promise<void>
|
||||
openMarkdownPreview: (
|
||||
|
|
@ -1335,13 +1337,6 @@ function shouldDeleteUntouchedUntitledFile(file: OpenFile | undefined, hasDraft:
|
|||
)
|
||||
}
|
||||
|
||||
function getWorktreeConnectionId(state: AppState, worktreeId: string): string | undefined {
|
||||
const worktree = findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = (state.repos ?? []).find((candidate) => candidate.id === repoId)
|
||||
return repo?.connectionId ?? undefined
|
||||
}
|
||||
|
||||
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
|
||||
editorDrafts: {},
|
||||
setEditorDraft: (fileId, content) =>
|
||||
|
|
@ -4170,16 +4165,48 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
|
||||
activateMarkdownLink: async (rawHref, ctx) => {
|
||||
const initialState = get()
|
||||
let inferredRuntimeEnvironmentId: string | null | undefined
|
||||
if (!ctx.sourceOwner && ctx.runtimeEnvironmentId === undefined) {
|
||||
const inferredRuntimeOwners = new Set(
|
||||
initialState.openFiles
|
||||
.filter(
|
||||
(file) => file.filePath === ctx.sourceFilePath && file.worktreeId === ctx.worktreeId
|
||||
)
|
||||
.map((file) => file.runtimeEnvironmentId?.trim() || null)
|
||||
)
|
||||
if (inferredRuntimeOwners.size > 1) {
|
||||
return
|
||||
}
|
||||
inferredRuntimeEnvironmentId =
|
||||
inferredRuntimeOwners.size === 1 ? [...inferredRuntimeOwners][0] : undefined
|
||||
}
|
||||
const sourceRuntimeEnvironmentId =
|
||||
ctx.runtimeEnvironmentId !== undefined
|
||||
? ctx.runtimeEnvironmentId
|
||||
: initialState.openFiles.find((file) => file.filePath === ctx.sourceFilePath)
|
||||
?.runtimeEnvironmentId
|
||||
const sourceSettings = settingsForRuntimeOwner(
|
||||
initialState.settings,
|
||||
sourceRuntimeEnvironmentId
|
||||
)
|
||||
const sourceConnectionId = getWorktreeConnectionId(initialState, ctx.worktreeId)
|
||||
ctx.sourceOwner?.kind === 'runtime'
|
||||
? ctx.sourceOwner.runtimeEnvironmentId
|
||||
: ctx.sourceOwner
|
||||
? null
|
||||
: ctx.runtimeEnvironmentId !== undefined
|
||||
? ctx.runtimeEnvironmentId
|
||||
: inferredRuntimeEnvironmentId
|
||||
const runtimeOwnerId = sourceRuntimeEnvironmentId?.trim() || null
|
||||
const sourceSettings = settingsForRuntimeOwner(initialState.settings, runtimeOwnerId)
|
||||
const resolvedConnectionId =
|
||||
ctx.sourceOwner || runtimeOwnerId
|
||||
? undefined
|
||||
: getConnectionIdForFileFromState(initialState, ctx.worktreeId, ctx.sourceFilePath)
|
||||
const sourceOwner: HttpLinkSourceOwner =
|
||||
ctx.sourceOwner ??
|
||||
(runtimeOwnerId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
|
||||
: resolvedConnectionId === undefined
|
||||
? { kind: 'unknown' }
|
||||
: resolvedConnectionId === null
|
||||
? { kind: 'local' }
|
||||
: { kind: 'ssh', connectionId: resolvedConnectionId })
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const sourceConnectionId = sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
|
||||
const fileContext = {
|
||||
settings: sourceSettings,
|
||||
worktreeId: ctx.worktreeId,
|
||||
|
|
@ -4194,7 +4221,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
return
|
||||
}
|
||||
if (target.kind === 'external') {
|
||||
openHttpLink(target.url, { worktreeId: ctx.worktreeId })
|
||||
openHttpLink(target.url, { worktreeId: ctx.worktreeId, sourceOwner })
|
||||
return
|
||||
}
|
||||
if (target.kind === 'file') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue