fix: address PR review comments for table editor

Bugs:
- Fix replaceNthTable off-by-one: findAllTables now uses truly exclusive
  end index (start + text.length) so content.slice(start, end) === text
- Replace fragile DOM-based table index resolution with AST-based approach
  using node.position.start.offset from hast ReactMarkdownProps

Architecture:
- Unify TableEditorDialog instances: InsertMenu no longer manages its own
  dialog, instead calls onOpenTableEditor from parent MemoEditor which
  owns the single shared dialog instance
- Remove onInsertText prop chain (InsertMenu → EditorToolbar → MemoEditor)
  replaced by onOpenTableEditor

Other improvements:
- Add i18n: all hardcoded English strings now use useTranslate()/t() with
  new editor.table.* keys in en.json
- Fix useCallback [props] dependency that defeated memoization (removed
  with dialog unification)
- Use stable row IDs (monotonic counter) as React keys instead of array
  indices in TableEditorDialog
- Replace hardcoded MONO_FONT constant with Tailwind font-mono class
  (maps to project's --font-mono CSS variable)
- Add 28 vitest tests for markdown-table.ts covering parse, serialize,
  findAllTables, replaceNthTable, createEmptyTable with edge cases
- Add vitest dev dependency with test/test:watch scripts
This commit is contained in:
milvasic 2026-03-11 21:21:45 +01:00
parent 0aada4230c
commit a759acc6a7
12 changed files with 639 additions and 96 deletions

View File

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

View File

@ -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": [

View File

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

View File

@ -1,10 +1,11 @@
import { PencilIcon, TrashIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
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 { 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 "../MemoView/MemoViewContext";
@ -18,8 +19,8 @@ interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdo
children: React.ReactNode;
}
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
const tableRef = useRef<HTMLDivElement>(null);
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);
@ -29,32 +30,31 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
const { readonly } = useMemoViewDerived();
const { mutate: updateMemo } = useUpdateMemo();
/** Resolve which markdown table index this rendered table corresponds to. */
const resolveTableIndex = useCallback(() => {
const container = tableRef.current?.closest('[class*="wrap-break-word"]');
if (!container) return -1;
/** Resolve which markdown table index this rendered table corresponds to using AST source positions. */
const resolveTableIndex = useMemo(() => {
const nodeStart = node?.position?.start?.offset;
if (nodeStart == null) return -1;
const allTables = container.querySelectorAll("table");
for (let i = 0; i < allTables.length; i++) {
if (tableRef.current?.contains(allTables[i])) return i;
const tables = findAllTables(memo.content);
for (let i = 0; i < tables.length; i++) {
if (nodeStart >= tables[i].start && nodeStart < tables[i].end) return i;
}
return -1;
}, []);
}, [memo.content, node]);
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const idx = resolveTableIndex();
const tables = findAllTables(memo.content);
if (idx < 0 || idx >= tables.length) return;
if (resolveTableIndex < 0 || resolveTableIndex >= tables.length) return;
const parsed = parseMarkdownTable(tables[idx].text);
const parsed = parseMarkdownTable(tables[resolveTableIndex].text);
if (!parsed) return;
setTableData(parsed);
setTableIndex(idx);
setTableIndex(resolveTableIndex);
setDialogOpen(true);
},
[memo.content, resolveTableIndex],
@ -65,10 +65,9 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
e.stopPropagation();
e.preventDefault();
const idx = resolveTableIndex();
if (idx < 0) return;
if (resolveTableIndex < 0) return;
setTableIndex(idx);
setTableIndex(resolveTableIndex);
setDeleteDialogOpen(true);
},
[resolveTableIndex],
@ -99,7 +98,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
return (
<>
<div ref={tableRef} className="group/table relative w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
<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>
@ -109,7 +108,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
type="button"
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
onClick={handleDeleteClick}
title="Delete table"
title={t("common.delete")}
>
<TrashIcon className="size-3.5" />
</button>
@ -117,7 +116,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
type="button"
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={handleEditClick}
title="Edit table"
title={t("common.edit")}
>
<PencilIcon className="size-3.5" />
</button>
@ -131,15 +130,15 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Delete table</DialogTitle>
<DialogDescription>Are you sure you want to delete this table? This action cannot be undone.</DialogDescription>
<DialogTitle>{t("editor.table.delete")}</DialogTitle>
<DialogDescription>{t("editor.table.delete-confirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
<Button variant="ghost">{t("common.cancel")}</Button>
</DialogClose>
<Button variant="destructive" onClick={handleConfirmDelete}>
Delete
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -15,7 +15,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import { useReverseGeocoding } from "@/components/map";
import TableEditorDialog from "@/components/TableEditorDialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -37,11 +36,10 @@ 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);
const [tableDialogOpen, setTableDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
@ -98,17 +96,6 @@ const InsertMenu = (props: InsertMenuProps) => {
setLinkDialogOpen(true);
}, []);
const handleOpenTableDialog = useCallback(() => {
setTableDialogOpen(true);
}, []);
const handleTableConfirm = useCallback(
(markdown: string) => {
props.onInsertText?.(markdown);
},
[props],
);
const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true);
if (!initialLocation && !locationInitialized) {
@ -154,9 +141,9 @@ const InsertMenu = (props: InsertMenuProps) => {
},
{
key: "table",
label: "Table",
label: t("editor.table.title"),
icon: TableIcon,
onClick: handleOpenTableDialog,
onClick: () => onOpenTableEditor?.(),
},
{
key: "link",
@ -171,7 +158,7 @@ const InsertMenu = (props: InsertMenuProps) => {
onClick: handleLocationClick,
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t],
[handleLocationClick, handleOpenLinkDialog, onOpenTableEditor, handleUploadClick, t],
);
return (
@ -238,8 +225,6 @@ const InsertMenu = (props: InsertMenuProps) => {
onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm}
/>
<TableEditorDialog open={tableDialogOpen} onOpenChange={setTableDialogOpen} onConfirm={handleTableConfirm} />
</>
);
};

View File

@ -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, onInsertText }) => {
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onOpenTableEditor }) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state);
@ -34,7 +34,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
location={state.metadata.location}
onLocationChange={handleLocationChange}
onToggleFocusMode={handleToggleFocusMode}
onInsertText={onInsertText}
onOpenTableEditor={onOpenTableEditor}
memoName={memoName}
/>
</div>

