mirror of https://github.com/usememos/memos.git
Merge 355fd9e7be into 63a17d897d
This commit is contained in:
commit
aa7e74d1e6
|
|
@ -22,6 +22,7 @@ pnpm dev # Dev server (:3001, proxies API to :8081)
|
|||
pnpm lint # Type check + Biome lint
|
||||
pnpm lint:fix # Auto-fix lint issues
|
||||
pnpm format # Format code
|
||||
pnpm test # Run frontend tests
|
||||
pnpm build # Production build
|
||||
pnpm release # Build to server/router/frontend/dist
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
|
||||
"lint": "tsc --noEmit --skipLibCheck && biome check src",
|
||||
"lint:fix": "biome check --write src",
|
||||
"format": "biome format --write src"
|
||||
"format": "biome format --write src",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
|
|
@ -92,7 +94,8 @@
|
|||
"terser": "^5.46.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ importers:
|
|||
vite:
|
||||
specifier: ^7.2.4
|
||||
version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
|
||||
vitest:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(@types/node@24.10.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -1249,6 +1252,9 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tailwindcss/node@4.2.1':
|
||||
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
|
||||
|
||||
|
|
@ -1372,6 +1378,9 @@ packages:
|
|||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
|
|
@ -1468,6 +1477,9 @@ packages:
|
|||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||
|
||||
|
|
@ -1548,6 +1560,35 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/expect@4.1.1':
|
||||
resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==}
|
||||
|
||||
'@vitest/mocker@4.1.1':
|
||||
resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.1':
|
||||
resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==}
|
||||
|
||||
'@vitest/runner@4.1.1':
|
||||
resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==}
|
||||
|
||||
'@vitest/snapshot@4.1.1':
|
||||
resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==}
|
||||
|
||||
'@vitest/spy@4.1.1':
|
||||
resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==}
|
||||
|
||||
'@vitest/utils@4.1.1':
|
||||
resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==}
|
||||
|
||||
'@xobotyi/scrollbar-width@1.9.5':
|
||||
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
|
||||
|
||||
|
|
@ -1560,6 +1601,10 @@ packages:
|
|||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||
engines: {node: '>=10', npm: '>=6'}
|
||||
|
|
@ -1590,6 +1635,10 @@ packages:
|
|||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
|
|
@ -1878,6 +1927,9 @@ packages:
|
|||
error-stack-parser@2.1.4:
|
||||
resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==}
|
||||
|
||||
es-module-lexer@2.0.0:
|
||||
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1898,6 +1950,13 @@ packages:
|
|||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
exsolve@1.0.8:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
|
|
@ -2407,6 +2466,9 @@ packages:
|
|||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
package-manager-detector@1.5.0:
|
||||
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
|
||||
|
||||
|
|
@ -2665,6 +2727,9 @@ packages:
|
|||
resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==}
|
||||
engines: {node: '>=6.9'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2690,6 +2755,9 @@ packages:
|
|||
stack-generator@2.0.10:
|
||||
resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
stackframe@1.3.4:
|
||||
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
|
||||
|
||||
|
|
@ -2699,6 +2767,9 @@ packages:
|
|||
stacktrace-js@2.0.2:
|
||||
resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==}
|
||||
|
||||
std-env@4.0.0:
|
||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
|
|
@ -2740,6 +2811,9 @@ packages:
|
|||
resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2748,6 +2822,10 @@ packages:
|
|||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
toggle-selection@1.0.6:
|
||||
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
|
||||
|
||||
|
|
@ -2884,6 +2962,41 @@ packages:
|
|||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.1:
|
||||
resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.1
|
||||
'@vitest/browser-preview': 4.1.1
|
||||
'@vitest/browser-webdriverio': 4.1.1
|
||||
'@vitest/ui': 4.1.1
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2911,6 +3024,11 @@ packages:
|
|||
web-namespaces@2.0.1:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
|
|
@ -3842,6 +3960,8 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.53.3':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tailwindcss/node@4.2.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
|
@ -3946,6 +4066,11 @@ snapshots:
|
|||
dependencies:
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
|
|
@ -4067,6 +4192,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
|
@ -4148,6 +4275,47 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/expect@4.1.1':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.1
|
||||
'@vitest/utils': 4.1.1
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.1
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
|
||||
|
||||
'@vitest/pretty-format@4.1.1':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.1':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.1
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.1':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.1
|
||||
'@vitest/utils': 4.1.1
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.1': {}
|
||||
|
||||
'@vitest/utils@4.1.1':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.1
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@xobotyi/scrollbar-width@1.9.5': {}
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
|
@ -4156,6 +4324,8 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
|
|
@ -4182,6 +4352,8 @@ snapshots:
|
|||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@3.0.0: {}
|
||||
|
|
@ -4488,6 +4660,8 @@ snapshots:
|
|||
dependencies:
|
||||
stackframe: 1.3.4
|
||||
|
||||
es-module-lexer@2.0.0: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
|
|
@ -4525,6 +4699,12 @@ snapshots:
|
|||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
|
@ -5286,6 +5466,8 @@ snapshots:
|
|||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
package-manager-detector@1.5.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
|
|
@ -5622,6 +5804,8 @@ snapshots:
|
|||
|
||||
set-harmonic-interval@1.0.1: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
|
@ -5641,6 +5825,8 @@ snapshots:
|
|||
dependencies:
|
||||
stackframe: 1.3.4
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
stackframe@1.3.4: {}
|
||||
|
||||
stacktrace-gps@3.1.2:
|
||||
|
|
@ -5654,6 +5840,8 @@ snapshots:
|
|||
stack-generator: 2.0.10
|
||||
stacktrace-gps: 3.1.2
|
||||
|
||||
std-env@4.0.0: {}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
|
|
@ -5690,6 +5878,8 @@ snapshots:
|
|||
|
||||
throttle-debounce@3.0.1: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
|
@ -5697,6 +5887,8 @@ snapshots:
|
|||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
toggle-selection@1.0.6: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
|
@ -5813,6 +6005,33 @@ snapshots:
|
|||
lightningcss: 1.31.1
|
||||
terser: 5.46.1
|
||||
|
||||
vitest@4.1.1(@types/node@24.10.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.1
|
||||
'@vitest/mocker': 4.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
|
||||
'@vitest/pretty-format': 4.1.1
|
||||
'@vitest/runner': 4.1.1
|
||||
'@vitest/snapshot': 4.1.1
|
||||
'@vitest/spy': 4.1.1
|
||||
'@vitest/utils': 4.1.1
|
||||
es-module-lexer: 2.0.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 4.0.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
vscode-jsonrpc@8.2.0: {}
|
||||
|
|
@ -5834,6 +6053,11 @@ snapshots:
|
|||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@1.10.2: {}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,166 @@
|
|||
import { PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import TableEditorDialog from "@/components/TableEditorDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { TableData } from "@/utils/markdown-table";
|
||||
import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "@/components/MemoView/MemoViewContext";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table (root wrapper with edit + delete buttons)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
|
||||
export const Table = ({ children, className, node, ...props }: TableProps) => {
|
||||
const t = useTranslate();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableData, setTableData] = useState<TableData | null>(null);
|
||||
const [tableIndex, setTableIndex] = useState(-1);
|
||||
|
||||
const { memo } = useMemoViewContext();
|
||||
const { readonly } = useMemoViewDerived();
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
|
||||
const tables = useMemo(() => findAllTables(memo.content), [memo.content]);
|
||||
|
||||
/** The index of the markdown table this rendered table corresponds to (from AST source positions). */
|
||||
const currentTableIndex = useMemo(() => {
|
||||
const nodeStart = node?.position?.start?.offset;
|
||||
if (nodeStart == null) return -1;
|
||||
|
||||
for (let i = 0; i < tables.length; i++) {
|
||||
if (nodeStart >= tables[i].start && nodeStart < tables[i].end) return i;
|
||||
}
|
||||
return -1;
|
||||
}, [tables, node]);
|
||||
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (currentTableIndex < 0 || currentTableIndex >= tables.length) return;
|
||||
|
||||
const parsed = parseMarkdownTable(tables[currentTableIndex].text);
|
||||
if (!parsed) return;
|
||||
|
||||
setTableData(parsed);
|
||||
setTableIndex(currentTableIndex);
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[tables, currentTableIndex],
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (currentTableIndex < 0) return;
|
||||
|
||||
setTableIndex(currentTableIndex);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
[currentTableIndex],
|
||||
);
|
||||
|
||||
const handleConfirmEdit = useCallback(
|
||||
async (markdown: string) => {
|
||||
if (tableIndex < 0) return;
|
||||
const newContent = replaceNthTable(memo.content, tableIndex, markdown);
|
||||
try {
|
||||
await updateMemo({
|
||||
update: { name: memo.name, content: newContent },
|
||||
updateMask: ["content"],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Update table", fallbackMessage: "An error occurred" });
|
||||
}
|
||||
},
|
||||
[memo.content, memo.name, tableIndex, updateMemo],
|
||||
);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (tableIndex < 0) return;
|
||||
// Replace the table with an empty string to delete it.
|
||||
const newContent = replaceNthTable(memo.content, tableIndex, "");
|
||||
try {
|
||||
await updateMemo({
|
||||
update: { name: memo.name, content: newContent },
|
||||
updateMask: ["content"],
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Delete table", fallbackMessage: "An error occurred" });
|
||||
}
|
||||
}, [memo.content, memo.name, tableIndex, updateMemo]);
|
||||
|
||||
return (
|
||||
<div className="my-2 w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
<div className="group/table relative w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
{!readonly && (
|
||||
<div className="absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover/table:opacity-100 transition-all">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
onClick={handleDeleteClick}
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={handleEditClick}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TableEditorDialog open={dialogOpen} onOpenChange={setDialogOpen} initialData={tableData} onConfirm={handleConfirmEdit} />
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editor.table.delete")}</DialogTitle>
|
||||
<DialogDescription>{t("editor.table.delete-confirm")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">{t("common.cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components (unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,10 +124,14 @@ const MemoContent = (props: MemoContentProps) => {
|
|||
// Code blocks
|
||||
pre: CodeBlock,
|
||||
// Tables
|
||||
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
|
||||
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
|
||||
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
|
||||
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
|
||||
table: ({ children, node, ...props }) => (
|
||||
<Table node={node} {...props}>
|
||||
{children}
|
||||
</Table>
|
||||
),
|
||||
thead: ({ children }) => <TableHead>{children}</TableHead>,
|
||||
tbody: ({ children }) => <TableBody>{children}</TableBody>,
|
||||
tr: ({ children }) => <TableRow>{children}</TableRow>,
|
||||
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
|
||||
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,18 @@ import { useSuggestions } from "./useSuggestions";
|
|||
|
||||
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
|
||||
const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
|
||||
// Remove trigger char + word, then insert command output
|
||||
// Remove trigger char + word.
|
||||
actions.removeText(index, word.length);
|
||||
|
||||
// If the command has a dialog action, invoke it instead of inserting text.
|
||||
if (cmd.action) {
|
||||
cmd.action();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise insert the command output text.
|
||||
actions.insertText(cmd.run());
|
||||
// Position cursor relative to insertion point, if specified
|
||||
// Position cursor relative to insertion point, if specified.
|
||||
if (cmd.cursorOffset) {
|
||||
actions.setCursorPosition(index + cmd.cursorOffset);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
export interface Command {
|
||||
name: string;
|
||||
/** Returns text to insert. Ignored if `action` is set. */
|
||||
run: () => string;
|
||||
cursorOffset?: number;
|
||||
/** If set, called instead of inserting run() text. Used for dialog-based commands. */
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export const editorCommands: Command[] = [
|
||||
|
|
@ -26,3 +29,18 @@ export const editorCommands: Command[] = [
|
|||
cursorOffset: 1,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create the full editor commands list, with the table command
|
||||
* wired to open the table editor dialog instead of inserting raw markdown.
|
||||
*/
|
||||
export function createEditorCommands(onOpenTableEditor?: () => void): Command[] {
|
||||
if (!onOpenTableEditor) return editorCommands;
|
||||
|
||||
return editorCommands.map((cmd) => {
|
||||
if (cmd.name === "table") {
|
||||
return { ...cmd, action: onOpenTableEditor };
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
isInIME = false,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
commands: customCommands,
|
||||
} = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -210,7 +211,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
onCompositionEnd={onCompositionEnd}
|
||||
></textarea>
|
||||
<TagSuggestions editorRef={editorRef} editorActions={ref} />
|
||||
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
|
||||
<SlashCommands editorRef={editorRef} editorActions={ref} commands={customCommands || editorCommands} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
FileIcon,
|
||||
LinkIcon,
|
||||
LoaderIcon,
|
||||
type LucideIcon,
|
||||
MapPinIcon,
|
||||
Maximize2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
TableIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
|
||||
|
|
@ -26,7 +36,7 @@ import type { LocalFile } from "../types/attachment";
|
|||
const InsertMenu = (props: InsertMenuProps) => {
|
||||
const t = useTranslate();
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
|
||||
const { location: initialLocation, onLocationChange, onToggleFocusMode, onOpenTableEditor, isUploading: isUploadingProp } = props;
|
||||
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
|
|
@ -129,6 +139,12 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
icon: FileIcon,
|
||||
onClick: handleUploadClick,
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
label: t("editor.table.title"),
|
||||
icon: TableIcon,
|
||||
onClick: () => onOpenTableEditor?.(),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: t("tooltip.link-memo"),
|
||||
|
|
@ -142,7 +158,7 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
onClick: handleLocationClick,
|
||||
},
|
||||
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
|
||||
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, t],
|
||||
[handleLocationClick, handleOpenLinkDialog, onOpenTableEditor, handleUploadClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { forwardRef } from "react";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import Editor, { type EditorRefActions } from "../Editor";
|
||||
import { createEditorCommands } from "../Editor/commands";
|
||||
import { useBlobUrls, useDragAndDrop } from "../hooks";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { EditorContentProps } from "../types";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
|
||||
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder }, ref) => {
|
||||
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder, onOpenTableEditor }, ref) => {
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { createBlobUrl } = useBlobUrls();
|
||||
|
||||
|
|
@ -54,6 +55,9 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
|
|||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Build commands with the table editor action wired in.
|
||||
const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
|
||||
<Editor
|
||||
|
|
@ -67,6 +71,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
|
|||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
commands={commands}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
|
|||
import VisibilitySelector from "../Toolbar/VisibilitySelector";
|
||||
import type { EditorToolbarProps } from "../types";
|
||||
|
||||
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName }) => {
|
||||
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onOpenTableEditor }) => {
|
||||
const t = useTranslate();
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { valid } = validationService.canSave(state);
|
||||
|
|
@ -34,6 +34,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
|
|||
location={state.metadata.location}
|
||||
onLocationChange={handleLocationChange}
|
||||
onToggleFocusMode={handleToggleFocusMode}
|
||||
onOpenTableEditor={onOpenTableEditor}
|
||||
memoName={memoName}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TableEditorDialog from "@/components/TableEditorDialog";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
|
|
@ -45,7 +46,14 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
// Get default visibility from user settings
|
||||
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
|
||||
|
||||
useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility });
|
||||
useMemoInit({
|
||||
editorRef,
|
||||
memo,
|
||||
cacheKey,
|
||||
username: currentUser?.name ?? "",
|
||||
autoFocus,
|
||||
defaultVisibility,
|
||||
});
|
||||
|
||||
// Auto-save content to localStorage
|
||||
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
|
||||
|
|
@ -57,6 +65,19 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
dispatch(actions.toggleFocusMode());
|
||||
};
|
||||
|
||||
// Table editor dialog (shared by slash command and toolbar).
|
||||
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
||||
|
||||
const handleOpenTableEditor = useCallback(() => {
|
||||
setTableDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleTableConfirm = useCallback((markdown: string) => {
|
||||
editorRef.current?.insertText(markdown);
|
||||
setTableDialogOpen(false);
|
||||
editorRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useKeyboard(editorRef, handleSave);
|
||||
|
||||
async function handleSave() {
|
||||
|
|
@ -70,7 +91,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
dispatch(actions.setLoading("saving", true));
|
||||
|
||||
try {
|
||||
const result = await memoService.save(state, { memoName, parentMemoName });
|
||||
const result = await memoService.save(state, {
|
||||
memoName,
|
||||
parentMemoName,
|
||||
});
|
||||
|
||||
if (!result.hasChanges) {
|
||||
toast.error(t("editor.no-changes-detected"));
|
||||
|
|
@ -89,12 +113,20 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
|
||||
// Ensure memo detail pages don't keep stale cached content after edits.
|
||||
if (memoName) {
|
||||
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
|
||||
invalidationPromises.push(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: memoKeys.detail(memoName),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If this was a comment, also invalidate the comments query for the parent memo
|
||||
if (parentMemoName) {
|
||||
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
|
||||
invalidationPromises.push(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: memoKeys.comments(parentMemoName),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(invalidationPromises);
|
||||
|
|
@ -145,14 +177,16 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
)}
|
||||
|
||||
{/* Editor content grows to fill available space in focus mode */}
|
||||
<EditorContent ref={editorRef} placeholder={placeholder} />
|
||||
<EditorContent ref={editorRef} placeholder={placeholder} onOpenTableEditor={handleOpenTableEditor} />
|
||||
|
||||
{/* Metadata and toolbar grouped together at bottom */}
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<EditorMetadata memoName={memoName} />
|
||||
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} />
|
||||
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onOpenTableEditor={handleOpenTableEditor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableEditorDialog open={tableDialogOpen} onOpenChange={setTableDialogOpen} onConfirm={handleTableConfirm} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ export interface MemoEditorProps {
|
|||
|
||||
export interface EditorContentProps {
|
||||
placeholder?: string;
|
||||
onOpenTableEditor?: () => void;
|
||||
}
|
||||
|
||||
export interface EditorToolbarProps {
|
||||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
memoName?: string;
|
||||
onOpenTableEditor?: () => void;
|
||||
}
|
||||
|
||||
export interface EditorMetadataProps {
|
||||
|
|
@ -44,6 +46,7 @@ export interface InsertMenuProps {
|
|||
location?: Location;
|
||||
onLocationChange: (location?: Location) => void;
|
||||
onToggleFocusMode?: () => void;
|
||||
onOpenTableEditor?: () => void;
|
||||
memoName?: string;
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +71,8 @@ export interface EditorProps {
|
|||
isInIME?: boolean;
|
||||
onCompositionStart?: () => void;
|
||||
onCompositionEnd?: () => void;
|
||||
/** Custom commands for slash menu. If not provided, defaults are used. */
|
||||
commands?: import("../Editor/commands").Command[];
|
||||
}
|
||||
|
||||
export interface VisibilitySelectorProps {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { ColumnAlignment, TableData } from "@/utils/markdown-table";
|
||||
import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { VisuallyHidden } from "./ui/visually-hidden";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: TableData | null;
|
||||
onConfirm: (markdown: string) => void;
|
||||
}
|
||||
|
||||
type SortState = { col: number; dir: "asc" | "desc" } | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => {
|
||||
const t = useTranslate();
|
||||
const [headers, setHeaders] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<string[][]>([]);
|
||||
const [rowIds, setRowIds] = useState<number[]>([]);
|
||||
const [alignments, setAlignments] = useState<ColumnAlignment[]>([]);
|
||||
const [sortState, setSortState] = useState<SortState>(null);
|
||||
|
||||
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
||||
const nextRowId = useRef(0);
|
||||
|
||||
const allocateRowId = useCallback(() => nextRowId.current++, []);
|
||||
|
||||
const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => {
|
||||
if (el) inputRefs.current.set(key, el);
|
||||
else inputRefs.current.delete(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
nextRowId.current = 0;
|
||||
if (initialData) {
|
||||
setHeaders([...initialData.headers]);
|
||||
setRows(initialData.rows.map((r) => [...r]));
|
||||
setRowIds(initialData.rows.map(() => nextRowId.current++));
|
||||
setAlignments([...initialData.alignments]);
|
||||
} else {
|
||||
const empty = createEmptyTable(3, 2);
|
||||
setHeaders(empty.headers);
|
||||
setRows(empty.rows);
|
||||
setRowIds(empty.rows.map(() => nextRowId.current++));
|
||||
setAlignments(empty.alignments);
|
||||
}
|
||||
setSortState(null);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const colCount = headers.length;
|
||||
const rowCount = rows.length;
|
||||
|
||||
// ---- Cell editing ----
|
||||
|
||||
const updateHeader = (col: number, value: string) => {
|
||||
setHeaders((prev) => {
|
||||
const next = [...prev];
|
||||
next[col] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateCell = (row: number, col: number, value: string) => {
|
||||
setRows((prev) => {
|
||||
const next = prev.map((r) => [...r]);
|
||||
next[row][col] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Add / Remove / Insert ----
|
||||
|
||||
const addColumn = () => {
|
||||
setHeaders((prev) => [...prev, ""]);
|
||||
setRows((prev) => prev.map((r) => [...r, ""]));
|
||||
setAlignments((prev) => [...prev, "none"]);
|
||||
setSortState(null);
|
||||
};
|
||||
|
||||
const insertColumnAt = (index: number) => {
|
||||
setHeaders((prev) => [...prev.slice(0, index), "", ...prev.slice(index)]);
|
||||
setRows((prev) => prev.map((r) => [...r.slice(0, index), "", ...r.slice(index)]));
|
||||
setAlignments((prev) => [...prev.slice(0, index), "none" as ColumnAlignment, ...prev.slice(index)]);
|
||||
setSortState(null);
|
||||
};
|
||||
|
||||
const removeColumn = (col: number) => {
|
||||
if (colCount <= 1) return;
|
||||
setHeaders((prev) => prev.filter((_, i) => i !== col));
|
||||
setRows((prev) => prev.map((r) => r.filter((_, i) => i !== col)));
|
||||
setAlignments((prev) => prev.filter((_, i) => i !== col));
|
||||
setSortState(null);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
const id = allocateRowId();
|
||||
setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]);
|
||||
setRowIds((prev) => [...prev, id]);
|
||||
};
|
||||
|
||||
const insertRowAt = (index: number) => {
|
||||
const id = allocateRowId();
|
||||
setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]);
|
||||
setRowIds((prev) => [...prev.slice(0, index), id, ...prev.slice(index)]);
|
||||
};
|
||||
|
||||
const removeRow = (row: number) => {
|
||||
if (rowCount <= 1) return;
|
||||
setRows((prev) => prev.filter((_, i) => i !== row));
|
||||
setRowIds((prev) => prev.filter((_, i) => i !== row));
|
||||
};
|
||||
|
||||
// ---- Sorting ----
|
||||
|
||||
const sortByColumn = (col: number) => {
|
||||
let newDir: "asc" | "desc" = "asc";
|
||||
if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc";
|
||||
setSortState({ col, dir: newDir });
|
||||
|
||||
const normalize = (s: string): string =>
|
||||
s
|
||||
.trim()
|
||||
.replace(/[^\d.-]/g, "")
|
||||
.replace(/(?<=.)-/g, "");
|
||||
const compareFn = (a: string[], b: string[]) => {
|
||||
const va = (a[col] || "").toLowerCase();
|
||||
const vb = (b[col] || "").toLowerCase();
|
||||
const ca = normalize(va);
|
||||
const cb = normalize(vb);
|
||||
const na = ca !== "" ? parseFloat(ca) : NaN;
|
||||
const nb = cb !== "" ? parseFloat(cb) : NaN;
|
||||
const aIsNum = Number.isFinite(na);
|
||||
const bIsNum = Number.isFinite(nb);
|
||||
if (aIsNum && bIsNum) return newDir === "asc" ? na - nb : nb - na;
|
||||
if (aIsNum !== bIsNum) return aIsNum ? (newDir === "asc" ? 1 : -1) : newDir === "asc" ? -1 : 1;
|
||||
const cmp = va.localeCompare(vb);
|
||||
return newDir === "asc" ? cmp : -cmp;
|
||||
};
|
||||
|
||||
// Build sorted indices, then apply to both rows and rowIds.
|
||||
const indices = rows.map((_, i) => i);
|
||||
indices.sort((a, b) => compareFn(rows[a], rows[b]));
|
||||
setRows(indices.map((i) => rows[i]));
|
||||
setRowIds(indices.map((i) => rowIds[i]));
|
||||
};
|
||||
|
||||
// ---- Tab / keyboard navigation ----
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, row: number, col: number) => {
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
// At grid boundaries, let the browser move focus naturally out of the grid.
|
||||
if (!e.shiftKey && row === rowCount - 1 && col === colCount - 1) return;
|
||||
if (e.shiftKey && row === 0 && col === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
const nextCol = e.shiftKey ? col - 1 : col + 1;
|
||||
let nextRow = row;
|
||||
if (nextCol >= colCount) {
|
||||
nextRow = row + 1;
|
||||
focusCell(nextRow, 0);
|
||||
} else if (nextCol < 0) {
|
||||
if (row > 0) {
|
||||
nextRow = row - 1;
|
||||
focusCell(nextRow, colCount - 1);
|
||||
} else {
|
||||
focusCell(-1, colCount - 1);
|
||||
}
|
||||
} else {
|
||||
focusCell(nextRow, nextCol);
|
||||
}
|
||||
};
|
||||
|
||||
const focusCell = (row: number, col: number) => {
|
||||
inputRefs.current.get(`${row}:${col}`)?.focus();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const md = serializeMarkdownTable({ headers, rows, alignments });
|
||||
onConfirm(md);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const SortIndicator = ({ col }: { col: number }) => {
|
||||
if (sortState?.col === col) {
|
||||
return sortState.dir === "asc" ? <ArrowUpIcon className="size-3 text-primary" /> : <ArrowDownIcon className="size-3 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDownIcon className="size-3 opacity-40" />;
|
||||
};
|
||||
|
||||
const totalColSpan = colCount + 2;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="full" className="p-0! w-[min(56rem,calc(100vw-2rem))] h-[min(44rem,calc(100vh-4rem))]" showCloseButton={false}>
|
||||
<VisuallyHidden>
|
||||
<DialogClose />
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{t("editor.table.editor-title")}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<DialogDescription>{t("editor.table.editor-description")}</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Scrollable table area */}
|
||||
<div className="flex-1 overflow-auto px-4 pb-2">
|
||||
{/* Wrapper: w-max + overflow-x-clip so row insert line is clipped (clip avoids breaking sticky); min-w-full so table fills when narrow */}
|
||||
<div className="relative min-w-full w-max overflow-x-clip">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* ============ STICKY HEADER ============ */}
|
||||
<thead className="sticky top-0 z-20">
|
||||
{/* Mask row: solid background that hides content scrolling behind the header */}
|
||||
<tr>
|
||||
<th colSpan={totalColSpan} className="h-4 bg-background p-0 border-0" />
|
||||
</tr>
|
||||
|
||||
{/* Header row */}
|
||||
<tr>
|
||||
{/* Row-number spacer */}
|
||||
<th className="w-7 min-w-7 bg-background" />
|
||||
|
||||
{headers.map((header, col) => (
|
||||
<th key={col} className="p-0 min-w-[140px] relative bg-background">
|
||||
{/* ---- Insert-column zone (left edge of this column) ---- */}
|
||||
<div
|
||||
className="group/cins absolute -left-4 top-0 bottom-0 w-8 z-30 cursor-pointer"
|
||||
onClick={() => insertColumnAt(col)}
|
||||
>
|
||||
{/* Blue vertical line through the entire table */}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 top-0 w-0 group-hover/cins:w-[3px] bg-blue-500/70 transition-all pointer-events-none"
|
||||
style={{ bottom: "-200rem" }}
|
||||
/>
|
||||
{/* + button — absolutely centered on the column border */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground cursor-pointer opacity-0 group-hover/cins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editor.table.insert-column")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Header cell — bg covers input + sort + delete */}
|
||||
<div className="flex items-center bg-accent/50 border border-border">
|
||||
<input
|
||||
ref={(el) => setInputRef(`-1:${col}`, el)}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent font-mono focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
value={header}
|
||||
onChange={(e) => updateHeader(col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, -1, col)}
|
||||
placeholder={`Col ${col + 1}`}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => sortByColumn(col)}
|
||||
>
|
||||
<SortIndicator col={col} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editor.table.sort-column")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{colCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 mr-2 rounded cursor-pointer opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
onClick={() => removeColumn(col)}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editor.table.remove-column")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
|
||||
{/* Add column at end */}
|
||||
<th className="w-8 min-w-8 align-middle bg-background">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded cursor-pointer hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={addColumn}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editor.table.add-column")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* ============ DATA ROWS ============ */}
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<React.Fragment key={rowIds[rowIdx]}>
|
||||
<tr>
|
||||
{/* Row number — with insert-row zone on top border */}
|
||||
<td className="w-7 min-w-7 text-center align-middle relative">
|
||||
<div
|
||||
className="group/rins absolute -top-[10px] -left-1 right-0 h-5 z-10 cursor-pointer"
|
||||
onClick={() => insertRowAt(rowIdx)}
|
||||
>
|
||||
{/* Blue horizontal line extending across the table */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-0 group-hover/rins:h-[3px] bg-blue-500/70 transition-all pointer-events-none"
|
||||
style={{ width: "200rem" }}
|
||||
/>
|
||||
{/* + button at intersection of row border and first-column border */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground cursor-pointer opacity-0 group-hover/rins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editor.table.insert-row")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{rowIdx + 1}</span>
|
||||
</td>
|
||||
|
||||
{/* Data cells */}
|
||||
{row.map((cell, col) => (
|
||||
<td key={col} className="p-0">
|
||||
<input
|
||||
ref={(el) => setInputRef(`${rowIdx}:${col}`, el)}
|
||||
className="w-full px-2 py-1.5 text-sm bg-transparent font-mono border border-border focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
value={cell}
|
||||
onChange={(e) => updateCell(rowIdx, col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, rowIdx, col)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* Row delete button */}
|
||||
<td className="w-8 min-w-8 align-middle">
|
||||
{rowCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded cursor-pointer opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editor.table.remove-row")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add row button below the table */}
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addRow}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("editor.table.add-row")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ FOOTER ============ */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{colCount} {colCount === 1 ? t("editor.table.column") : t("editor.table.columns")} · {rowCount}{" "}
|
||||
{rowCount === 1 ? t("editor.table.row") : t("editor.table.rows")}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addRow}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("editor.table.add-row")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addColumn}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("editor.table.add-column")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button className="cursor-pointer" onClick={handleConfirm}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableEditorDialog;
|
||||
|
|
@ -124,7 +124,25 @@
|
|||
"no-changes-detected": "No changes detected",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"slash-commands": "Type `/` for commands"
|
||||
"slash-commands": "Type `/` for commands",
|
||||
"table": {
|
||||
"title": "Table",
|
||||
"editor-title": "Table Editor",
|
||||
"editor-description": "Edit table headers, rows, columns and sort data",
|
||||
"add-row": "Add row",
|
||||
"add-column": "Add column",
|
||||
"insert-column": "Insert column",
|
||||
"insert-row": "Insert row",
|
||||
"remove-column": "Remove column",
|
||||
"remove-row": "Remove row",
|
||||
"sort-column": "Sort column",
|
||||
"delete": "Delete table",
|
||||
"delete-confirm": "Are you sure you want to delete this table? This action cannot be undone.",
|
||||
"column": "column",
|
||||
"columns": "columns",
|
||||
"row": "row",
|
||||
"rows": "rows"
|
||||
}
|
||||
},
|
||||
"inbox": {
|
||||
"failed-to-load": "Failed to load inbox item",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createEmptyTable,
|
||||
findAllTables,
|
||||
parseMarkdownTable,
|
||||
replaceNthTable,
|
||||
serializeMarkdownTable,
|
||||
type TableData,
|
||||
} from "@/utils/markdown-table";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMarkdownTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseMarkdownTable", () => {
|
||||
it("parses a basic table", () => {
|
||||
const md = `| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
| 3 | 4 |`;
|
||||
const result = parseMarkdownTable(md);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.headers).toEqual(["A", "B"]);
|
||||
expect(result!.rows).toEqual([
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
]);
|
||||
expect(result!.alignments).toEqual(["none", "none"]);
|
||||
});
|
||||
|
||||
it("parses alignment markers", () => {
|
||||
const md = `| Left | Center | Right | None |
|
||||
| :--- | :---: | ---: | --- |
|
||||
| a | b | c | d |`;
|
||||
const result = parseMarkdownTable(md);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.alignments).toEqual(["left", "center", "right", "none"]);
|
||||
});
|
||||
|
||||
it("returns null for non-table text", () => {
|
||||
expect(parseMarkdownTable("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a single line", () => {
|
||||
expect(parseMarkdownTable("| A | B |")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when separator is invalid", () => {
|
||||
const md = `| A | B |
|
||||
| not | valid |`;
|
||||
expect(parseMarkdownTable(md)).toBeNull();
|
||||
});
|
||||
|
||||
it("pads short rows to match header count", () => {
|
||||
const md = `| A | B | C |
|
||||
| --- | --- | --- |
|
||||
| 1 |`;
|
||||
const result = parseMarkdownTable(md);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rows[0]).toEqual(["1", "", ""]);
|
||||
});
|
||||
|
||||
it("trims long rows to match header count", () => {
|
||||
const md = `| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 | 3 | 4 |`;
|
||||
const result = parseMarkdownTable(md);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rows[0]).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("handles empty cells", () => {
|
||||
const md = `| A | B |
|
||||
| --- | --- |
|
||||
| | |`;
|
||||
const result = parseMarkdownTable(md);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rows[0]).toEqual(["", ""]);
|
||||
});
|
||||
|
||||
it("handles table with no data rows", () => {
|
||||
const md = `| A | B |
|
||||
| --- | --- |`;
|
||||
const result = parseMarkdownTable(md);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.headers).toEqual(["A", "B"]);
|
||||
expect(result!.rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serializeMarkdownTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("serializeMarkdownTable", () => {
|
||||
it("serializes a basic table", () => {
|
||||
const data: TableData = {
|
||||
headers: ["A", "B"],
|
||||
rows: [
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
],
|
||||
alignments: ["none", "none"],
|
||||
};
|
||||
const result = serializeMarkdownTable(data);
|
||||
expect(result).toContain("| A");
|
||||
expect(result).toContain("| 1");
|
||||
// Should have 4 lines: header, separator, 2 data rows
|
||||
expect(result.split("\n")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("preserves alignment in separator", () => {
|
||||
const data: TableData = {
|
||||
headers: ["Left", "Center", "Right"],
|
||||
rows: [["a", "b", "c"]],
|
||||
alignments: ["left", "center", "right"],
|
||||
};
|
||||
const result = serializeMarkdownTable(data);
|
||||
const lines = result.split("\n");
|
||||
const sep = lines[1];
|
||||
// Left alignment: starts with ":"
|
||||
expect(sep).toMatch(/\| :-+\s/);
|
||||
// Center alignment: starts and ends with ":"
|
||||
expect(sep).toMatch(/:-+:/);
|
||||
// Right alignment: ends with ":"
|
||||
expect(sep).toMatch(/-+: \|$/);
|
||||
});
|
||||
|
||||
it("pads cells to uniform width", () => {
|
||||
const data: TableData = {
|
||||
headers: ["Short", "A very long header"],
|
||||
rows: [["x", "y"]],
|
||||
alignments: ["none", "none"],
|
||||
};
|
||||
const result = serializeMarkdownTable(data);
|
||||
const lines = result.split("\n");
|
||||
// All lines should have same length due to padding
|
||||
expect(new Set(lines.map((l) => l.length)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trips a cell containing a pipe character", () => {
|
||||
const data: TableData = {
|
||||
headers: ["A", "B"],
|
||||
rows: [["foo|bar", "baz"]],
|
||||
alignments: ["none", "none"],
|
||||
};
|
||||
const md = serializeMarkdownTable(data);
|
||||
const parsed = parseMarkdownTable(md);
|
||||
expect(parsed?.rows[0][0]).toBe("foo|bar");
|
||||
expect(parsed?.rows[0][1]).toBe("baz");
|
||||
});
|
||||
|
||||
it("round-trips a cell whose value has two backslashes before a pipe (\\\\\\\\|)", () => {
|
||||
// Cell value "foo\\|bar" (two backslashes + pipe) is a valid parsed value
|
||||
// that comes from markdown "foo\\\\\\|bar". The old escapeCell regex
|
||||
// (?<!\\)\\| would NOT escape the pipe (it sees one \\ before the | and
|
||||
// thinks it is already escaped), causing the parser to split on the pipe.
|
||||
// The fixed escapeCell counts all consecutive backslashes: 2 is even, so
|
||||
// it correctly inserts an escape backslash.
|
||||
const data: TableData = {
|
||||
headers: ["A"],
|
||||
rows: [["foo\\\\|bar"]],
|
||||
alignments: ["none"],
|
||||
};
|
||||
const md = serializeMarkdownTable(data);
|
||||
const parsed = parseMarkdownTable(md);
|
||||
expect(parsed?.headers.length).toBe(1); // pipe must NOT split into extra columns
|
||||
expect(parsed?.rows[0][0]).toBe("foo\\\\|bar");
|
||||
});
|
||||
|
||||
it("round-trips through parse and serialize", () => {
|
||||
const original = `| Name | Age |
|
||||
| ----- | --- |
|
||||
| Alice | 30 |
|
||||
| Bob | 25 |`;
|
||||
const parsed = parseMarkdownTable(original);
|
||||
expect(parsed).not.toBeNull();
|
||||
const serialized = serializeMarkdownTable(parsed!);
|
||||
const reparsed = parseMarkdownTable(serialized);
|
||||
expect(reparsed).not.toBeNull();
|
||||
expect(reparsed!.headers).toEqual(parsed!.headers);
|
||||
expect(reparsed!.rows).toEqual(parsed!.rows);
|
||||
expect(reparsed!.alignments).toEqual(parsed!.alignments);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findAllTables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("findAllTables", () => {
|
||||
it("finds a single table", () => {
|
||||
const content = `Some text
|
||||
|
||||
| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
|
||||
More text`;
|
||||
const tables = findAllTables(content);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].text).toContain("| A | B |");
|
||||
// Verify start/end are correct by slicing
|
||||
expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text);
|
||||
});
|
||||
|
||||
it("finds multiple tables", () => {
|
||||
const content = `| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
|
||||
Some text between
|
||||
|
||||
| X | Y |
|
||||
| --- | --- |
|
||||
| 3 | 4 |`;
|
||||
const tables = findAllTables(content);
|
||||
expect(tables).toHaveLength(2);
|
||||
expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text);
|
||||
expect(content.slice(tables[1].start, tables[1].end)).toBe(tables[1].text);
|
||||
});
|
||||
|
||||
it("returns empty for no tables", () => {
|
||||
expect(findAllTables("just some text\nno tables here")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("requires at least 2 lines for a table", () => {
|
||||
const content = "| single line |";
|
||||
expect(findAllTables(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles table at end of content without trailing newline", () => {
|
||||
const content = `text
|
||||
| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |`;
|
||||
const tables = findAllTables(content);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text);
|
||||
});
|
||||
|
||||
it("handles table at start of content", () => {
|
||||
const content = `| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
more text`;
|
||||
const tables = findAllTables(content);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].start).toBe(0);
|
||||
expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text);
|
||||
});
|
||||
|
||||
it("finds a pipe-less GFM table", () => {
|
||||
const content = "A | B\n--- | ---\n1 | 2";
|
||||
const tables = findAllTables(content);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// replaceNthTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("replaceNthTable", () => {
|
||||
const content = `Before
|
||||
|
||||
| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
|
||||
Middle
|
||||
|
||||
| X | Y |
|
||||
| --- | --- |
|
||||
| 3 | 4 |
|
||||
|
||||
After`;
|
||||
|
||||
it("replaces the first table", () => {
|
||||
const result = replaceNthTable(content, 0, "NEW TABLE");
|
||||
expect(result).toContain("NEW TABLE");
|
||||
expect(result).toContain("| X | Y |");
|
||||
expect(result).not.toContain("| A | B |");
|
||||
});
|
||||
|
||||
it("replaces the second table", () => {
|
||||
const result = replaceNthTable(content, 1, "NEW TABLE");
|
||||
expect(result).toContain("| A | B |");
|
||||
expect(result).toContain("NEW TABLE");
|
||||
expect(result).not.toContain("| X | Y |");
|
||||
});
|
||||
|
||||
it("deletes a table when replacing with empty string", () => {
|
||||
const result = replaceNthTable(content, 0, "");
|
||||
expect(result).not.toContain("| A | B |");
|
||||
expect(result).toContain("Before");
|
||||
expect(result).toContain("Middle");
|
||||
});
|
||||
|
||||
it("returns content unchanged for invalid index", () => {
|
||||
expect(replaceNthTable(content, -1, "X")).toBe(content);
|
||||
expect(replaceNthTable(content, 99, "X")).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createEmptyTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createEmptyTable", () => {
|
||||
it("creates table with specified dimensions", () => {
|
||||
const table = createEmptyTable(3, 2);
|
||||
expect(table.headers).toHaveLength(3);
|
||||
expect(table.rows).toHaveLength(2);
|
||||
expect(table.alignments).toHaveLength(3);
|
||||
expect(table.rows[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("creates default 2x2 table", () => {
|
||||
const table = createEmptyTable();
|
||||
expect(table.headers).toHaveLength(2);
|
||||
expect(table.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("initializes with header placeholders", () => {
|
||||
const table = createEmptyTable(2, 1);
|
||||
expect(table.headers[0]).toBe("Header 1");
|
||||
expect(table.headers[1]).toBe("Header 2");
|
||||
});
|
||||
|
||||
it("initializes cells as empty strings", () => {
|
||||
const table = createEmptyTable(2, 2);
|
||||
for (const row of table.rows) {
|
||||
for (const cell of row) {
|
||||
expect(cell).toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("initializes all alignments to none", () => {
|
||||
const table = createEmptyTable(3, 1);
|
||||
expect(table.alignments).toEqual(["none", "none", "none"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* Utilities for parsing, serializing, and manipulating markdown tables.
|
||||
*/
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmFromMarkdown } from "mdast-util-gfm";
|
||||
import { gfm } from "micromark-extension-gfm";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
export interface TableData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
/** Column alignments: "left" | "center" | "right" | "none". */
|
||||
alignments: ColumnAlignment[];
|
||||
}
|
||||
|
||||
export type ColumnAlignment = "left" | "center" | "right" | "none";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a markdown table string into structured TableData.
|
||||
*
|
||||
* Expects a standard GFM table:
|
||||
* | Header1 | Header2 |
|
||||
* | ------- | ------- |
|
||||
* | cell | cell |
|
||||
*/
|
||||
export function parseMarkdownTable(md: string): TableData | null {
|
||||
const lines = md
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const parseRow = (line: string): string[] => {
|
||||
// Strip leading/trailing pipes and split by pipes preceded by an even number
|
||||
// of backslashes (0, 2, 4, …). A pipe preceded by an odd number of
|
||||
// backslashes is an escaped pipe and must not be treated as a column
|
||||
// separator. The simpler regex (?<!\\)\| fails for "\\|" (escaped
|
||||
// backslash + unescaped pipe) because the lookbehind only checks the single
|
||||
// character immediately before the pipe.
|
||||
let trimmed = line;
|
||||
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
|
||||
if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
|
||||
|
||||
const cells: string[] = [];
|
||||
let cellStart = 0;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (trimmed[i] === "|") {
|
||||
let backslashes = 0;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && trimmed[j] === "\\") {
|
||||
backslashes++;
|
||||
j--;
|
||||
}
|
||||
if (backslashes % 2 === 0) {
|
||||
cells.push(trimmed.slice(cellStart, i).trim().replace(/\\\|/g, "|"));
|
||||
cellStart = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.push(trimmed.slice(cellStart).trim().replace(/\\\|/g, "|"));
|
||||
return cells;
|
||||
};
|
||||
|
||||
const headers = parseRow(lines[0]);
|
||||
|
||||
// Parse the separator line for alignments.
|
||||
const sepCells = parseRow(lines[1]);
|
||||
const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim()));
|
||||
if (!isSeparator) return null;
|
||||
|
||||
const alignments: ColumnAlignment[] = sepCells.map((cell) => {
|
||||
const c = cell.trim();
|
||||
const left = c.startsWith(":");
|
||||
const right = c.endsWith(":");
|
||||
if (left && right) return "center";
|
||||
if (right) return "right";
|
||||
if (left) return "left";
|
||||
return "none";
|
||||
});
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (let i = 2; i < lines.length; i++) {
|
||||
const cells = parseRow(lines[i]);
|
||||
// Pad or trim to match header count.
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
if (cells.length > headers.length) cells.length = headers.length;
|
||||
rows.push(cells);
|
||||
}
|
||||
|
||||
return { headers, rows, alignments };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serialize TableData into a properly-aligned markdown table string.
|
||||
*/
|
||||
export function serializeMarkdownTable(data: TableData): string {
|
||||
const { headers, rows, alignments } = data;
|
||||
const colCount = headers.length;
|
||||
|
||||
const escapeCell = (text: string): string => {
|
||||
let result = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === "|") {
|
||||
let backslashes = 0;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && text[j] === "\\") {
|
||||
backslashes++;
|
||||
j--;
|
||||
}
|
||||
if (backslashes % 2 === 0) result += "\\";
|
||||
}
|
||||
result += text[i];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Calculate maximum width per column (minimum 3 for the separator).
|
||||
const widths: number[] = [];
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
let max = Math.max(3, escapeCell(headers[c]).length);
|
||||
for (const row of rows) {
|
||||
max = Math.max(max, escapeCell(row[c] || "").length);
|
||||
}
|
||||
widths.push(max);
|
||||
}
|
||||
|
||||
const padCell = (text: string, width: number, align: ColumnAlignment): string => {
|
||||
const t = text || "";
|
||||
const padding = width - t.length;
|
||||
if (padding <= 0) return t;
|
||||
if (align === "right") return " ".repeat(padding) + t;
|
||||
if (align === "center") {
|
||||
const left = Math.floor(padding / 2);
|
||||
const right = padding - left;
|
||||
return " ".repeat(left) + t + " ".repeat(right);
|
||||
}
|
||||
return t + " ".repeat(padding);
|
||||
};
|
||||
|
||||
const formatRow = (cells: string[]): string => {
|
||||
const formatted = cells.map((cell, i) => {
|
||||
const align = alignments[i] || "none";
|
||||
return padCell(escapeCell(cell), widths[i], align);
|
||||
});
|
||||
return "| " + formatted.join(" | ") + " |";
|
||||
};
|
||||
|
||||
const separator = widths.map((w, i) => {
|
||||
const align = alignments[i] || "none";
|
||||
const dashes = "-".repeat(w);
|
||||
if (align === "center") return ":" + dashes.slice(1, -1) + ":";
|
||||
if (align === "right") return dashes.slice(0, -1) + ":";
|
||||
if (align === "left") return ":" + dashes.slice(1);
|
||||
return dashes;
|
||||
});
|
||||
const separatorLine = "| " + separator.join(" | ") + " |";
|
||||
|
||||
const headerLine = formatRow(headers);
|
||||
const rowLines = rows.map((row) => formatRow(row));
|
||||
|
||||
return [headerLine, separatorLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find & Replace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TableMatch {
|
||||
/** The raw markdown of the table. */
|
||||
text: string;
|
||||
/** Start index in the source string. */
|
||||
start: number;
|
||||
/** End index (exclusive) in the source string. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all markdown table blocks in a content string.
|
||||
*
|
||||
* Uses a GFM-aware markdown AST parser so that tables without leading/trailing
|
||||
* pipes (e.g. `A | B\n--- | ---\n1 | 2`) are recognised in addition to
|
||||
* fully-fenced `| … |` tables.
|
||||
*/
|
||||
export function findAllTables(content: string): TableMatch[] {
|
||||
const tree = fromMarkdown(content, {
|
||||
extensions: [gfm()],
|
||||
mdastExtensions: [gfmFromMarkdown()],
|
||||
});
|
||||
|
||||
const tables: TableMatch[] = [];
|
||||
visit(tree, "table", (node) => {
|
||||
if (!node.position) return;
|
||||
const start = node.position.start.offset ?? 0;
|
||||
const end = node.position.end.offset ?? content.length;
|
||||
tables.push({ text: content.slice(start, end), start, end });
|
||||
});
|
||||
return tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the nth table in the content with new markdown.
|
||||
*/
|
||||
export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string {
|
||||
const tables = findAllTables(content);
|
||||
if (tableIndex < 0 || tableIndex >= tables.length) return content;
|
||||
|
||||
const table = tables[tableIndex];
|
||||
return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default empty table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a default empty table with the given dimensions.
|
||||
*/
|
||||
export function createEmptyTable(cols = 2, rows = 2): TableData {
|
||||
return {
|
||||
headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`),
|
||||
rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")),
|
||||
alignments: Array.from({ length: cols }, () => "none"),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue