From d42d60d880a63685a942269945c295a96ccafe8f Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Mon, 6 Jul 2026 21:20:52 +0200 Subject: [PATCH 01/22] fix: link color --- .../app/src/features/NoteEditor/RichEditor/theme/RichEditor.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css index ce7506a70..bb53fc64c 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css +++ b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css @@ -115,7 +115,7 @@ .RichEditor__link { display: inline-block; - color: var(--chakra-colors-link-base); + color: var(--chakra-colors-link); text-decoration: inherit; } From 1213fd8c4c2d639daaa65b3b69587d323fab603b Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Mon, 6 Jul 2026 21:21:08 +0200 Subject: [PATCH 02/22] fix: css custom props name --- .../src/features/NoteEditor/RichEditor/theme/RichEditor.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css index bb53fc64c..e623e9f44 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css +++ b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css @@ -102,7 +102,7 @@ .RichEditor__textCode { background-color: var(--chakra-colors-dim-100); - color: var(--chakra-colors-typography-base); + color: var(--chakra-colors-typography); padding: 1px 0.25rem; font-family: Menlo, Consolas, Monaco, monospace; font-size: 94%; @@ -129,7 +129,7 @@ --block-padding: 0.8rem; background-color: var(--chakra-colors-dim-100); - color: var(--chakra-colors-typography-base); + color: var(--chakra-colors-typography); font-family: Menlo, Consolas, Monaco, monospace; display: block; padding: var(--block-padding); From 19539a2857372666c6dbae3fc98095e320446054 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Mon, 6 Jul 2026 21:44:38 +0200 Subject: [PATCH 03/22] fix: consistent selection style --- packages/app/src/components/theme/base.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/theme/base.ts b/packages/app/src/components/theme/base.ts index b27b19bbf..34c02e04b 100644 --- a/packages/app/src/components/theme/base.ts +++ b/packages/app/src/components/theme/base.ts @@ -14,12 +14,7 @@ ============================================================ */ /* eslint-disable @cspell/spellchecker */ -import { - defineConfig, - defineRecipe, - defineSlotRecipe, - SystemStyleObject, -} from '@chakra-ui/react'; +import { defineConfig, defineRecipe, defineSlotRecipe } from '@chakra-ui/react'; import { alertAnatomy, dialogAnatomy, @@ -82,6 +77,13 @@ export const getScrollBarStyles = (): Record => { export default defineConfig({ globalCss: { + html: { + '& *::selection': { + color: 'selection.foreground', + backgroundColor: 'selection.background', + }, + }, + body: { margin: 0, backgroundColor: 'var(--chakra-colors-surface-background)', @@ -97,12 +99,6 @@ export default defineConfig({ 'Segoe UI Emoji'`, }, - ':root': { - '::selection': { - color: 'selection.foreground', - backgroundColor: 'selection.background', - }, - } as SystemStyleObject, ...getScrollBarStyles(), }, From 719e55f28547bb7342bc95b6f220449a985a0084 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 7 Jul 2026 13:52:06 +0200 Subject: [PATCH 04/22] chore: update packages --- package-lock.json | 583 +++++++++++++++++++++++++------------- packages/app/package.json | 4 +- 2 files changed, 389 insertions(+), 198 deletions(-) diff --git a/package-lock.json b/package-lock.json index a30dd0d70..a0248318f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6184,13 +6184,13 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.27.17", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", - "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.7", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -6199,12 +6199,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.5" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -7557,278 +7557,468 @@ "license": "MIT" }, "node_modules/@lexical/clipboard": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.39.0.tgz", - "integrity": "sha512-ylrHy8M+I5EH4utwqivslugqQhvgLTz9VEJdrb2RjbhKQEXwMcqKCRWh6cRfkYx64onE2YQE0nRIdzHhExEpLQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.46.0.tgz", + "integrity": "sha512-c7lQeORx5+2tfc9+xnHDPBT097t9KdWW3a/kg/ThliokHOkaYz6OeM2z53uID/OOpRGao8FRO5vw9rz5ZAhg+w==", "license": "MIT", "dependencies": { - "@lexical/html": "0.39.0", - "@lexical/list": "0.39.0", - "@lexical/selection": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/extension": "0.46.0", + "@lexical/html": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/list": "0.46.0", + "@lexical/selection": "0.46.0", + "@lexical/utils": "0.46.0", + "@types/trusted-types": "^2.0.7", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@lexical/code": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.39.0.tgz", - "integrity": "sha512-3tqFOOzP5Z9nRkZPHZYmIyLXd28gMMrlAD3k2zxiH5vGnAqiYTezR24CpRDw1BaF2c8vCgY/9CNobZzjXivpIA==", + "node_modules/@lexical/code-core": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.46.0.tgz", + "integrity": "sha512-ljlMBSvjTwKVNV7myEr4xJRGTDsJ3kcjHi6rZuBteZExnd/tD44oSY3TSa1jgy+TqiXkEdUpWQYqmnwMsOJrlQ==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.39.0", - "lexical": "0.39.0", - "prismjs": "^1.30.0" + "@lexical/extension": "0.46.0", + "@lexical/html": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/devtools-core": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.39.0.tgz", - "integrity": "sha512-2ET2nFeRhcc2YMrn184wxoEOTLl3UOlugi8ozuZFa6F4UDMXPq7nZRhiQNgYzhE6Z7NLMFrcmghvx652JbEowg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.46.0.tgz", + "integrity": "sha512-9J5dRO/tZXM/ROeSRvnkfMvfGa4AbOjDQmVFhK1WiuoPNBoC45m4hijE4R3/l/Z6xvjinc804PfAsAxVjgVVnA==", "license": "MIT", "dependencies": { - "@lexical/html": "0.39.0", - "@lexical/link": "0.39.0", - "@lexical/mark": "0.39.0", - "@lexical/table": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/html": "0.46.0", + "@lexical/link": "0.46.0", + "@lexical/mark": "0.46.0", + "@lexical/table": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" }, "peerDependencies": { "react": ">=17.x", - "react-dom": ">=17.x" + "react-dom": ">=17.x", + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/dragon": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.39.0.tgz", - "integrity": "sha512-JkcBAYPZGzfs29gtkePeJG9US1uwKW6PkUt8G4QZkMTt4QMDnadqXauFE+30rbpvRdeNcR7s+/jOuRHd5SurDQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.46.0.tgz", + "integrity": "sha512-/PIjscXlD0jp3Bj5MWJ+y1WZsVxagsqnnzOkqdDStM3033soj/2cI7tysMU/7XAEJtMBk1bTJdDvmDHM/bn/rg==", "license": "MIT", "dependencies": { - "@lexical/extension": "0.39.0", - "lexical": "0.39.0" + "@lexical/extension": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/extension": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.39.0.tgz", - "integrity": "sha512-mp/WcF8E53FWPiUHgHQz382J7u7C4+cELYNkC00dKaymf8NhS6M65Y8tyDikNGNUcLXSzaluwK0HkiKjTYGhVQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.46.0.tgz", + "integrity": "sha512-bdO/ON5dwVwtqeTes/+My/sRU7vNuH3W8ovJ2yffXRFmAxVtzKr6/6TYJWwtUvncQjvTHEKAo5MqWUoQXp8VMw==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.39.0", - "@preact/signals-core": "^1.11.0", - "lexical": "0.39.0" + "@lexical/internal": "0.46.0", + "@lexical/utils": "0.46.0", + "@preact/signals-core": "^1.14.1", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/hashtag": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.39.0.tgz", - "integrity": "sha512-CFLNB74a607nC2GGcjKNPbo/ZnehnR3zz9+S5bfUg5dblSGKdCfxHiyr2cDwHY3dfOTu+qtimfh2Zqxz4dfghA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.46.0.tgz", + "integrity": "sha512-6XiL5/KXyCxR+oD7fWjJL3enqsrkn4hgjEa13wAQrBZ5W+avFl/9Cd5bkwcXS+jPjmhk47+zYj76l6uuiFoMoQ==", "license": "MIT", "dependencies": { - "@lexical/text": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/text": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/history": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.39.0.tgz", - "integrity": "sha512-kuctleDime0tRDxQNDW8i5d6D/ys5Npp2yoCBmdKS8HfS/jz7uPumfZcX7wvUvNAEVExh+bY9IxqIexyGkNUtA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.46.0.tgz", + "integrity": "sha512-PbbyXVfbm/RBS/hkPJrAGd6DYZ8/4E+nyPaiArwo4WGYQBoEXc2dU+dwAcIrhwVVXuOVrkm9Hp0cZYl4hKM8qQ==", "license": "MIT", "dependencies": { - "@lexical/extension": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/extension": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/html": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.39.0.tgz", - "integrity": "sha512-7VLWP5DpzBg3kKctpNK6PbhymKAtU6NAnKieopCfCIWlMW+EqpldteiIXGqSqrMRK0JWTmF1gKgr9nnQyOOsXw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.46.0.tgz", + "integrity": "sha512-Rvke8FvQkjd+hFBCe39YdQxPQZZ/7W95xilY3qFxmSZaW0zakcA8pItaf7HfueG8Ja6jDu/TOMH4LUeyQPT3HQ==", "license": "MIT", "dependencies": { - "@lexical/selection": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/extension": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/selection": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/internal": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/internal/-/internal-0.46.0.tgz", + "integrity": "sha512-OV1ePAAnVVd0K+Bq5SX7vOKE/LqJLYcDM4HD4zVCisbgVaznE+pTj/nDB2uWTidwf8o7QhPR65VhEi+KkUtyEw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/link": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.39.0.tgz", - "integrity": "sha512-L1jSF2BVRHDqIQbKYFcQt3CqtVIphRA3QAW2VooYPNlKeaAb/yfFS+C60GX1cj96b0rMlHKrNC17ik2aEBZKLQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.46.0.tgz", + "integrity": "sha512-MdGiAOAmqnZKtfaQxKFZv18SC+1zJIOfqB7bR43va/rp4M3u1cVniquUCTALub40WEWTuJgT2XvfSlpKm7FuXA==", "license": "MIT", "dependencies": { - "@lexical/extension": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/extension": "0.46.0", + "@lexical/html": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/list": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.39.0.tgz", - "integrity": "sha512-mxgSxUrakTCHtC+gF30BChQBJTsCMiMgfC2H5VvhcFwXMgsKE/aK9+a+C/sSvvzCmPXqzYsuAcGkJcrY3e5xlw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.46.0.tgz", + "integrity": "sha512-nIX3k05MFAhWZvVXZBZB7LTS1DhsCF2iCFH2WDYebwJfADTnAqhE/ZCAJax84RRMjYla67JU31br9r+P6KR+hA==", "license": "MIT", "dependencies": { - "@lexical/extension": "0.39.0", - "@lexical/selection": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/extension": "0.46.0", + "@lexical/html": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/mark": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.39.0.tgz", - "integrity": "sha512-wVs5498dWYOQ07FAHaFW6oYgNG3moBargf6es7+gHPzjlaoZ6Hd8sbvJtlT8F2RRlw+U+kUh4s8SjFuMSEJp0w==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.46.0.tgz", + "integrity": "sha512-WRpMATEhci3LxCgF4wN3mX2IIctTN5Zx8GQZU4rtbVBWHaYRA0azpyzsHeFJlpaaI0FJVMWfNQJ3QBeVIr6Ciw==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/markdown": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.39.0.tgz", - "integrity": "sha512-mPaKH2FSwRwU2bDbMiMtdOridaEvSLU3Q5l7bqYE+TW799C/1EEtiv4xSkI01SjV9YOxNf24VNOipAMymPueKA==", - "license": "MIT", - "dependencies": { - "@lexical/code": "0.39.0", - "@lexical/link": "0.39.0", - "@lexical/list": "0.39.0", - "@lexical/rich-text": "0.39.0", - "@lexical/text": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" - } - }, - "node_modules/@lexical/offset": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.39.0.tgz", - "integrity": "sha512-8p+16AgFsG8ecZVQlFO6TQ+zHHHg7LKPNdm9BkklkJux41Y1+9rlPO12Mgbi4x2Hy2pRA8Gd/Su3hySGqEEVlA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.46.0.tgz", + "integrity": "sha512-2e/JFuS+7QEPLW4K2SOoYORpLXhdjFBh7l5OxAf8HljuFAvX0x9eFBxlWVG0Y00TFUMnOcUm1QnIo5l2e+0Cjg==", "license": "MIT", "dependencies": { - "lexical": "0.39.0" + "@lexical/code-core": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/link": "0.46.0", + "@lexical/list": "0.46.0", + "@lexical/rich-text": "0.46.0", + "@lexical/selection": "0.46.0", + "@lexical/text": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/overflow": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.39.0.tgz", - "integrity": "sha512-BLtF4MNDrTNQFgryw6MPWh2Fj4GMjqC/6p9bbnZ9fdwMWKGSbsSNcK9PLlBwg3IzEK3XiibFDHUbsETwUd/bfw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.46.0.tgz", + "integrity": "sha512-VRa/VovH+FeuL9oyEjqF0zM2Nu4stgPs78Ek0mm9SR3EV1Cq58CoqjMvz/eH+4OsYMksFNfkxBg5f4a7QSAeDQ==", "license": "MIT", "dependencies": { - "lexical": "0.39.0" + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/plain-text": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.39.0.tgz", - "integrity": "sha512-Ep0PGF7GlBNgiJJh/DBEPLt1WXyHUb7bCYZ4MUbD31AiJdG0p5a/g9dVTUr4QtNlCIXBCZjuatHyp6e2mzMacg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.46.0.tgz", + "integrity": "sha512-fH1MYHrilsho6hkpGHnjw2eFBeLaJzz5PY4LT9ZVxtliPB3YRKUYZLcP23fGidzOC2it2xvTkT9D7kFo7zTQug==", "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.39.0", - "@lexical/dragon": "0.39.0", - "@lexical/selection": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/clipboard": "0.46.0", + "@lexical/dragon": "0.46.0", + "@lexical/extension": "0.46.0", + "@lexical/selection": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/react": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.39.0.tgz", - "integrity": "sha512-6ySVb5xv99GIkVzio4qqOBxkPgOSSeFAB4o9bVqtg72JbCoEKZPnWq5VVurGe1uiRJM8jvqTseM9mo2zTvUfXQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.27.16", - "@lexical/devtools-core": "0.39.0", - "@lexical/dragon": "0.39.0", - "@lexical/extension": "0.39.0", - "@lexical/hashtag": "0.39.0", - "@lexical/history": "0.39.0", - "@lexical/link": "0.39.0", - "@lexical/list": "0.39.0", - "@lexical/mark": "0.39.0", - "@lexical/markdown": "0.39.0", - "@lexical/overflow": "0.39.0", - "@lexical/plain-text": "0.39.0", - "@lexical/rich-text": "0.39.0", - "@lexical/table": "0.39.0", - "@lexical/text": "0.39.0", - "@lexical/utils": "0.39.0", - "@lexical/yjs": "0.39.0", - "lexical": "0.39.0", - "react-error-boundary": "^6.0.0" + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.46.0.tgz", + "integrity": "sha512-yYz09UTTSVFFpU6Zku/m4LLGgg6qjnz83PoPf7rxx7/IuZHcJK6dCaApXZUdilMHf+A1gGG0W4UNXGjWVaX1nw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.19", + "@lexical/devtools-core": "0.46.0", + "@lexical/dragon": "0.46.0", + "@lexical/extension": "0.46.0", + "@lexical/hashtag": "0.46.0", + "@lexical/history": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/link": "0.46.0", + "@lexical/list": "0.46.0", + "@lexical/mark": "0.46.0", + "@lexical/markdown": "0.46.0", + "@lexical/overflow": "0.46.0", + "@lexical/plain-text": "0.46.0", + "@lexical/rich-text": "0.46.0", + "@lexical/table": "0.46.0", + "@lexical/text": "0.46.0", + "@lexical/utils": "0.46.0", + "@lexical/yjs": "0.46.0", + "lexical": "0.46.0" }, "peerDependencies": { "react": ">=17.x", - "react-dom": ">=17.x" + "react-dom": ">=17.x", + "typescript": ">=5.2", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "yjs": { + "optional": true + } } }, "node_modules/@lexical/rich-text": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.39.0.tgz", - "integrity": "sha512-UoSgRi09nLP/mmD3ijdZycr9icnqlb761rzHC1gicuPDdTu0ruxAFbGanSE2h36ihSu0IUHwkpf4gBpgPPqWBw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.46.0.tgz", + "integrity": "sha512-YwERYF7bOGZOmulxzyHID+gUkxCbTbn73pqow8GyasAdwlbTiiyqKSUp3fXC3prcMftQX8eg+SJNwkijvZk9yA==", "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.39.0", - "@lexical/dragon": "0.39.0", - "@lexical/selection": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/clipboard": "0.46.0", + "@lexical/dragon": "0.46.0", + "@lexical/extension": "0.46.0", + "@lexical/html": "0.46.0", + "@lexical/selection": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/selection": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.39.0.tgz", - "integrity": "sha512-j0cgNuTKDCdf/4MzRnAUwEqG6C/WQp18k2WKmX5KIVZJlhnGIJmlgSBrxjo8AuZ16DIHxTm2XNB4cUDCgZNuPA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.46.0.tgz", + "integrity": "sha512-iWrztXnBl9x7DthlRefUCNHfY8lHU6AzDm7pIr3o7t1hoixDtTuiZ5cCw+PkShC/hZ8v7VuV+FaDNraSaTTkHA==", "license": "MIT", "dependencies": { - "lexical": "0.39.0" + "@lexical/internal": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/table": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.39.0.tgz", - "integrity": "sha512-1eH11kV4bJ0fufCYl8DpE19kHwqUI8Ev5CZwivfAtC3ntwyNkeEpjCc0pqeYYIWN/4rTZ5jgB3IJV4FntyfCzw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.46.0.tgz", + "integrity": "sha512-azgbCn+CxyGF2wVFB5Rmcx3wFKR9WKiGvJUUh8YDnMxL9LMWESfFy36XbedR140xUq0QoYTw8ZxBGMHE4ZiIIg==", "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.39.0", - "@lexical/extension": "0.39.0", - "@lexical/utils": "0.39.0", - "lexical": "0.39.0" + "@lexical/clipboard": "0.46.0", + "@lexical/extension": "0.46.0", + "@lexical/html": "0.46.0", + "@lexical/internal": "0.46.0", + "@lexical/utils": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/text": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.39.0.tgz", - "integrity": "sha512-fcIgejtIgfMAkxio6BO1eLA2eb4oRIFoUVA2jAXdCaLVHrG/cizitbygPrgWnWd8nt1WlMuS4lxa0PJl7h7Lqg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.46.0.tgz", + "integrity": "sha512-XjH6ry2vYOfTO1JOLdbjlZ8he8LjkFjLbVIk+1I/q+8vp5ADx5GK99KaQfBiPZb0WTJEkuqo4AELWn3sXMvAiw==", "license": "MIT", "dependencies": { - "lexical": "0.39.0" + "@lexical/internal": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/utils": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.39.0.tgz", - "integrity": "sha512-8YChidpMJpwQc4nex29FKUeuZzC++QCS/Jt46lPuy1GS/BZQoPHFKQ5hyVvM9QVhc5CEs4WGNoaCZvZIVN8bQw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.46.0.tgz", + "integrity": "sha512-s8XQDoBr1wyPKGqy3qyDeHvi+vMgOiszzcRdHvTd6AqWpXxw2MsDQzMdF3Nr56QHahcOYxMOcv+r7r2nnCcgCA==", "license": "MIT", "dependencies": { - "@lexical/list": "0.39.0", - "@lexical/selection": "0.39.0", - "@lexical/table": "0.39.0", - "lexical": "0.39.0" + "@lexical/internal": "0.46.0", + "@lexical/selection": "0.46.0", + "lexical": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/yjs": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.39.0.tgz", - "integrity": "sha512-peBrzIDoRWeyX9XTilKVdeJua6A+RZ24CG7lgGLEhmNSGCqpj9FqlC1Wtrul4wTSh85KlDeI1Nq30gnyeNKWYA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.46.0.tgz", + "integrity": "sha512-CsDD3NpmhoHjqnvbwQoTa3/lDYOCqlpBhRJcE14ERfngZ3PpoNu+YUW6IbhJ5PVFvtub8H3x8V9OjZ1IbHvZjg==", "license": "MIT", "dependencies": { - "@lexical/offset": "0.39.0", - "@lexical/selection": "0.39.0", - "lexical": "0.39.0" + "@lexical/internal": "0.46.0", + "@lexical/selection": "0.46.0", + "lexical": "0.46.0" }, "peerDependencies": { + "typescript": ">=5.2", "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@listr2/prompt-adapter-inquirer": { @@ -8543,9 +8733,9 @@ } }, "node_modules/@preact/signals-core": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.2.tgz", - "integrity": "sha512-5Yf8h1Ke3SMHr15xl630KtwPTW4sYDFkkxS0vQ8UiQLWwZQnrF9IKaVG1mN5VcJz52EcWs2acsc/Npjha/7ysA==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", "license": "MIT", "funding": { "type": "opencollective", @@ -10715,8 +10905,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/unbzip2-stream": { "version": "1.4.3", @@ -23297,10 +23486,21 @@ } }, "node_modules/lexical": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.39.0.tgz", - "integrity": "sha512-lpLv7MEJH5QDujEDlYqettL3ATVtNYjqyimzqgrm0RvCm3AO9WXSdsgTxuN7IAZRu88xkxCDeYubeUf4mNZVdg==", - "license": "MIT" + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.46.0.tgz", + "integrity": "sha512-oaE7OLY/FuqGKC7ihaqLJvgn/NvvRveryKBBgOwrSe+UIpBmc9IvyXf6hVcvew9M3Vc0XavHWe/ZAL7qc5DzMw==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.46.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, "node_modules/lib0": { "version": "0.2.117", @@ -28237,15 +28437,6 @@ "react": ">= 16.8 || 18.0.0" } }, - "node_modules/react-error-boundary": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.0.tgz", - "integrity": "sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA==", - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, "node_modules/react-focus-lock": { "version": "2.13.7", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.7.tgz", @@ -35051,9 +35242,9 @@ } }, "node_modules/yjs": { - "version": "13.6.29", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", - "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", "license": "MIT", "peer": true, "dependencies": { @@ -35183,7 +35374,7 @@ "@headless-tree/core": "^1.7.0", "@headless-tree/react": "^1.7.0", "@hookform/resolvers": "^5.2.2", - "@lexical/react": "^0.39.0", + "@lexical/react": "^0.46.0", "@msgpack/msgpack": "^3.1.3", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-virtual": "^3.14.2", @@ -35217,7 +35408,7 @@ "i18next-http-backend": "^3.0.4", "idb-keyval": "^6.2.2", "leviathan-crypto": "^1.4.0", - "lexical": "^0.39.0", + "lexical": "^0.46.0", "libsodium-wrappers-sumo": "^0.8.2", "lodash": "^4.17.23", "lru-cache": "^11.2.7", diff --git a/packages/app/package.json b/packages/app/package.json index dc357e1a6..e3f2419a9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -109,7 +109,7 @@ "@headless-tree/core": "^1.7.0", "@headless-tree/react": "^1.7.0", "@hookform/resolvers": "^5.2.2", - "@lexical/react": "^0.39.0", + "@lexical/react": "^0.46.0", "@msgpack/msgpack": "^3.1.3", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-virtual": "^3.14.2", @@ -143,7 +143,7 @@ "i18next-http-backend": "^3.0.4", "idb-keyval": "^6.2.2", "leviathan-crypto": "^1.4.0", - "lexical": "^0.39.0", + "lexical": "^0.46.0", "libsodium-wrappers-sumo": "^0.8.2", "lodash": "^4.17.23", "lru-cache": "^11.2.7", From 80da8431d7de24b5fac6e9c0eebca01de55b6877 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 7 Jul 2026 20:35:35 +0200 Subject: [PATCH 05/22] chore: improve CSS --- .../RichEditor/theme/RichEditor.css | 263 +++++++++++------- 1 file changed, 159 insertions(+), 104 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css index e623e9f44..9219bd5ce 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css +++ b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css @@ -1,91 +1,134 @@ +/* stylelint-disable no-descending-specificity */ +/* stylelint-disable rule-empty-line-before */ + /** * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * + * MIT license. */ + +/* ============================================================ + SPACING & INDENT TOKENS + Two tiers only, by design: + - FLOW gap: distance between any two ordinary blocks + (paragraph, list, quote, code, table, embed, layout). + Visual style (border/background) already marks these as + distinct — spacing doesn't need to repeat that signal. + - BREAK gap: reserved exclusively for headings and
, + the only elements whose entire job is "a new section + starts here." Must look decisively larger than FLOW gap, + not just "a bit more." + ============================================================ */ +.RichEditor { + --re-flow-gap: 1rem; /* 16px – between any two ordinary blocks */ + --re-item-gap: 0.25rem; /* 4px – between items inside one list */ + --re-break-major: 3rem; /* 48px – h1/h2/h3 top margin, hr margin */ + --re-break-minor: 1.5rem; /* 24px – h4/h5/h6 top margin */ + --re-bond-major: 0.75rem; /* 12px – h1/h2/h3 bottom margin (bond to body) */ + --re-bond-minor: 0.5rem; /* 8px – h4/h5/h6 bottom margin */ + --re-indent: 1.5rem; /* 24px – shared left indent for ALL list types */ +} + +/* First block in a note never carries a leading break/flow gap */ +.RichEditor > :first-child { + margin-top: 0 !important; +} + .RichEditor__ltr { text-align: left; } - .RichEditor__rtl { text-align: right; } +/* ============================================================ + FLOWING TEXT + ============================================================ */ .RichEditor__paragraph { - margin: 0; + margin: 0 0 var(--re-flow-gap) 0; + line-height: 1.6; position: relative; } -.RichEditor__quote { - margin: 0; - margin-left: 10px; - margin-bottom: 10px; - font-size: 15px; - color: var(--chakra-colors-typography-secondary); - border-left: 4px solid var(--chakra-colors-surface-border); - padding-left: 16px; +/* ============================================================ + HEADINGS + Deterministic spacing: headings own the ONLY margin-top in + the system, so collapsing always resolves in their favor. + font-size carries "which level"; margin carries "new section + (h1–h3)" vs "new subsection (h4–h6)" — one signal per job. + ============================================================ */ +.RichEditor__h1, +.RichEditor__h2, +.RichEditor__h3, +.RichEditor__h4, +.RichEditor__h5, +.RichEditor__h6 { + line-height: 1.3; + font-weight: 600; +} + +.RichEditor__h1, +.RichEditor__h2, +.RichEditor__h3 { + margin: var(--re-break-major) 0 var(--re-bond-major) 0; } .RichEditor__h4, .RichEditor__h5, .RichEditor__h6 { - margin-bottom: 0.2em; + margin: var(--re-break-minor) 0 var(--re-bond-minor) 0; } .RichEditor__h1 { - padding-bottom: 0.3em; - margin-bottom: 0.5em; - font-size: 2em; + font-size: 2rem; + padding-bottom: 0.3em; /* em: proportional to THIS heading's own size */ border-bottom: 1px solid var(--chakra-colors-surface-border); } .RichEditor__h2 { + font-size: 1.5rem; padding-bottom: 0.3em; - margin-bottom: 0.4em; - font-size: 1.5em; border-bottom: 1px solid var(--chakra-colors-surface-border); } .RichEditor__h3 { - margin-bottom: 0.4em; - font-size: 1.25em; + font-size: 1.25rem; } .RichEditor__h4 { - font-size: 1em; + font-size: 1.1rem; } .RichEditor__h5 { - font-size: 0.875em; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--chakra-colors-typography-secondary); } .RichEditor__h6 { - font-size: 0.85em; + font-size: 0.9rem; + color: var(--chakra-colors-typography-secondary); } .RichEditor__indent { --lexical-indent-base-value: 40px; } +/* ============================================================ + INLINE MARKS — purely typographic, no spacing concerns + ============================================================ */ .RichEditor__textBold { font-weight: bold; } - .RichEditor__textItalic { font-style: italic; } - .RichEditor__textUnderline { text-decoration: underline; } - .RichEditor__textStrikethrough { text-decoration: line-through; } - .RichEditor__textUnderlineStrikethrough { text-decoration: underline line-through; } @@ -103,9 +146,9 @@ .RichEditor__textCode { background-color: var(--chakra-colors-dim-100); color: var(--chakra-colors-typography); - padding: 1px 0.25rem; + padding: 0.0625rem 0.25rem; font-family: Menlo, Consolas, Monaco, monospace; - font-size: 94%; + font-size: 0.9em; } .RichEditor__hashtag { @@ -125,6 +168,20 @@ cursor: pointer; } +/* ============================================================ + ORDINARY BLOCKS — quote / code / table / embed / layout. + Same FLOW gap as a paragraph. Their border/background/font + already signals "distinct" — spacing doesn't need to repeat it. + ============================================================ */ +.RichEditor__quote { + margin: 0 0 var(--re-flow-gap) 0; + padding-left: 1rem; + border-left: 3px solid var(--chakra-colors-surface-border); + font-size: 1rem; + line-height: 1.6; + color: var(--chakra-colors-typography-secondary); +} + .RichEditor__code { --block-padding: 0.8rem; @@ -134,10 +191,8 @@ display: block; padding: var(--block-padding); line-height: 1.53; - font-size: 13px; - margin: 0; - margin-top: 8px; - margin-bottom: 8px; + font-size: 0.85rem; + margin: 0 0 var(--re-flow-gap) 0; overflow-x: auto; position: relative; tab-size: 2; @@ -158,6 +213,7 @@ } .RichEditor__table { + margin: 0 0 var(--re-flow-gap) 0; border-collapse: collapse; border-spacing: 0; overflow: scroll; @@ -182,7 +238,7 @@ width: 75px; vertical-align: top; text-align: start; - padding: 6px 8px; + padding: 0.4rem 0.5rem; position: relative; outline: none; } @@ -242,8 +298,8 @@ cursor: pointer; } -.RichEditor__tableAddColumns::after { - /* background-image: url(../images/icons/plus.svg); */ +.RichEditor__tableAddColumns::after, +.RichEditor__tableAddRows::after { background-size: contain; background-position: center; background-repeat: no-repeat; @@ -271,26 +327,10 @@ background-color: #c9dbf0; } -.RichEditor__tableAddRows::after { - /* background-image: url(../images/icons/plus.svg); */ - background-size: contain; - background-position: center; - background-repeat: no-repeat; - display: block; - content: ' '; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.4; -} - @keyframes table-controls { 0% { opacity: 0; } - 100% { opacity: 1; } @@ -335,72 +375,89 @@ background-color: #fbb !important; } -.RichEditor__ol1 { - padding: 0; - margin: 0; +/* ============================================================ + LISTS + All list types (bullet / ordered / checklist) share ONE + indent token, applied via padding-left (not margin), so + markers of every list type sit on the same vertical line and + nested lists stack the indent predictably. + The list container itself behaves like a paragraph in the + flow (same FLOW gap). Items inside a list get only a small + ITEM gap, small enough to still read as "one group." + ============================================================ */ +.RichEditor__ol1, +.RichEditor__ol2, +.RichEditor__ol3, +.RichEditor__ol4, +.RichEditor__ol5, +.RichEditor__ul { + margin: 0 0 var(--re-flow-gap) 0; + padding: 0 0 0 var(--re-indent); list-style-position: outside; } .RichEditor__ol2 { - padding: 0; - margin: 0; list-style-type: upper-alpha; - list-style-position: outside; } - .RichEditor__ol3 { - padding: 0; - margin: 0; list-style-type: lower-alpha; - list-style-position: outside; } - .RichEditor__ol4 { - padding: 0; - margin: 0; list-style-type: upper-roman; - list-style-position: outside; } - .RichEditor__ol5 { - padding: 0; - margin: 0; list-style-type: lower-roman; - list-style-position: outside; } -.RichEditor__ul { - padding: 0; - margin: 0; - list-style-position: outside; +/* Nested lists (a
    /
      as a direct child of a list item) + shouldn't add an extra FLOW gap — they're still one list. */ +.RichEditor__listItem > .RichEditor__ul, +.RichEditor__listItem > .RichEditor__ol1, +.RichEditor__listItem > .RichEditor__ol2, +.RichEditor__listItem > .RichEditor__ol3, +.RichEditor__listItem > .RichEditor__ol4, +.RichEditor__listItem > .RichEditor__ol5 { + margin-bottom: 0; + margin-top: var(--re-item-gap); } .RichEditor__listItem { - margin: 0 32px; + margin: 0 0 var(--re-item-gap) 0; + line-height: 1.6; +} + +.RichEditor__listItem:last-child { + margin-bottom: 0; } .RichEditor__listItemChecked, .RichEditor__listItemUnchecked { position: relative; - margin-left: 8px; - margin-right: 8px; - padding-left: 24px; - padding-right: 24px; + margin: 0 0 var(--re-item-gap) 0; + padding-left: var(--re-indent); list-style-type: none; outline: none; + line-height: 1.6; +} + +.RichEditor__listItemChecked:last-child, +.RichEditor__listItemUnchecked:last-child { + margin-bottom: 0; } .RichEditor__listItemChecked { text-decoration: line-through; } +/* Checkbox is a fixed-size UI control, not typographic content + — px is correct here, per our units rule. */ .RichEditor__listItemUnchecked::before, .RichEditor__listItemChecked::before { content: ''; width: 16px; height: 16px; - top: 2px; - left: 0; + top: 3px; + left: 4px; cursor: pointer; display: block; background-size: cover; @@ -422,7 +479,7 @@ .RichEditor__listItemUnchecked[dir='rtl']::before, .RichEditor__listItemChecked[dir='rtl']::before { left: auto; - right: 0; + right: 4px; } .RichEditor__listItemUnchecked:focus::before, @@ -438,10 +495,10 @@ border-style: solid; position: absolute; display: block; - top: 6px; + top: 7px; width: 3px; - left: 7px; - right: 7px; + left: 11px; + right: 11px; height: 6px; transform: rotate(45deg); border-width: 0 2px 2px 0; @@ -451,39 +508,30 @@ ul > li.RichEditor__listItem:not([role='checkbox']) { list-style: disc; } -/* -* Based on "Gruvbox light" theme -* -* Source: https://github.com/PrismJS/prism-themes/blob/447479fc7b2be2051fe27e561aceed7cc87a589f/themes/prism-gruvbox-light.css -*/ +/* ============================================================ + CODE TOKENS — unchanged, purely color + ============================================================ */ .RichEditor__tokenComment { color: var(--chakra-colors-code-token-comment); } - .RichEditor__tokenPunctuation { color: var(--chakra-colors-code-token-punctuation); } - .RichEditor__tokenProperty { color: var(--chakra-colors-code-token-property); } - .RichEditor__tokenSelector { color: var(--chakra-colors-code-token-selector); } - .RichEditor__tokenOperator { color: var(--chakra-colors-code-token-operator); } - .RichEditor__tokenAttr { color: var(--chakra-colors-code-token-attr); } - .RichEditor__tokenVariable { color: var(--chakra-colors-code-token-variable); } - .RichEditor__tokenFunction { color: var(--chakra-colors-code-token-function); } @@ -510,6 +558,7 @@ ul > li.RichEditor__listItem:not([role='checkbox']) { } .RichEditor__embedBlock { + margin: 0 0 var(--re-flow-gap) 0; user-select: none; } @@ -519,24 +568,30 @@ ul > li.RichEditor__listItem:not([role='checkbox']) { .RichEditor__layoutContainer { display: grid; - gap: 10px; - margin: 10px 0; + gap: var(--re-flow-gap); + margin: 0 0 var(--re-flow-gap) 0; } .RichEditor__layoutItem { border: 1px dashed #ddd; - padding: 8px 16px; + padding: 0.5rem 1rem; } .RichEditor__autocomplete { color: #ccc; } +/* ============================================================ +
      — the strongest possible break in the document (a topic + change with no label at all). Gets the SAME margin as a + major heading break, symmetric on both sides since it has + no "content it bonds to" the way a heading does. + ============================================================ */ .RichEditor__hr { height: 1px; padding: 0; border: none; - margin: 1em 0; + margin: var(--re-break-major) 0; cursor: pointer; } From 1d496c640b2810d59026860c76069bdbfb0ebab4 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 7 Jul 2026 20:38:38 +0200 Subject: [PATCH 06/22] chore: update imports --- .../app/src/features/NoteEditor/RichEditor/RichEditor.tsx | 2 +- .../NoteEditor/RichEditor/plugins/CodeHighlightPlugin.tsx | 5 +++-- .../plugins/EditorPanelPlugin/EditorPanelPlugin.tsx | 2 +- .../RichEditor/plugins/EditorPanelPlugin/utils/tree.tsx | 2 +- .../plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts | 2 +- .../RichEditor/plugins/Markdown/__tests__/utils.ts | 2 +- .../plugins/Markdown/convertLexicalNodeToMarkdownNode.ts | 2 +- .../NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts | 2 +- 8 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx index 13b1ecc94..b7d309964 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { CodeHighlightNode, CodeNode } from '@lexical/code-core'; import { AutoLinkNode, LinkNode } from '@lexical/link'; import { ListItemNode, ListNode } from '@lexical/list'; import { MarkNode } from '@lexical/mark'; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/CodeHighlightPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/CodeHighlightPlugin.tsx index e48bdd35b..970e8e76d 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/CodeHighlightPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/CodeHighlightPlugin.tsx @@ -1,12 +1,13 @@ import { useEffect } from 'react'; -import { registerCodeHighlighting } from '@lexical/code'; +import { registerCodeIndentation } from '@lexical/code-core'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; export const CodeHighlightPlugin = () => { const [editor] = useLexicalComposerContext(); + // TODO: use registerCodeHighlighting useEffect(() => { - return registerCodeHighlighting(editor); + return registerCodeIndentation(editor); }, [editor]); return null; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index 0dfd87249..19eae8282 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -10,7 +10,7 @@ import { CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, } from 'lexical'; -import { $createCodeNode } from '@lexical/code'; +import { $createCodeNode } from '@lexical/code-core'; import { TOGGLE_LINK_COMMAND } from '@lexical/link'; import { INSERT_CHECK_LIST_COMMAND, diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/tree.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/tree.tsx index ac832f6e7..69bf529c0 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/tree.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/tree.tsx @@ -10,7 +10,7 @@ import { ElementNode, LexicalNode, } from 'lexical'; -import { $isCodeNode } from '@lexical/code'; +import { $isCodeNode } from '@lexical/code-core'; import { $findMatchingParent } from '@lexical/utils'; export const $canInsertElementsToNode = (node: LexicalNode) => { diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts index b01406f95..82e126098 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts @@ -10,7 +10,7 @@ import { ElementNode, KEY_ENTER_COMMAND, } from 'lexical'; -import { $isCodeNode } from '@lexical/code'; +import { $isCodeNode } from '@lexical/code-core'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $isQuoteNode } from '@lexical/rich-text'; import { mergeRegister } from '@lexical/utils'; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts index 01e2a1aa1..4eab5830b 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts @@ -1,6 +1,6 @@ import { createEditor, LexicalEditor } from 'lexical'; import { visit } from 'unist-util-visit'; -import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { CodeHighlightNode, CodeNode } from '@lexical/code-core'; import { AutoLinkNode, LinkNode } from '@lexical/link'; import { ListItemNode, ListNode } from '@lexical/list'; import { MarkNode } from '@lexical/mark'; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts index d6e5fcae5..8f3f9614c 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts @@ -27,7 +27,7 @@ import { ThematicBreak, } from 'mdast'; import { u } from 'unist-builder'; -import { $isCodeNode } from '@lexical/code'; +import { $isCodeNode } from '@lexical/code-core'; import { $isLinkNode } from '@lexical/link'; import { $isListItemNode, $isListNode } from '@lexical/list'; import { $isHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts index 16bd97908..44dfe8476 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts @@ -16,7 +16,7 @@ import remarkStringify from 'remark-stringify'; import { unified } from 'unified'; import { u } from 'unist-builder'; import { TextFormat } from '@features/NoteEditor/EditorPanel'; -import { $createCodeNode } from '@lexical/code'; +import { $createCodeNode } from '@lexical/code-core'; import { $createLinkNode } from '@lexical/link'; import { $createListItemNode, $createListNode, ListType } from '@lexical/list'; import { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; From cbd56605d671e17064ebbc0f70ce7e19967fcd99 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 7 Jul 2026 23:20:08 +0200 Subject: [PATCH 07/22] test: implement Playwright unit tests --- package-lock.json | 430 +++++++++++++++++- packages/app/.env.example | 10 +- packages/app/package.json | 4 + .../RichEditor/RichEditorContent.tsx | 15 +- .../__tests__/utils/renderEditorInDOM.tsx | 98 ++++ .../__tests__/utils/renderRichEditor.tsx | 43 +- .../paragraphs.browser.test.ts.snap | 7 + .../__tests__/paragraphs.browser.test.ts | 25 + packages/app/vitest.config.mts | 67 ++- 9 files changed, 669 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts diff --git a/package-lock.json b/package-lock.json index a0248318f..3fb4c10e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2738,6 +2738,13 @@ "license": "ISC", "optional": true }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -8732,6 +8739,13 @@ "node": ">= 12" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@preact/signals-core": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", @@ -10437,7 +10451,6 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -10495,8 +10508,7 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/emscripten": { "version": "1.41.5", @@ -13510,7 +13522,6 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" } @@ -15352,7 +15363,6 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -19239,7 +19249,6 @@ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.0.0" } @@ -27631,6 +27640,53 @@ "lodash": "^4.17.23" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -30715,8 +30771,7 @@ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/signal-exit": { "version": "4.1.0", @@ -30730,6 +30785,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -31096,8 +31166,7 @@ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/stackframe": { "version": "1.3.4", @@ -31133,8 +31202,7 @@ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", @@ -32271,8 +32339,7 @@ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tinyexec": { "version": "1.0.4", @@ -32305,7 +32372,6 @@ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -32412,6 +32478,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -33990,7 +34066,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -34794,7 +34869,6 @@ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -35482,6 +35556,8 @@ "@types/unbzip2-stream": "^1.4.3", "@types/uuid": "^11.0.0", "@types/webpack": "^5.28.5", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", "@vitest/web-worker": "^4.1.1", "blob-polyfill": "^9.0.20240710", "copy-webpack-plugin": "^13.0.1", @@ -35496,6 +35572,7 @@ "mini-css-extract-plugin": "^2.10.0", "node-loader": "^2.1.0", "p-limit": "^7.2.0", + "playwright": "^1.61.1", "react-refresh": "^0.18.0", "sharp": "^0.34.5", "sharp-ico": "^0.1.5", @@ -35510,6 +35587,7 @@ "typescript-eslint": "^8.54.0", "unbzip2-stream": "^1.4.3", "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.10", "webpack": "^5.106.2", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.4", @@ -35519,6 +35597,207 @@ "node": ">=22" } }, + "packages/app/node_modules/@vitest/browser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "packages/app/node_modules/@vitest/browser-playwright": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "packages/app/node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/app/node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/app/node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/app/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/app/node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/app/node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/app/node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/app/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/app/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "packages/app/node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "packages/app/node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -35529,6 +35808,123 @@ "node": ">=0.10.0" } }, + "packages/app/node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "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-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/app/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/site": { "version": "0.0.1", "dependencies": { diff --git a/packages/app/.env.example b/packages/app/.env.example index c9883ff57..b65b1c92d 100644 --- a/packages/app/.env.example +++ b/packages/app/.env.example @@ -3,4 +3,12 @@ TELEMETRY=off TELEMETRY_DEBUG=on TELEMETRY_SYNC_INTERVAL=10s TELEMETRY_URL=https://uxt.vitonsky.net -TELEMETRY_APP_NAME=deepink \ No newline at end of file +TELEMETRY_APP_NAME=deepink + +# Used for translate locales +OPENAI_API_URL=https://openrouter.ai/api/v1 +OPENAI_API_KEY= + +# Tests +HEADLESS_BROWSER=on +#PLAYWRIGHT_BROWSER_PATH=/usr/bin/chromium-browser \ No newline at end of file diff --git a/packages/app/package.json b/packages/app/package.json index e3f2419a9..e743e0fac 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -69,6 +69,8 @@ "@types/unbzip2-stream": "^1.4.3", "@types/uuid": "^11.0.0", "@types/webpack": "^5.28.5", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", "@vitest/web-worker": "^4.1.1", "blob-polyfill": "^9.0.20240710", "copy-webpack-plugin": "^13.0.1", @@ -83,6 +85,7 @@ "mini-css-extract-plugin": "^2.10.0", "node-loader": "^2.1.0", "p-limit": "^7.2.0", + "playwright": "^1.61.1", "react-refresh": "^0.18.0", "sharp": "^0.34.5", "sharp-ico": "^0.1.5", @@ -97,6 +100,7 @@ "typescript-eslint": "^8.54.0", "unbzip2-stream": "^1.4.3", "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.10", "webpack": "^5.106.2", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.4", diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx index 2a58248d4..51a82f082 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx @@ -1,5 +1,11 @@ -import React, { Ref, useEffect, useMemo } from 'react'; -import { $createRangeSelection, $getRoot, $getSelection, $setSelection } from 'lexical'; +import React, { Ref, useEffect, useImperativeHandle, useMemo } from 'react'; +import { + $createRangeSelection, + $getRoot, + $getSelection, + $setSelection, + LexicalEditor, +} from 'lexical'; import { Box, useSlotRecipe } from '@chakra-ui/react'; import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; @@ -43,6 +49,7 @@ export type RichEditorContentProps = RichTextContainerProps & isReadOnly?: boolean; search?: string; apiRef?: Ref; + editorRef?: Ref; }; export const RichEditorContent = ({ @@ -52,6 +59,7 @@ export const RichEditorContent = ({ isReadOnly, search, apiRef, + editorRef, ...props }: RichEditorContentProps) => { const recipe = useSlotRecipe({ key: 'richEditor' }); @@ -61,6 +69,8 @@ export const RichEditorContent = ({ // Expose API const [editor] = useLexicalComposerContext(); + useImperativeHandle(editorRef, () => editor); + const api = useMemo(() => { return { focus() { @@ -99,6 +109,7 @@ export const RichEditorContent = ({ width="100%" height="100%" overflow="auto" + className="RichEditor" css={{ ...styles.root, diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx new file mode 100644 index 000000000..e949dd305 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx @@ -0,0 +1,98 @@ +import React, { createRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { createEvent } from 'effector'; +import { LexicalEditor } from 'lexical'; +import { FilesController } from '@core/features/files/FilesController'; +import { + FilesRegistryContext, + NotesContext, + NotesRegistryContext, +} from '@features/App/Workspace/WorkspaceProvider'; +import { + editorPanelContext, + InsertingPayload, + TextFormat, +} from '@features/NoteEditor/EditorPanel'; +import { RichEditor } from '@features/NoteEditor/RichEditor/RichEditor'; +import { RichEditorContentProps } from '@features/NoteEditor/RichEditor/RichEditorContent'; +import { ThemeProvider } from '@features/ThemeProvider'; +import { createTestStore } from '@tests/utils/redux'; + +// Mock useUrlOpener to avoid importing monaco-editor-core in tests, +// which causes Vite module resolution errors during test setup +vi.mock('@hooks/useUrlOpener', () => ({ + useUrlOpener: () => vi.fn(), +})); + +export const MockWorkspaceProvider = ({ children }: { children: React.ReactNode }) => { + const filesRegistry = { + add: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + query: vi.fn(), + } as unknown as FilesController; + + const notesControl = { + get: vi.fn(), + updateBatch: vi.fn(), + getById: vi.fn(), + getLength: vi.fn(), + query: vi.fn(), + add: vi.fn(), + update: vi.fn(), + updateMeta: vi.fn(), + delete: vi.fn(), + }; + + const notesApi = { + openNote: vi.fn(), + noteUpdated: vi.fn(), + noteClosed: vi.fn(), + }; + + return ( + + + + {children} + + + + ); +}; + +export const renderRichEditorInDOM = async (props: RichEditorContentProps) => { + const { store } = createTestStore(); + const onFormatting = createEvent(); + const onInserting = createEvent(); + + const editorRef = createRef(); + + const renderEditor = (props: RichEditorContentProps) => ( + + + + + + + + + + ); + + const container = document.createElement('div'); + document.body.appendChild(container); + + const root = createRoot(container); + root.render(renderEditor({ ...props, editorRef })); + + return { + root, + container, + + getEditor() { + return editorRef.current; + }, + }; +}; diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderRichEditor.tsx b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderRichEditor.tsx index 3530187b1..1e79002e0 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderRichEditor.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderRichEditor.tsx @@ -1,6 +1,9 @@ -import React, { act } from 'react'; +import React, { act, createRef } from 'react'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; import { Provider } from 'react-redux'; import { createEvent } from 'effector'; +import i18n from 'i18next'; +import { LexicalEditor } from 'lexical'; import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; import { FilesController } from '@core/features/files/FilesController'; import { @@ -77,19 +80,41 @@ export const renderRichEditor = async (props: RichEditorContentProps) => { const onFormatting = createEvent(); const onInserting = createEvent(); + const editorRef = createRef(); + + i18n.use(initReactI18next) // passes i18n down to react-i18next + .init({ + lng: 'en', + fallbackLng: 'en', + + ns: [], + + interpolation: { + escapeValue: false, + }, + + react: { + useSuspense: false, // important since we manually wait + }, + }); + const renderEditor = (props: RichEditorContentProps) => ( - - - - - + + + + + + + ); - const result = render(renderEditor(props)); + const result = render(renderEditor({ ...props, editorRef })); return { ...result, @@ -113,5 +138,9 @@ export const renderRichEditor = async (props: RichEditorContentProps) => { format: async (format: TextFormat) => { await act(async () => onFormatting(format)); }, + + getEditor() { + return editorRef.current; + }, }; }; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap new file mode 100644 index 000000000..803f2807c --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Few paragraphs with empty lines 1`] = ` +"Hello +World +" +`; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts new file mode 100644 index 000000000..d91cba459 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts @@ -0,0 +1,25 @@ +import { page, userEvent } from 'vitest/browser'; +import { renderRichEditorInDOM } from '@features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM'; + +import { $convertToMarkdownString } from '../markdownParser'; + +vi.mock('electron', () => () => {}); + +test('Few paragraphs with empty lines', async () => { + const { getEditor } = await renderRichEditorInDOM({ value: '' }); + + const textbox = page.getByRole('textbox'); + const paragraph = page.getByRole('paragraph'); + + await expect.element(textbox).toBeVisible(); + await expect.element(paragraph.first()).not.toBeInTheDocument(); + + await userEvent.click(textbox); + await userEvent.keyboard('Hello'); + await expect.poll(() => paragraph.all()).toHaveLength(1); + + await userEvent.keyboard('{Enter}World'); + await expect.poll(() => paragraph.all()).toHaveLength(2); + + expect(getEditor()!.read(() => $convertToMarkdownString())).toMatchSnapshot(); +}); diff --git a/packages/app/vitest.config.mts b/packages/app/vitest.config.mts index c841b4605..f3f4bc339 100644 --- a/packages/app/vitest.config.mts +++ b/packages/app/vitest.config.mts @@ -1,10 +1,19 @@ +import 'dotenv/config' + import { readFileSync } from 'fs'; import { resolve } from 'path'; import { pathToFileURL } from 'url'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defaultExclude, defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { playwright } from '@vitest/browser-playwright'; + +const isHeadlessBrowser = process.env.HEADLESS_BROWSER !== 'off'; export default defineConfig({ + resolve: { + dedupe: ['react', 'react-dom'], + }, plugins: [ tsconfigPaths(), { @@ -33,7 +42,6 @@ export default defineConfig({ VITEST_WEB_WORKER_CLONE: 'none', }, globals: true, - setupFiles: ['@vitest/web-worker', 'scripts/vitest.setup.ts'], exclude: defaultExclude.concat(['tmp/**', 'dist/**', 'out/**']), // DB initialization takes some time at first time, @@ -47,7 +55,8 @@ export default defineConfig({ test: { name: 'node', environment: 'node', - exclude: ['**/*.dom.test.ts'], + exclude: ['./**/*.dom.test.ts', './**/*.browser.test.ts'], + setupFiles: ['@vitest/web-worker', 'scripts/vitest.setup.ts'], }, }, { @@ -55,7 +64,59 @@ export default defineConfig({ test: { name: 'dom', environment: 'jsdom', - include: ['**/*.dom.test.ts'], + include: ['./**/*.dom.test.ts'], + setupFiles: ['@vitest/web-worker', 'scripts/vitest.setup.ts'], + }, + }, + { + resolve: { + conditions: ['development', 'import', 'module', 'browser', 'default'], + }, + define: { + // https://react.dev/blog/2022/03/08/react-18-upgrade-guide#configuring-your-testing-environment + IS_REACT_ACT_ENVIRONMENT: true, + }, + extends: true, + // Pre-bundle React (and react-dom/client) together so browser tests + // that render React share a single React instance with the + // source-aliased @lexical/react packages. Without this the optimized + // react-dom/client bundle gets its own copy and hooks fail with a null + // dispatcher ("Cannot read properties of null (reading 'useMemo')"). + optimizeDeps: { + include: [ + 'react', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + ], + }, + plugins: [react()], + test: { + browser: { + // Vitest's default browser server port (63315) is in the + // ephemeral range, and Windows reserves randomized blocks of + // that range (Hyper-V excluded port ranges), so on Windows CI + // runners listen() occasionally fails with EACCES + // (vitest-dev/vitest#9035). Pin a port below the ephemeral + // range instead; if it happens to be busy, Vite falls back to + // the next free port rather than failing. + api: { port: 8315 }, + enabled: true, + // at least one instance is required + instances: [{ browser: 'chromium' }], + // Headless everywhere by default so the suite runs the same way in + // CI and in headless dev containers. Pass `--browser.headless=false` + // (or use the Vitest UI) to debug in a real window locally. + headless: isHeadlessBrowser, + provider: playwright({ + launchOptions: { + executablePath: process.env.PLAYWRIGHT_BROWSER_PATH, + headless: isHeadlessBrowser, + }, + }), + }, + include: ['./**/*.browser.test.ts'], + name: 'browser', }, }, ], From 10ee5c4442cae7c7427e5215cacfbad9185bfefb Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 7 Jul 2026 23:20:54 +0200 Subject: [PATCH 08/22] test: add text with few paragraphs --- .../markdown-roundtrips-consistency.test.ts.snap | 9 +++++++++ .../__tests__/markdown-roundtrips-consistency.test.ts | 6 ++++++ .../plugins/Markdown/__tests__/markdown-samples/index.ts | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap index 9b9dd5f91..520dfbe94 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap @@ -38,6 +38,15 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Few paragraphs 1`] = ` +"# Why Russian Classics Feel So Different + +I've noticed that after finishing a Russian novel I rarely want to start another book immediately. + +It's not because the stories are difficult. It's because they seem to leave unfinished conversations in my head. Instead of remembering the plot, I keep thinking about the questions the characters were asking themselves. +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Formatted line 1`] = ` "**That** is a ***formatted*** *line*. " diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts index 7105ea31f..c83b1c71f 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts @@ -11,6 +11,7 @@ import { } from '../markdownParser'; import { detailsWithSummary, + fewParagraphs, formattedLine, formattingInList, formattingInQuote, @@ -47,6 +48,10 @@ describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level' markdown: '**foo __bar__ baz**', inconsistentAST: true, }, + { + title: 'Few paragraphs', + markdown: fewParagraphs, + }, { title: 'Inline code', markdown: 'Text with `code`', @@ -157,6 +162,7 @@ describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level' { title: 'Details with summary', markdown: detailsWithSummary, + inconsistentAST: true, }, { title: 'Unsupported features', diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts index 9084c67c5..64f31c0bb 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts @@ -1,3 +1,11 @@ +export const fewParagraphs = ` +# Why Russian Classics Feel So Different + +I've noticed that after finishing a Russian novel I rarely want to start another book immediately. + +It's not because the stories are difficult. It's because they seem to leave unfinished conversations in my head. Instead of remembering the plot, I keep thinking about the questions the characters were asking themselves. +`.trim(); + export const simpleFormatting = ` Text may be **bold**, *italic*, ~~strikethrough~~. `.trim(); From 5f52f6eab76b89839def692a4f06f6aa0d30410a Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 8 Jul 2026 14:13:33 +0200 Subject: [PATCH 09/22] chore: update tsconfig types --- packages/app/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 5efa5777e..5f6f93332 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -33,7 +33,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "baseUrl": ".", - "types": ["vitest/globals", "@testing-library/jest-dom/vitest"], + "types": ["vitest/globals", "@testing-library/jest-dom"], "paths": { "@assets/*": ["assets/*"], "@tests/*": ["src/__tests__/*"], From acbeccac57ed3ff4b2c0632ec7cd020d56cc0286 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 8 Jul 2026 14:21:53 +0200 Subject: [PATCH 10/22] test: preserve empty paragraphs --- packages/app/.gitignore | 3 +- .../__tests__/utils/renderEditorInDOM.tsx | 15 ++++-- .../paragraphs.browser.test.ts.snap | 7 --- .../__tests__/paragraphs.browser.test.ts | 50 ++++++++++++++++--- 4 files changed, 56 insertions(+), 19 deletions(-) delete mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap diff --git a/packages/app/.gitignore b/packages/app/.gitignore index ffbc07a79..d0f321e34 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1,2 +1,3 @@ .transly -chakra-config.json \ No newline at end of file +chakra-config.json +.vitest-attachments \ No newline at end of file diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx index e949dd305..654326034 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM.tsx @@ -1,4 +1,4 @@ -import React, { createRef } from 'react'; +import React, { act, createRef } from 'react'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { createEvent } from 'effector'; @@ -85,14 +85,23 @@ export const renderRichEditorInDOM = async (props: RichEditorContentProps) => { document.body.appendChild(container); const root = createRoot(container); - root.render(renderEditor({ ...props, editorRef })); + act(() => root.render(renderEditor({ ...props, editorRef }))); return { root, container, + destroy() { + act(() => { + root.unmount(); + }); + container.remove(); + }, + getEditor() { - return editorRef.current; + const editor = editorRef.current; + if (!editor) throw new Error('Error instance is not set'); + return editor; }, }; }; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap deleted file mode 100644 index 803f2807c..000000000 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/paragraphs.browser.test.ts.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Few paragraphs with empty lines 1`] = ` -"Hello -World -" -`; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts index d91cba459..76cffd309 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/paragraphs.browser.test.ts @@ -1,25 +1,59 @@ +import { act } from 'react'; import { page, userEvent } from 'vitest/browser'; import { renderRichEditorInDOM } from '@features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM'; -import { $convertToMarkdownString } from '../markdownParser'; +import { $convertToMarkdownString, parseMarkdownToAST } from '../markdownParser'; vi.mock('electron', () => () => {}); -test('Few paragraphs with empty lines', async () => { - const { getEditor } = await renderRichEditorInDOM({ value: '' }); +test('Exact paragraphs count must be preserved', async () => { + const { getEditor, destroy } = await renderRichEditorInDOM({ value: '' }); + onTestFinished(destroy); const textbox = page.getByRole('textbox'); const paragraph = page.getByRole('paragraph'); await expect.element(textbox).toBeVisible(); - await expect.element(paragraph.first()).not.toBeInTheDocument(); + await expect.poll(() => paragraph.all()).toHaveLength(1); + await expect.element(paragraph).toHaveTextContent(''); - await userEvent.click(textbox); - await userEvent.keyboard('Hello'); + await act(async () => { + await userEvent.click(textbox); + await userEvent.keyboard('Hello'); + }); await expect.poll(() => paragraph.all()).toHaveLength(1); - await userEvent.keyboard('{Enter}World'); + await act(async () => { + await userEvent.keyboard('{Enter}World'); + }); await expect.poll(() => paragraph.all()).toHaveLength(2); - expect(getEditor()!.read(() => $convertToMarkdownString())).toMatchSnapshot(); + const serializedOut = getEditor().read(() => $convertToMarkdownString()); + expect(parseMarkdownToAST(serializedOut).children).toHaveLength(2); +}); + +test('Empty paragraphs must be preserved', async () => { + const { getEditor, destroy } = await renderRichEditorInDOM({ value: '' }); + onTestFinished(destroy); + + const textbox = page.getByRole('textbox'); + const paragraph = page.getByRole('paragraph'); + + await expect.element(textbox).toBeVisible(); + await expect.poll(() => paragraph.all()).toHaveLength(1); + await expect.element(paragraph).toHaveTextContent(''); + + await act(async () => { + await userEvent.click(textbox); + await userEvent.keyboard('Hello'); + }); + await expect.poll(() => paragraph.all()).toHaveLength(1); + + await act(async () => { + await userEvent.keyboard('{Enter}'.repeat(3) + 'World'); + }); + await expect.poll(() => paragraph.all()).toHaveLength(4); + + const serializedOut = getEditor().read(() => $convertToMarkdownString()); + expect(parseMarkdownToAST(serializedOut).children).toHaveLength(4); }); From d629334cea0629f49c9867db828e6c3abf40f304 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 8 Jul 2026 14:23:08 +0200 Subject: [PATCH 11/22] feat: ensure empty paragraph in Lexical --- .../RichEditor/plugins/Markdown/markdownParser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts index 44dfe8476..ce14b970c 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts @@ -231,7 +231,12 @@ export const $convertFromMarkdownString = (rawMarkdown: string) => { const rootNode = $getRoot(); rootNode.clear(); - rootNode.append(...lexicalNodes); + + if (lexicalNodes.length > 0) { + rootNode.append(...lexicalNodes); + } else { + rootNode.append($createParagraphNode()); + } }; export const $serializeAsMarkdownAST = () => { From eae00e540e4292e83794e1a65ae117ec5b56921b Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 8 Jul 2026 14:27:14 +0200 Subject: [PATCH 12/22] feat: convert each 2 lines into 1 paragraph --- .../remark/remarkPreserveBlankLines.test.ts | 49 ++++++++ .../remark/remarkPreserveBlankLines.ts | 112 +++++++++--------- 2 files changed, 108 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts new file mode 100644 index 000000000..bc7cd30d0 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts @@ -0,0 +1,49 @@ +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +import { fillGapsWithParagraphs } from './remarkPreserveBlankLines'; + +export const markdownProcessor = unified().use(remarkParse); + +test('Must not to add paragraphs for text with no gaps', () => { + const tree = markdownProcessor.parse('Hello\nWorld'); + expect(tree.children).toHaveLength(1); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(1); +}); + +test('Must not to add paragraphs for one line gap', () => { + const tree = markdownProcessor.parse('Hello\n\nWorld'); + expect(tree.children).toHaveLength(2); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(2); +}); + +test('Must add 1 paragraph for gap in 2 lines', () => { + const tree = markdownProcessor.parse('Hello\n\n\nWorld'); + expect(tree.children).toHaveLength(2); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(3); +}); + +test('Must add 5 paragraphs for gap in 10 lines', () => { + const tree = markdownProcessor.parse('Hello\n' + '\n'.repeat(10) + 'World'); + expect(tree.children).toHaveLength(2); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(2 + 5); +}); + +// TODO: implement lines preservation +test.fails('Preserve lines for document with no text', () => { + const tree = markdownProcessor.parse('\n'.repeat(10)); + expect(tree.children).toHaveLength(0); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(5); +}); + +test.fails('Preserve lines at start', () => { + const tree = markdownProcessor.parse('\n'.repeat(10) + '\nText'); + expect(tree.children).toHaveLength(1); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(6); +}); + +test.fails('Preserve lines at end', () => { + const tree = markdownProcessor.parse('Text\n' + '\n'.repeat(10)); + expect(tree.children).toHaveLength(1); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(6); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts index 450800060..38fba547e 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts @@ -2,61 +2,67 @@ import { type Root, RootContent } from 'mdast'; import { Plugin } from 'unified'; import { CONTINUE, SKIP, visit } from 'unist-util-visit'; -export const remarkPreserveBlankLines: Plugin<[], Root> = () => { - const ignoredNodeTypes = new Set([ - 'table', - 'tableCell', - 'tableRow', - ] satisfies RootContent['type'][]); - - return (tree: Root) => { - const skipNodes = new Set(); - - visit(tree, (node) => { - // Skip nodes with no nested elements - if (!('children' in node)) return SKIP; - - // Skip ignored node types - if (ignoredNodeTypes.has(node.type)) return SKIP; - - // Skip already handled nodes - if (skipNodes.has(node)) return SKIP; - - const newChildren: RootContent[] = []; - for (let i = 0; i < node.children.length; i++) { - const current = node.children[i]; - const next = node.children[i + 1]; - - // Collect its own children - newChildren.push(current); - - // Add empty lines to preserve - if (next && current.position && next.position) { - const lineGap = next.position.start.line - current.position.end.line; - // lineGap === 2 means exactly one blank line, 3 means two, etc. - const blankLineCount = lineGap - 1; - - for (let b = 0; b < blankLineCount; b++) { - const line = current.position.end.line + 1 + b; - const emptyLine = { - type: 'paragraph', - children: [], - // TODO: add tests to verify position are correct in complex cases - position: { - start: { line, column: 1, offset: 0 }, - end: { line, column: 1, offset: 0 }, - }, - } as RootContent; - - newChildren.push(emptyLine); - skipNodes.add(emptyLine); - } +const ignoredNodeTypes = new Set([ + 'table', + 'tableCell', + 'tableRow', +] satisfies RootContent['type'][]); + +export const fillGapsWithParagraphs = (tree: Root) => { + const skipNodes = new Set(); + + visit(tree, (node) => { + // Skip nodes with no nested elements + if (!('children' in node)) return SKIP; + + // Skip ignored node types + if (ignoredNodeTypes.has(node.type)) return SKIP; + + // Skip already handled nodes + if (skipNodes.has(node)) return SKIP; + + const newChildren: RootContent[] = []; + for (let i = 0; i < node.children.length; i++) { + const current = node.children[i]; + const next = node.children[i + 1]; + + // Collect its own children + newChildren.push(current); + + // Add empty lines to preserve + if (next && current.position && next.position) { + const lineGap = next.position.start.line - current.position.end.line; + // lineGap === 2 means exactly one blank line, 3 means two, etc. + const blankLineCount = lineGap - 1; + const paragraphsCount = + blankLineCount > 0 ? Math.floor(blankLineCount / 2) : 0; + + for (let b = 0; b < paragraphsCount; b++) { + const line = current.position.end.line + 1 + b; + const emptyLine = { + type: 'paragraph', + children: [], + // TODO: add tests to verify position are correct in complex cases + position: { + start: { line, column: 1, offset: 0 }, + end: { line, column: 1, offset: 0 }, + }, + } as RootContent; + + newChildren.push(emptyLine); + skipNodes.add(emptyLine); } } + } + + node.children = newChildren; - node.children = newChildren; + return CONTINUE; + }); - return CONTINUE; - }); - }; + return tree; +}; + +export const remarkPreserveBlankLines: Plugin<[], Root> = () => { + return fillGapsWithParagraphs; }; From e0fd1e044fc60b03e8ca148b4801cf027dd971c2 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 8 Jul 2026 21:52:22 +0200 Subject: [PATCH 13/22] feat: serialize empty paragraph as empty line --- ...rkdown-roundtrips-consistency.test.ts.snap | 3 +- .../plugins/Markdown/markdownParser.ts | 45 +++++- .../Markdown/remark/remarkLiftFormatting.ts | 4 +- .../remark/remarkPreserveBlankLines.ts | 3 +- .../Markdown/serializeMarkdownTree.test.ts | 138 ++++++++++++++++++ 5 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap index 520dfbe94..a886b1b1f 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap @@ -182,12 +182,11 @@ Another paragraph. ## Another section Introduction - *** - Text after a separator ## Header with no space + Text under header ## Yet another section diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts index ce14b970c..949419cfd 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts @@ -10,10 +10,11 @@ import { TextNode, } from 'lexical'; import { Content, type Root } from 'mdast'; +import { defaultHandlers } from 'mdast-util-to-markdown'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; -import { unified } from 'unified'; +import { Processor, unified } from 'unified'; import { u } from 'unist-builder'; import { TextFormat } from '@features/NoteEditor/EditorPanel'; import { $createCodeNode } from '@lexical/code-core'; @@ -32,26 +33,54 @@ import { $createImageNode } from '../Image/ImageNode'; import { convertLexicalNodeToMarkdownNode } from './convertLexicalNodeToMarkdownNode'; import { createSyncContext } from './createSyncContext'; import { $createRawNode } from './nodes/RawNode'; -import { liftFormattingNodes } from './remark/remarkLiftFormatting'; -import { remarkPreserveBlankLines } from './remark/remarkPreserveBlankLines'; +import { hasChildren, liftFormattingNodes } from './remark/remarkLiftFormatting'; +import { fillGapsWithParagraphs } from './remark/remarkPreserveBlankLines'; export const markdownProcessor = unified() .use(remarkParse) - .use(remarkPreserveBlankLines) .use(remarkGfm) .use(remarkStringify, { bullet: '-', listItemIndent: 'one', + handlers: { + paragraph(node, parent, state, info) { + // Empty line will be added after that node anyway + // So empty paragraph converts to 1 empty line + if (node.children.length === 0) { + return ''; + } + + // In case paragraph is not empty, we fallback + // to default serializer + return defaultHandlers.paragraph(node, parent, state, info); + }, + }, join: [ - () => { - return 0; + (left, right) => { + if (left.type === 'paragraph' || right.type === 'paragraph') { + const isLeftEmpty = !hasChildren(left) || left.children.length === 0; + const isRightEmpty = + !hasChildren(right) || right.children.length === 0; + + if (isLeftEmpty || isRightEmpty) return 0; + + return null; + } + + return null; }, ], }) - .freeze(); + .freeze() as unknown as Processor; export const parseMarkdownToAST = (source: string) => { - return markdownProcessor.runSync(markdownProcessor.parse(source)); + return markdownProcessor.runSync( + fillGapsWithParagraphs(markdownProcessor.parse(source)), + ); +}; + +export const serializeMarkdownTree = (tree: Root) => { + return markdownProcessor.stringify(tree); }; export const dumpMarkdownNode = (node: Content) => { diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts index dafdc7764..c7acbe1cf 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts @@ -27,8 +27,8 @@ function isMarkNode(node: PhrasingContent): node is MarkNode { return MARK_TYPES.has(node.type); } -function hasChildren( - node: PhrasingContent, +export function hasChildren( + node: RootContent, ): node is PhrasingContent & { children: PhrasingContent[] } { return ( 'children' in node && diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts index 38fba547e..2f30080d9 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts @@ -34,8 +34,7 @@ export const fillGapsWithParagraphs = (tree: Root) => { const lineGap = next.position.start.line - current.position.end.line; // lineGap === 2 means exactly one blank line, 3 means two, etc. const blankLineCount = lineGap - 1; - const paragraphsCount = - blankLineCount > 0 ? Math.floor(blankLineCount / 2) : 0; + const paragraphsCount = blankLineCount > 1 ? blankLineCount : 0; for (let b = 0; b < paragraphsCount; b++) { const line = current.position.end.line + 1 + b; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts new file mode 100644 index 000000000..2b7bbcbfa --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts @@ -0,0 +1,138 @@ +import { Paragraph } from 'mdast'; +import { u } from 'unist-builder'; + +import { serializeMarkdownTree } from './markdownParser'; + +describe('AST serialization', () => { + test('2 paragraphs have 1 empty line', () => { + expect( + serializeMarkdownTree( + u('root', { + children: [ + u('paragraph', { children: [u('text', { value: 'Hello' })] }), + u('paragraph', { children: [u('text', { value: 'World' })] }), + ] satisfies Paragraph[], + }), + ), + ).toBe('Hello\n\nWorld\n'); + }); + + test('Empty paragraph is considered as additional empty line', () => { + expect( + serializeMarkdownTree( + u('root', { + children: [ + u('paragraph', { children: [u('text', { value: 'Hello' })] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [u('text', { value: 'World' })] }), + ] satisfies Paragraph[], + }), + ), + ).toBe('Hello\n\nWorld\n'); + }); + + test('3 empty paragraphs is considered as 3 additional empty lines', () => { + expect( + serializeMarkdownTree( + u('root', { + children: [ + u('paragraph', { children: [u('text', { value: 'Hello' })] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [u('text', { value: 'World' })] }), + ] satisfies Paragraph[], + }), + ), + ).toBe('Hello\n\n\n\nWorld\n'); + }); + + test('Quote with 3 empty paragraphs must contain 3 empty lines', () => { + expect( + serializeMarkdownTree({ + type: 'root', + children: [ + { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Hello', + }, + ], + }, + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'World', + }, + ], + }, + ], + }, + ], + }), + ).toBe('> Hello\n>\n>\n>\n> World\n'); + }); + + test('Nested quote with 3 empty paragraphs must contain 3 empty lines', () => { + expect( + serializeMarkdownTree({ + type: 'root', + children: [ + { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Hello', + }, + ], + }, + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Hello', + }, + ], + }, + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + u('paragraph', { children: [] }), + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'World', + }, + ], + }, + ], + }, + ], + }, + ], + }), + ).toBe('> Hello\n>\n>\n>\n> > Hello\n> >\n> >\n> >\n> > World\n'); + }); +}); From bc004101ea453cbc1e0c16fe3151dfc456ce8753 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sat, 11 Jul 2026 13:31:35 +0200 Subject: [PATCH 14/22] fix: stabilize empty lines strategy --- ...rkdown-roundtrips-consistency.test.ts.snap | 64 ++-- .../markdown-roundtrips-consistency.test.ts | 322 +++++++++++------- .../plugins/Markdown/markdownParser.ts | 14 +- .../remark/remarkPreserveBlankLines.ts | 5 +- 4 files changed, 235 insertions(+), 170 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap index a886b1b1f..a3c8a5e24 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap @@ -1,32 +1,32 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Bold node inside bold node 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Bold node inside bold node > Consistent markdown output 1`] = ` "**foo bar baz** " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Check list with all checked items 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Check list with all checked items > Consistent markdown output 1`] = ` "- [x] foo - [x] bar - [x] baz " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Check list with no checked items 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Check list with no checked items > Consistent markdown output 1`] = ` "- [ ] foo - [ ] bar - [ ] baz " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Check list with one checked item 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Check list with one checked item > Consistent markdown output 1`] = ` "- [ ] foo - [x] bar - [ ] baz " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Details with summary 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Details with summary > Consistent markdown output 1`] = ` "
      Shopping list @@ -38,7 +38,7 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Few paragraphs 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Few paragraphs > Consistent markdown output 1`] = ` "# Why Russian Classics Feel So Different I've noticed that after finishing a Russian novel I rarely want to start another book immediately. @@ -47,54 +47,54 @@ It's not because the stories are difficult. It's because they seem to leave unfi " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Formatted line 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Formatted line > Consistent markdown output 1`] = ` "**That** is a ***formatted*** *line*. " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Header with formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Header with formatting > Consistent markdown output 1`] = ` "### *All header is italic, **something bold**, ~~strikethrough~~, \`inline code\`* " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Image 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Image > Consistent markdown output 1`] = ` "![Alt text](https://url) " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Image wrapped by link 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Image wrapped by link > Consistent markdown output 1`] = ` "[![Alt text](https://url)](proto://url2 "Link title") " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Inline code 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Inline code > Consistent markdown output 1`] = ` "Text with \`code\` " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Inline code with formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Inline code with formatting > Consistent markdown output 1`] = ` "Text with ***~~\`formatted code\`~~*** " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Links 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Links > Consistent markdown output 1`] = ` "Some [link](https://url "Title") and [another link](proto://url) " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > List item with inline elements 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > List item with inline elements > Consistent markdown output 1`] = ` "- foo [bar](...) baz " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > List item with inline elements and nested list 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > List item with inline elements and nested list > Consistent markdown output 1`] = ` "- item 1 [foo](...) bar - item 1-2 - item 2-2 " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > List with formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > List with formatting > Consistent markdown output 1`] = ` "- *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ - Plain text - *Italic* @@ -103,7 +103,7 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Mixed list 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Mixed list > Consistent markdown output 1`] = ` "- level 1-1 - [x] level 2-1 - [x] level 2-2 @@ -118,33 +118,32 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Nested quote 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Nested quote > Consistent markdown output 1`] = ` "> The quote > > > > > Quote inside another quote > > -> > > > With some spaces " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Numbered list 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Numbered list > Consistent markdown output 1`] = ` "1. foo 2. bar 3. baz " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Plain list 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Plain list > Consistent markdown output 1`] = ` "- foo - bar - baz " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Quote with formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Quote with formatting > Consistent markdown output 1`] = ` "> *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ > Plain text > *Italic* @@ -153,12 +152,12 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Rich formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Rich formatting > Consistent markdown output 1`] = ` "*All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple code 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Simple code > Consistent markdown output 1`] = ` "\`\`\`js const x = 42; console.log("Your number is", x ** x); @@ -166,12 +165,12 @@ console.log("Your number is", x ** x); " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Simple formatting > Consistent markdown output 1`] = ` "Text may be **bold**, *italic*, ~~strikethrough~~. " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple post with headers 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Simple post with headers > Consistent markdown output 1`] = ` "# The title A few lines @@ -182,7 +181,9 @@ Another paragraph. ## Another section Introduction + *** + Text after a separator ## Header with no space @@ -195,12 +196,12 @@ Section content " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple quote 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Simple quote > Consistent markdown output 1`] = ` "> The quote " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple table 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Simple table > Consistent markdown output 1`] = ` "| Name | Value | | ----- | ----- | | One | 1 | @@ -209,7 +210,7 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Table with formatting 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Table with formatting > Consistent markdown output 1`] = ` "| foo | bar | | ---------------------------------------------------------------------------------------------------------------------- | -------------------------- | | *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ | @@ -217,15 +218,14 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Text with few empty lines 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Text with few empty lines > Consistent markdown output 1`] = ` "foo - bar " `; -exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Unsupported features 1`] = ` +exports[`Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text > Unsupported features > Consistent markdown output 1`] = ` " " `; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts index c83b1c71f..c34451e06 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts @@ -41,136 +41,142 @@ test('Markdown parser round-trips', () => { ); }); -describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level', () => { - const cases = [ - { - title: 'Bold node inside bold node', - markdown: '**foo __bar__ baz**', - inconsistentAST: true, - }, - { - title: 'Few paragraphs', - markdown: fewParagraphs, - }, - { - title: 'Inline code', - markdown: 'Text with `code`', - }, - { - title: 'Inline code with formatting', - markdown: 'Text with ***~~`formatted code`~~***', - }, - { - title: 'Rich formatting', - markdown: richFormatting, - }, - { - title: 'Plain list', - markdown: '- foo\n - bar\n - baz', - }, - { - title: 'Simple formatting', - markdown: simpleFormatting, - }, - { - title: 'Header with formatting', - markdown: - '### *All header is italic, **something bold**, ~~strikethrough~~, `inline code`*', - }, - { - title: 'List with formatting', - markdown: formattingInList, - }, - { - title: 'Quote with formatting', - markdown: formattingInQuote, - }, - { - title: 'Table with formatting', - markdown: formattingInTable, - }, - { - title: 'List item with inline elements', - markdown: '- foo [bar](...) baz', - }, - { - title: 'List item with inline elements and nested list', - markdown: '- item 1 [foo](...) bar\n - item 1-2\n - item 2-2', - }, - { - title: 'Numbered list', - markdown: '1. foo\n 2. bar\n 3. baz', - }, - { - title: 'Check list with no checked items', - markdown: '- [ ] foo\n - [ ] bar\n - [ ] baz', - }, - { - title: 'Check list with one checked item', - markdown: '- [ ] foo\n - [x] bar\n - [ ] baz', - }, - { - title: 'Check list with all checked items', - markdown: '- [x] foo\n - [x] bar\n - [x] baz', - }, - { - title: 'Mixed list', - markdown: mixedList, - }, - { - title: 'Simple post with headers', - markdown: postWithHeaders, - }, - { - title: 'Text with few empty lines', - markdown: 'foo\n\n\nbar', - }, - { - title: 'Simple code', - markdown: simpleCode, - }, - { - title: 'Formatted line', - markdown: formattedLine, - }, - { - title: 'Links', - markdown: `Some [link](https://url "Title") and [another link](proto://url)`, - }, - { - title: 'Image', - // TODO: support for image title - markdown: `![Alt text](https://url)`, - }, - { - title: 'Image wrapped by link', - // TODO: support for image title - markdown: `[![Alt text](https://url)](proto://url2 "Link title")`, - }, - { - title: 'Simple quote', - markdown: simpleQuote, - }, - { - title: 'Nested quote', - markdown: nestedQuote, - }, - { - title: 'Simple table', - markdown: simpleTable, - }, - { - title: 'Details with summary', - markdown: detailsWithSummary, - inconsistentAST: true, - }, - { - title: 'Unsupported features', - markdown: unsupportedFeatures, - }, - ]; +type MarkdownTestCase = { + title: string; + markdown: string; + inconsistentAST?: boolean; +}; + +const normalizedMarkdownCases: MarkdownTestCase[] = [ + { + title: 'Bold node inside bold node', + markdown: '**foo __bar__ baz**', + inconsistentAST: true, + }, + { + title: 'Few paragraphs', + markdown: fewParagraphs, + }, + { + title: 'Inline code', + markdown: 'Text with `code`', + }, + { + title: 'Inline code with formatting', + markdown: 'Text with ***~~`formatted code`~~***', + }, + { + title: 'Rich formatting', + markdown: richFormatting, + }, + { + title: 'Plain list', + markdown: '- foo\n - bar\n - baz', + }, + { + title: 'Simple formatting', + markdown: simpleFormatting, + }, + { + title: 'Header with formatting', + markdown: + '### *All header is italic, **something bold**, ~~strikethrough~~, `inline code`*', + }, + { + title: 'List with formatting', + markdown: formattingInList, + }, + { + title: 'Quote with formatting', + markdown: formattingInQuote, + }, + { + title: 'Table with formatting', + markdown: formattingInTable, + }, + { + title: 'List item with inline elements', + markdown: '- foo [bar](...) baz', + }, + { + title: 'List item with inline elements and nested list', + markdown: '- item 1 [foo](...) bar\n - item 1-2\n - item 2-2', + }, + { + title: 'Numbered list', + markdown: '1. foo\n 2. bar\n 3. baz', + }, + { + title: 'Check list with no checked items', + markdown: '- [ ] foo\n - [ ] bar\n - [ ] baz', + }, + { + title: 'Check list with one checked item', + markdown: '- [ ] foo\n - [x] bar\n - [ ] baz', + }, + { + title: 'Check list with all checked items', + markdown: '- [x] foo\n - [x] bar\n - [x] baz', + }, + { + title: 'Mixed list', + markdown: mixedList, + }, + { + title: 'Simple post with headers', + markdown: postWithHeaders, + }, + { + title: 'Text with few empty lines', + markdown: 'foo\n\n\nbar', + }, + { + title: 'Simple code', + markdown: simpleCode, + }, + { + title: 'Formatted line', + markdown: formattedLine, + }, + { + title: 'Links', + markdown: `Some [link](https://url "Title") and [another link](proto://url)`, + }, + { + title: 'Image', + // TODO: support for image title + markdown: `![Alt text](https://url)`, + }, + { + title: 'Image wrapped by link', + // TODO: support for image title + markdown: `[![Alt text](https://url)](proto://url2 "Link title")`, + }, + { + title: 'Simple quote', + markdown: simpleQuote, + }, + { + title: 'Nested quote', + markdown: nestedQuote, + }, + { + title: 'Simple table', + markdown: simpleTable, + }, + { + title: 'Details with summary', + markdown: detailsWithSummary, + inconsistentAST: true, + }, + { + title: 'Unsupported features', + markdown: unsupportedFeatures, + }, +]; - cases.forEach(({ title, markdown: sourceText, inconsistentAST }) => +describe('Normalized Markdown-Lexical-Markdown round-trips must yield consistent markdown text', () => { + normalizedMarkdownCases.forEach(({ title, markdown: sourceText, inconsistentAST }) => test(title, async () => { const { editor, destroy } = createLexicalEditorInstance(); onTestFinished(destroy); @@ -184,18 +190,72 @@ describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level' if (!inconsistentAST) { expect( editor.read(() => normalizeMarkdownTree($serializeAsMarkdownAST())), + 'Exact AST equality after roundtrip', ).toMatchObject(normalizeMarkdownTree(parseMarkdownToAST(sourceText))); } const out = editor.read(() => $convertToMarkdownString()); - expect(out).toMatchSnapshot(); + expect(out).toMatchSnapshot('Consistent markdown output'); // Parse markdown await updateEditorState(editor, () => { $convertFromMarkdownString(out); }); - expect(editor.read(() => $convertToMarkdownString())).toBe(out); + expect( + editor.read(() => $convertToMarkdownString()), + 'Consistent markdown after roundtrips', + ).toBe(out); + }), + ); +}); + +describe('Any Markdown-Lexical-Markdown round-trips must yield consistent markdown text after normalization', () => { + const markdownCases: MarkdownTestCase[] = [ + ...normalizedMarkdownCases, + { + title: '2 paragraph with one line gap', + markdown: 'foo\n\nbar', + }, + { + title: '2 paragraph with two lines gap', + markdown: 'foo\n\n\nbar', + }, + { + title: '2 paragraph with 3 lines gap', + markdown: `foo${'\n'.repeat(3)}\nbar`, + }, + { + title: '2 paragraph with 10 lines gap', + markdown: `foo${'\n'.repeat(10)}\nbar`, + }, + ]; + + markdownCases.forEach(({ title, markdown: sourceText }) => + test(title, async () => { + const { editor, destroy } = createLexicalEditorInstance(); + onTestFinished(destroy); + + const rounds = 10; + expect.assertions(rounds - 1); + + let input = sourceText; + const outputs: string[] = []; + for (let round = 0; round < rounds; round++) { + await updateEditorState(editor, () => { + $convertFromMarkdownString(input); + }); + + const output = editor.read(() => $convertToMarkdownString()); + outputs.push(output); + + // Use output as input on next step + input = output; + + if (round > 0) { + expect(outputs[round], `Round #${round}`).toBe(outputs[round - 1]); + } + } }), ); }); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts index 949419cfd..81568997a 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts @@ -33,7 +33,7 @@ import { $createImageNode } from '../Image/ImageNode'; import { convertLexicalNodeToMarkdownNode } from './convertLexicalNodeToMarkdownNode'; import { createSyncContext } from './createSyncContext'; import { $createRawNode } from './nodes/RawNode'; -import { hasChildren, liftFormattingNodes } from './remark/remarkLiftFormatting'; +import { liftFormattingNodes } from './remark/remarkLiftFormatting'; import { fillGapsWithParagraphs } from './remark/remarkPreserveBlankLines'; export const markdownProcessor = unified() @@ -57,13 +57,15 @@ export const markdownProcessor = unified() }, join: [ (left, right) => { - if (left.type === 'paragraph' || right.type === 'paragraph') { - const isLeftEmpty = !hasChildren(left) || left.children.length === 0; - const isRightEmpty = - !hasChildren(right) || right.children.length === 0; + // Join empty paragraphs with no empty lines between them + if (left.type === 'paragraph' && right.type === 'paragraph') { + const isLeftEmpty = left.children.length === 0; + const isRightEmpty = right.children.length === 0; - if (isLeftEmpty || isRightEmpty) return 0; + // Join only in case both nodes are empty + if (isLeftEmpty && isRightEmpty) return 0; + // Otherwise do not change standard behavior return null; } diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts index 2f30080d9..c6ddd8c0f 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts @@ -34,7 +34,10 @@ export const fillGapsWithParagraphs = (tree: Root) => { const lineGap = next.position.start.line - current.position.end.line; // lineGap === 2 means exactly one blank line, 3 means two, etc. const blankLineCount = lineGap - 1; - const paragraphsCount = blankLineCount > 1 ? blankLineCount : 0; + // 1 or 2 blank lines means just a gap between paragraphs + // More than 2 blank lines means there are `n-2` paragraphs joined with no empty lines, + // and 1 line gap from each side + const paragraphsCount = Math.max(0, blankLineCount - 2); for (let b = 0; b < paragraphsCount; b++) { const line = current.position.end.line + 1 + b; From a9395e7f9d755f6467ec6b7065fb84ed93b9d112 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sat, 11 Jul 2026 13:33:12 +0200 Subject: [PATCH 15/22] test: fix dom test --- .../NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts index a2466bd22..bb886cd01 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts @@ -5,7 +5,7 @@ import { setCursorPosition } from '../utils/utils'; test(`Inserts image between text nodes`, async () => { const richEditor = await renderRichEditor({ - value: `My favorite image\n\n I love cat`, + value: `My favorite image\n\n\n\nI love cat`, }); const editor = screen.getByRole('textbox'); From 3e573fcaefa2d9bb060441edb8d5ef35caed07b8 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sat, 11 Jul 2026 13:52:28 +0200 Subject: [PATCH 16/22] test: update serialization cases --- .../remark/remarkPreserveBlankLines.test.ts | 8 ++++---- .../Markdown/serializeMarkdownTree.test.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts index bc7cd30d0..2465ab56c 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.test.ts @@ -17,16 +17,16 @@ test('Must not to add paragraphs for one line gap', () => { expect(fillGapsWithParagraphs(tree).children).toHaveLength(2); }); -test('Must add 1 paragraph for gap in 2 lines', () => { +test('Must not to add paragraphs for gap in 2 lines', () => { const tree = markdownProcessor.parse('Hello\n\n\nWorld'); expect(tree.children).toHaveLength(2); - expect(fillGapsWithParagraphs(tree).children).toHaveLength(3); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(2); }); -test('Must add 5 paragraphs for gap in 10 lines', () => { +test('Must add 8 paragraphs for gap in 10 lines', () => { const tree = markdownProcessor.parse('Hello\n' + '\n'.repeat(10) + 'World'); expect(tree.children).toHaveLength(2); - expect(fillGapsWithParagraphs(tree).children).toHaveLength(2 + 5); + expect(fillGapsWithParagraphs(tree).children).toHaveLength(2 + 8); }); // TODO: implement lines preservation diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts index 2b7bbcbfa..387483c03 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/serializeMarkdownTree.test.ts @@ -17,7 +17,7 @@ describe('AST serialization', () => { ).toBe('Hello\n\nWorld\n'); }); - test('Empty paragraph is considered as additional empty line', () => { + test('Empty paragraph is considered as additional empty line + 2 lines around', () => { expect( serializeMarkdownTree( u('root', { @@ -28,10 +28,10 @@ describe('AST serialization', () => { ] satisfies Paragraph[], }), ), - ).toBe('Hello\n\nWorld\n'); + ).toBe(`Hello${'\n'.repeat(3)}\nWorld\n`); }); - test('3 empty paragraphs is considered as 3 additional empty lines', () => { + test('3 empty paragraphs must be serialized as 5 empty lines', () => { expect( serializeMarkdownTree( u('root', { @@ -44,10 +44,10 @@ describe('AST serialization', () => { ] satisfies Paragraph[], }), ), - ).toBe('Hello\n\n\n\nWorld\n'); + ).toBe(`Hello${'\n'.repeat(5)}\nWorld\n`); }); - test('Quote with 3 empty paragraphs must contain 3 empty lines', () => { + test('Quote with 3 empty paragraphs must contain 5 empty lines', () => { expect( serializeMarkdownTree({ type: 'root', @@ -80,10 +80,10 @@ describe('AST serialization', () => { }, ], }), - ).toBe('> Hello\n>\n>\n>\n> World\n'); + ).toBe(`> Hello${`\n>`.repeat(5)}\n> World\n`); }); - test('Nested quote with 3 empty paragraphs must contain 3 empty lines', () => { + test('Nested quote with 3 empty paragraphs must contain 5 empty lines', () => { expect( serializeMarkdownTree({ type: 'root', @@ -133,6 +133,6 @@ describe('AST serialization', () => { }, ], }), - ).toBe('> Hello\n>\n>\n>\n> > Hello\n> >\n> >\n> >\n> > World\n'); + ).toBe(`> Hello${`\n>`.repeat(5)}\n> > Hello${'\n> >'.repeat(5)}\n> > World\n`); }); }); From e79bec4dd744db3cb93869566499a312fbe91778 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sun, 12 Jul 2026 15:39:54 +0200 Subject: [PATCH 17/22] chore: fix list styles --- .../RichEditor/RichEditorContent.tsx | 1 - .../RichEditor/RichTextContainer.tsx | 1 + .../RichEditor/theme/RichEditor.css | 21 ++++++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx index 51a82f082..5680bf89d 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx @@ -109,7 +109,6 @@ export const RichEditorContent = ({ width="100%" height="100%" overflow="auto" - className="RichEditor" css={{ ...styles.root, diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichTextContainer.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichTextContainer.tsx index 45016261a..f411dd670 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichTextContainer.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichTextContainer.tsx @@ -19,6 +19,7 @@ export const RichTextContainer = memo( outline="none" padding="1rem 1rem 5rem" overflow="auto" + className="RichEditor" {...props} asChild > diff --git a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css index 9219bd5ce..301fcc69e 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css +++ b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css @@ -396,6 +396,9 @@ list-style-position: outside; } +.RichEditor__ol1 { + list-style: auto; +} .RichEditor__ol2 { list-style-type: upper-alpha; } @@ -504,10 +507,26 @@ border-width: 0 2px 2px 0; } -ul > li.RichEditor__listItem:not([role='checkbox']) { +/* Set default marker style for usual list items */ +.RichEditor__ul > .RichEditor__listItem:not([role='checkbox']) { list-style: disc; } +/* Don't draw marker for list item that nest another list */ +.RichEditor__ul + > .RichEditor__listItem.RichEditor__nestedListItem:not(:has(> [data-lexical-text])) { + list-style-type: none; + + &::before { + display: none; + } +} + +/* No start padding for nested list, because li already have the one */ +.RichEditor__nestedListItem > .RichEditor__ul { + padding-inline-start: 0; +} + /* ============================================================ CODE TOKENS — unchanged, purely color ============================================================ */ From 436558d063f655c206e0e890f228ec771cfac683 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sun, 12 Jul 2026 15:44:32 +0200 Subject: [PATCH 18/22] ci: install playwright --- .github/workflows/codeql.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bed7b0680..86b490cd8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,7 +8,7 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: [master] pull_request: workflow_dispatch: @@ -25,6 +25,7 @@ jobs: tests: name: Tests runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@v6 @@ -48,6 +49,9 @@ jobs: - name: Linter run: npm run lint + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests run: npm test --workspaces --if-present @@ -68,7 +72,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ "javascript", "TypeScript" ] + language: ["javascript", "TypeScript"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed From d403848f3a978fc2825ce4a502e70f77cca849cd Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sun, 12 Jul 2026 21:24:40 +0200 Subject: [PATCH 19/22] fix: header styles --- .../app/src/features/NoteEditor/RichEditor/theme/RichEditor.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css index 301fcc69e..0749bc4be 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css +++ b/packages/app/src/features/NoteEditor/RichEditor/theme/RichEditor.css @@ -100,7 +100,6 @@ .RichEditor__h5 { font-size: 1rem; - text-transform: uppercase; letter-spacing: 0.02em; color: var(--chakra-colors-typography-secondary); } From 57787e1b8b4cc0342d75ec016c83811cd35228a4 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sun, 12 Jul 2026 21:33:58 +0200 Subject: [PATCH 20/22] docs: add readme --- packages/app/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/app/README.md diff --git a/packages/app/README.md b/packages/app/README.md new file mode 100644 index 000000000..df4f35f31 --- /dev/null +++ b/packages/app/README.md @@ -0,0 +1,15 @@ +# Start development + +Prepare deps +- Install deps via `npm install` +- Build deps via `npm run build --workspace=twofish` + +Configure repo to run tests +- Install the Playwright `npx playwright install --with-deps` +- To install Playwright on Linux Fedora, [use Distrobox](https://gist.github.com/pskopek/de9d79cf0511839dd5c97703be5cc624) + +Test code with `npm test`. We have following suffixes for test files +- `.dom.test.ts` for tests that use JSDOM +- `.browser.test.ts` for tests that use a vitest Playwright integration + +So for example if you want to run browser tests, just run `npm test .browser` \ No newline at end of file From 96f8f1894f5f9f5d53afbc65216b9d74082a523c Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sun, 12 Jul 2026 21:36:09 +0200 Subject: [PATCH 21/22] ci: build the app --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 86b490cd8..fee297f0b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: run: npm test --workspaces --if-present - name: Build - run: npm run build --workspace=deepink + run: npm run build --workspace=app analyze: name: Analyze From 995e814eb86317b2f1bdc886ffabdefd2c923376 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sun, 12 Jul 2026 21:43:52 +0200 Subject: [PATCH 22/22] ci: build the app --- .github/workflows/codeql.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fee297f0b..b1ec51b3e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,8 @@ jobs: run: npm test --workspaces --if-present - name: Build - run: npm run build --workspace=app + # The name from package.json is used as workspace name, not a dir name + run: npm run build --workspace deepink analyze: name: Analyze