View File

@ -209,12 +209,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{/* 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}
onInsertText={(text) => editorRef.current?.insertText(text)}
/>
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onOpenTableEditor={handleOpenTableEditor} />
</div>
</div>

View File

@ -23,7 +23,7 @@ export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
onInsertText?: (text: string) => void;
onOpenTableEditor?: () => void;
}
export interface EditorMetadataProps {
@ -46,7 +46,7 @@ export interface InsertMenuProps {
location?: Location;
onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void;
onInsertText?: (text: string) => void;
onOpenTableEditor?: () => void;
memoName?: string;
}

View File

@ -1,5 +1,6 @@
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";
@ -7,12 +8,6 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } fr
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { VisuallyHidden } from "./ui/visually-hidden";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -31,12 +26,17 @@ type SortState = { col: number; dir: "asc" | "desc" } | null;
// ---------------------------------------------------------------------------
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);
@ -45,14 +45,17 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
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);
@ -105,16 +108,21 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
};
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 ----
@ -123,18 +131,22 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
let newDir: "asc" | "desc" = "asc";
if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc";
setSortState({ col, dir: newDir });
setRows((prev) => {
const sorted = [...prev].sort((a, b) => {
const va = (a[col] || "").toLowerCase();
const vb = (b[col] || "").toLowerCase();
const na = Number(va);
const nb = Number(vb);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na;
const cmp = va.localeCompare(vb);
return newDir === "asc" ? cmp : -cmp;
});
return sorted;
});
const compareFn = (a: string[], b: string[]) => {
const va = (a[col] || "").toLowerCase();
const vb = (b[col] || "").toLowerCase();
const na = Number(va);
const nb = Number(vb);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na;
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 ----
@ -190,10 +202,10 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<DialogClose />
</VisuallyHidden>
<VisuallyHidden>
<DialogTitle>Table Editor</DialogTitle>
<DialogTitle>{t("editor.table.editor-title")}</DialogTitle>
</VisuallyHidden>
<VisuallyHidden>
<DialogDescription>Edit table headers, rows, columns and sort data</DialogDescription>
<DialogDescription>{t("editor.table.editor-description")}</DialogDescription>
</VisuallyHidden>
<div className="flex flex-col h-full">
@ -236,7 +248,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<PlusIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Insert column</TooltipContent>
<TooltipContent>{t("editor.table.insert-column")}</TooltipContent>
</Tooltip>
</div>
@ -244,8 +256,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<div className="flex items-center bg-accent/50 border border-border">
<input
ref={(el) => setInputRef(`-1:${col}`, el)}
style={{ fontFamily: MONO_FONT }}
className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40"
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)}
@ -261,7 +272,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<SortIndicator col={col} />
</button>
</TooltipTrigger>
<TooltipContent>Sort column</TooltipContent>
<TooltipContent>{t("editor.table.sort-column")}</TooltipContent>
</Tooltip>
{colCount > 1 && (
<Tooltip>
@ -274,7 +285,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<TrashIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove column</TooltipContent>
<TooltipContent>{t("editor.table.remove-column")}</TooltipContent>
</Tooltip>
)}
</div>
@ -293,7 +304,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<PlusIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Add column</TooltipContent>
<TooltipContent>{t("editor.table.add-column")}</TooltipContent>
</Tooltip>
</th>
</tr>
@ -302,7 +313,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
{/* ============ DATA ROWS ============ */}
<tbody>
{rows.map((row, rowIdx) => (
<React.Fragment key={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">
@ -325,7 +336,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<PlusIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Insert row</TooltipContent>
<TooltipContent>{t("editor.table.insert-row")}</TooltipContent>
</Tooltip>
</div>
<span className="text-xs text-muted-foreground">{rowIdx + 1}</span>
@ -336,8 +347,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<td key={col} className="p-0">
<input
ref={(el) => setInputRef(`${rowIdx}:${col}`, el)}
style={{ fontFamily: MONO_FONT }}
className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40"
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)}
@ -358,7 +368,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<TrashIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove row</TooltipContent>
<TooltipContent>{t("editor.table.remove-row")}</TooltipContent>
</Tooltip>
)}
</td>
@ -373,7 +383,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: 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" />
Add row
{t("editor.table.add-row")}
</Button>
</div>
</div>
@ -382,23 +392,24 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
<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 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"}
{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" />
Add row
{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" />
Add column
{t("editor.table.add-column")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="cursor-pointer" onClick={() => onOpenChange(false)}>
Cancel
{t("common.cancel")}
</Button>
<Button className="cursor-pointer" onClick={handleConfirm}>
Confirm
{t("common.confirm")}
</Button>
</div>
</div>

View File

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

View File

@ -0,0 +1,308 @@
import { describe, expect, it } from "vitest";
import {
createEmptyTable,
findAllTables,
parseMarkdownTable,
replaceNthTable,
serializeMarkdownTable,
type TableData,
} from "./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 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);
});
});
// ---------------------------------------------------------------------------
// 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"]);
});
});

View File

@ -161,11 +161,10 @@ export function findAllTables(content: string): TableMatch[] {
offset += lines[i].length + 1; // +1 for newline
i++;
}
const endOffset = offset - 1; // exclude trailing newline
const text = lines.slice(startLine, i).join("\n");
// Only count if it has at least a header + separator (2 lines).
if (i - startLine >= 2) {
tables.push({ text, start: startOffset, end: endOffset });
tables.push({ text, start: startOffset, end: startOffset + text.length });
}
} else {
offset += lines[i].length + 1;