summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-27 18:18:44 +0100
committersoryu <soryu@soryu.co>2026-04-27 18:18:44 +0100
commitf1c035dea861dae0744f3c562f2461e39702b70f (patch)
treeab0a2d252f57344f43b7682239acf15deaa9526a
parent7958f75ebfc408c6cd076820942abb2ba7cfa18c (diff)
parenta6a21fe58a209f163c54faefeab7b1a8254c81cc (diff)
downloadsoryu-f1c035dea861dae0744f3c562f2461e39702b70f.tar.gz
soryu-f1c035dea861dae0744f3c562f2461e39702b70f.zip
Merge remote-tracking branch 'origin/makima/soryu-co-soryu---makima--install-lexical-and-creat-97407702' into makima/directive-soryu-co-soryu---makima-19fd3e1d
-rw-r--r--frontend/package-lock.json394
-rw-r--r--frontend/package.json9
-rw-r--r--frontend/src/components/document/AutoSavePlugin.tsx140
-rw-r--r--frontend/src/components/document/DocumentEditor.css246
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx154
-rw-r--r--frontend/src/components/document/EditorTheme.ts30
-rw-r--r--frontend/tsconfig.tsbuildinfo2
7 files changed, 966 insertions, 9 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 230ed07..4c4bcbc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,8 +8,17 @@
"name": "pc98-vn",
"version": "0.0.1",
"dependencies": {
+ "@lexical/html": "^0.44.0",
+ "@lexical/link": "^0.44.0",
+ "@lexical/list": "^0.44.0",
+ "@lexical/markdown": "^0.44.0",
+ "@lexical/react": "^0.44.0",
+ "@lexical/rich-text": "^0.44.0",
+ "@lexical/selection": "^0.44.0",
+ "@lexical/utils": "^0.44.0",
"@nanostores/react": "^1.0.0",
"@types/three": "^0.180.0",
+ "lexical": "^0.44.0",
"nanostores": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -66,7 +75,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -675,6 +683,54 @@
"node": ">=12"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.27.19",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
+ "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.8",
+ "@floating-ui/utils": "^0.2.11",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "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==",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -710,6 +766,255 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lexical/clipboard": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.44.0.tgz",
+ "integrity": "sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/html": "0.44.0",
+ "@lexical/list": "0.44.0",
+ "@lexical/selection": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "@types/trusted-types": "^2.0.7",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/code-core": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.44.0.tgz",
+ "integrity": "sha512-m57JyXTIvW1tsqw/Vuogk8jqWCZZIeFQbWybRc46ytR8ReDgzPRODpN8+dacIIeRH5yC5UC3lAa743mtdNkxqg==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/devtools-core": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.44.0.tgz",
+ "integrity": "sha512-X3uNG3P1vOsdzmEcy+7m9DxAcIVtVUZnvskmLqqLs6VluVVwH9xy7h1bPsvlDKvj1Nj73tWJ3TW0qXQWDTo5tw==",
+ "dependencies": {
+ "@lexical/html": "0.44.0",
+ "@lexical/link": "0.44.0",
+ "@lexical/mark": "0.44.0",
+ "@lexical/table": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.x",
+ "react-dom": ">=17.x"
+ }
+ },
+ "node_modules/@lexical/dragon": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.44.0.tgz",
+ "integrity": "sha512-RhlsjVDket9k1+YFEkDE0/7Qyrh2BI0vxBMzrWwPJTXX/4YFanYN9su8RSabkIukBBJ3QiNOOoC8FKK4Lkr4qg==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/extension": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.44.0.tgz",
+ "integrity": "sha512-BsYtoc+0EU0pqcOpf/lIUDU6LQVO6zX2AawZoUWJzT3Wzfov23qsqZWvl2WGM9dnRTN5iISJL3Fl53bQVxiXxw==",
+ "dependencies": {
+ "@lexical/utils": "0.44.0",
+ "@preact/signals-core": "^1.14.1",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/hashtag": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.44.0.tgz",
+ "integrity": "sha512-0WATahDSqYKVTudQv3KpFbLeCpmrCpRptPFbjxOMckAX2MRpYlrExlqKfgfpri5BSQPtG49EPSGeNfSx/Faavw==",
+ "dependencies": {
+ "@lexical/text": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/history": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.44.0.tgz",
+ "integrity": "sha512-RGXcbFTgYL1GIWaReBI26mNSsJTfiA9EAtDY4LBeZ14NrIQhYNokKgNiOxq5Bn8xXrl2+mawQEqoMfgpWp/5YA==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/html": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.44.0.tgz",
+ "integrity": "sha512-5X6eGsgwtqPxABsuShUxF7ZfyB/U4GwSEyeonvwH1Vc/5Q2uQVjlB+FAYd+MNwWMHMh4d4+yZ3l70AtIuhr5eg==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/selection": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/link": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.44.0.tgz",
+ "integrity": "sha512-uvEqEol/mLEzGVQd8Rok9I48RgYPKokM/nsclI9nYcEdccVOM2Nri4ntoRwodhbccFLtjMPl8OBldwXbfc77tQ==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/list": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.44.0.tgz",
+ "integrity": "sha512-ZTCWxDz1okPrC9FBXi1yV3W5fbQQeMUlFIcSVF9HibcVPmCsPa900IxthuiQbGiTycUyXDTOB3IUYRtlJNtpjw==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/mark": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.44.0.tgz",
+ "integrity": "sha512-bWMowllwe6BcgYMAkrsZx6Z+CX/72qCQpFKhlkR4ael92yOWSBkz68xp1wxxkSnQX9zoI1gYTeWBofVsSDKcsQ==",
+ "dependencies": {
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/markdown": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.44.0.tgz",
+ "integrity": "sha512-DwlXdp85pYMo3exDF6W3iz8plpuP+RQ4Me4Iljm7O5aPDp0SSrIoZxyX4zS668mVAoz5HHj1Ka0kQkft8mq26Q==",
+ "dependencies": {
+ "@lexical/code-core": "0.44.0",
+ "@lexical/link": "0.44.0",
+ "@lexical/list": "0.44.0",
+ "@lexical/rich-text": "0.44.0",
+ "@lexical/text": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/overflow": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.44.0.tgz",
+ "integrity": "sha512-5GYaYjSxn27pqHRfU+tQ2STF10wgJvI+MUnwTnUFSzy3dko1b+oV94K/Yx0TuEewPbwDibfoFA8CwqUvOLHAyw==",
+ "dependencies": {
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/plain-text": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.44.0.tgz",
+ "integrity": "sha512-bIV4Lljk0x70zFhkZIwzSPK5q3m9FpDisjGm2/3Q/chb+5BW3Tv8QJmqnpCiSO6S2KXO7gfSy81ZfkQ1dcd4EQ==",
+ "dependencies": {
+ "@lexical/clipboard": "0.44.0",
+ "@lexical/dragon": "0.44.0",
+ "@lexical/selection": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/react": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.44.0.tgz",
+ "integrity": "sha512-p/NQd/fMh3pXb1XqegE2ruvWDcUmfB12OidQ9nwtMtj5VfcUjQu2I+trUhgGRIADxSYxMWmw+8PPj5YSf4m5oA==",
+ "dependencies": {
+ "@floating-ui/react": "^0.27.16",
+ "@lexical/devtools-core": "0.44.0",
+ "@lexical/dragon": "0.44.0",
+ "@lexical/extension": "0.44.0",
+ "@lexical/hashtag": "0.44.0",
+ "@lexical/history": "0.44.0",
+ "@lexical/link": "0.44.0",
+ "@lexical/list": "0.44.0",
+ "@lexical/mark": "0.44.0",
+ "@lexical/markdown": "0.44.0",
+ "@lexical/overflow": "0.44.0",
+ "@lexical/plain-text": "0.44.0",
+ "@lexical/rich-text": "0.44.0",
+ "@lexical/table": "0.44.0",
+ "@lexical/text": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "@lexical/yjs": "0.44.0",
+ "lexical": "0.44.0",
+ "react-error-boundary": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.x",
+ "react-dom": ">=17.x",
+ "yjs": ">=13.5.22"
+ },
+ "peerDependenciesMeta": {
+ "yjs": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@lexical/rich-text": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.44.0.tgz",
+ "integrity": "sha512-IIdrutK5GY47ITjPlZB7KzUi9dBDwygsyFOwolnrYSL7m6TtGhAqrYiFg/YNOTT/nBzK3KQeCJRbnxpjJAVZtQ==",
+ "dependencies": {
+ "@lexical/clipboard": "0.44.0",
+ "@lexical/dragon": "0.44.0",
+ "@lexical/selection": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/selection": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.44.0.tgz",
+ "integrity": "sha512-AEyeZJFFr5YRLeqVR+X0QAW19c4Fk4MFAQu52z2gxAyDGTj9xwVJxjfepVpfUp4P9K+sPtJ/yaqfMXH506ksSQ==",
+ "dependencies": {
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/table": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.44.0.tgz",
+ "integrity": "sha512-5Uq0O/fBCxcZp9y17fXUONY7dU9lVo/mB5JHy23laIiKzBKP5IzzTLMU9ikZTppIXbMNxYXd+R2pmy7PYTLyvw==",
+ "dependencies": {
+ "@lexical/clipboard": "0.44.0",
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/text": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.44.0.tgz",
+ "integrity": "sha512-1XJD8ZbwaXljTl8k4+jjiopdhnYZm26IJw9Gv8+cIThVC0b6B3JZ/WxH97BMDcSloKvWHFkGiPztxRwNwA29Rw==",
+ "dependencies": {
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/utils": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.44.0.tgz",
+ "integrity": "sha512-/D2ptztNevfBJgtkj4uaiYBeRcvSy+1mQj6pNYaCFZIoPJIwl6H5fXwWAvpvr11vcQKP9DEEoXR+V4qkMOA+EA==",
+ "dependencies": {
+ "@lexical/selection": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/yjs": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.44.0.tgz",
+ "integrity": "sha512-b3QTub9J/3LuwSSdooynb6GbMHBRyBT4xUbXzXqNPbDHgYe6CDrqf/uJIHRihIjAhOnPaHYqo9XUzitl++N1DQ==",
+ "dependencies": {
+ "@lexical/selection": "0.44.0",
+ "lexical": "0.44.0"
+ },
+ "peerDependencies": {
+ "yjs": ">=13.5.22"
+ }
+ },
"node_modules/@nanostores/react": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.0.0.tgz",
@@ -728,6 +1033,15 @@
"react": ">=18.0.0"
}
},
+ "node_modules/@preact/signals-core": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.1.tgz",
+ "integrity": "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1060,7 +1374,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"dev": true,
- "peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -1076,7 +1389,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
"integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
"dev": true,
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1110,6 +1422,11 @@
"meshoptimizer": "~0.22.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
+ },
"node_modules/@types/webxr": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.23.tgz",
@@ -1159,7 +1476,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -1303,6 +1619,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/isomorphic.js": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+ "peer": true,
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1332,6 +1658,32 @@
"node": ">=6"
}
},
+ "node_modules/lexical": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.44.0.tgz",
+ "integrity": "sha512-ReDUjRlFgkGoPWzvdjr7s16PUVpHATN+2NH2NiZs+PLlISTaIFFgKil2P467oP3Vg+XgmpDsUgmWZsFJTztYjg=="
+ },
+ "node_modules/lib0": {
+ "version": "0.2.117",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
+ "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
+ "peer": true,
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1391,7 +1743,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
@@ -1440,7 +1791,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1452,7 +1802,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1461,6 +1810,14 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-error-boundary": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz",
+ "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1567,6 +1924,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/tabbable": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
+ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="
+ },
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
@@ -1626,7 +1988,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1686,6 +2047,23 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
+ },
+ "node_modules/yjs": {
+ "version": "13.6.30",
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
+ "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
+ "peer": true,
+ "dependencies": {
+ "lib0": "^0.2.99"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 197c3d8..230a982 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,8 +9,17 @@
"preview": "vite preview"
},
"dependencies": {
+ "@lexical/html": "^0.44.0",
+ "@lexical/link": "^0.44.0",
+ "@lexical/list": "^0.44.0",
+ "@lexical/markdown": "^0.44.0",
+ "@lexical/react": "^0.44.0",
+ "@lexical/rich-text": "^0.44.0",
+ "@lexical/selection": "^0.44.0",
+ "@lexical/utils": "^0.44.0",
"@nanostores/react": "^1.0.0",
"@types/three": "^0.180.0",
+ "lexical": "^0.44.0",
"nanostores": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx
new file mode 100644
index 0000000..d3d0eb5
--- /dev/null
+++ b/frontend/src/components/document/AutoSavePlugin.tsx
@@ -0,0 +1,140 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
+import { UNDO_COMMAND } from 'lexical';
+
+const COUNTDOWN_DURATION_MS = 3000;
+const TICK_INTERVAL_MS = 50;
+
+interface AutoSavePluginProps {
+ onAutoSave: (content: string) => void;
+ getContent: () => string;
+ enabled?: boolean;
+}
+
+export default function AutoSavePlugin({
+ onAutoSave,
+ getContent,
+ enabled = true,
+}: AutoSavePluginProps) {
+ const [editor] = useLexicalComposerContext();
+ const [countdown, setCountdown] = useState<number | null>(null);
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+ const startTimeRef = useRef<number>(0);
+ const pendingContentRef = useRef<string>('');
+ const lastSavedContentRef = useRef<string>('');
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ setCountdown(null);
+ }, []);
+
+ const cancelCountdown = useCallback(() => {
+ clearTimer();
+ }, [clearTimer]);
+
+ const startCountdown = useCallback(
+ (content: string) => {
+ pendingContentRef.current = content;
+ clearTimer();
+
+ startTimeRef.current = Date.now();
+ setCountdown(COUNTDOWN_DURATION_MS);
+
+ timerRef.current = setInterval(() => {
+ const elapsed = Date.now() - startTimeRef.current;
+ const remaining = COUNTDOWN_DURATION_MS - elapsed;
+
+ if (remaining <= 0) {
+ clearTimer();
+ lastSavedContentRef.current = pendingContentRef.current;
+ onAutoSave(pendingContentRef.current);
+ } else {
+ setCountdown(remaining);
+ }
+ }, TICK_INTERVAL_MS);
+ },
+ [clearTimer, onAutoSave]
+ );
+
+ // Listen for editor updates (content changes)
+ useEffect(() => {
+ if (!enabled) return;
+
+ const unregister = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
+ // Only trigger on actual content changes
+ if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
+
+ const content = getContent();
+ if (content !== lastSavedContentRef.current) {
+ startCountdown(content);
+ }
+ });
+
+ return unregister;
+ }, [editor, enabled, getContent, startCountdown]);
+
+ // Listen for undo command to cancel countdown
+ useEffect(() => {
+ const unregister = editor.registerCommand(
+ UNDO_COMMAND,
+ () => {
+ cancelCountdown();
+ return false; // Don't prevent the undo from executing
+ },
+ 1 // COMMAND_PRIORITY_LOW
+ );
+
+ return unregister;
+ }, [editor, cancelCountdown]);
+
+ // Listen for Escape key to cancel countdown
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && countdown !== null) {
+ e.preventDefault();
+ cancelCountdown();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [countdown, cancelCountdown]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (timerRef.current !== null) {
+ clearInterval(timerRef.current);
+ }
+ };
+ }, []);
+
+ if (countdown === null) return null;
+
+ const progressPercent = (countdown / COUNTDOWN_DURATION_MS) * 100;
+ const secondsLeft = Math.ceil(countdown / 1000);
+
+ return (
+ <div className="autosave-bar">
+ <span className="autosave-bar-text">
+ Saving in {secondsLeft}s... <kbd>Esc</kbd> to cancel
+ </span>
+ <div className="autosave-bar-progress-track">
+ <div
+ className="autosave-bar-progress-fill"
+ style={{ width: `${progressPercent}%` }}
+ />
+ </div>
+ <button
+ className="autosave-bar-cancel"
+ onClick={cancelCountdown}
+ type="button"
+ >
+ Cancel
+ </button>
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/DocumentEditor.css b/frontend/src/components/document/DocumentEditor.css
new file mode 100644
index 0000000..0be1151
--- /dev/null
+++ b/frontend/src/components/document/DocumentEditor.css
@@ -0,0 +1,246 @@
+/* ============================================
+ Document Editor - Clean, modern document UI
+ ============================================ */
+
+.document-editor-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ position: relative;
+}
+
+/* ---- Lexical Root ---- */
+.doc-editor-root {
+ outline: none;
+ min-height: 400px;
+ padding: 1rem 0;
+ color: #1a1a2e;
+ line-height: 1.7;
+ font-size: 16px;
+}
+
+/* ---- Headings ---- */
+.doc-editor-h1 {
+ font-size: 2.25rem;
+ font-weight: 700;
+ color: #0f0f23;
+ margin: 0 0 0.25rem 0;
+ padding: 0;
+ line-height: 1.3;
+ letter-spacing: -0.02em;
+ border: none;
+}
+
+.doc-editor-h2 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: #1a1a2e;
+ margin: 1.5rem 0 0.5rem 0;
+ line-height: 1.4;
+}
+
+.doc-editor-h3 {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: #2a2a4a;
+ margin: 1.25rem 0 0.4rem 0;
+ line-height: 1.4;
+}
+
+/* ---- Paragraphs ---- */
+.doc-editor-paragraph {
+ margin: 0.4rem 0;
+ padding: 0;
+ color: #374151;
+ line-height: 1.7;
+}
+
+/* ---- Text Formatting ---- */
+.doc-editor-text-bold {
+ font-weight: 700;
+}
+
+.doc-editor-text-italic {
+ font-style: italic;
+}
+
+.doc-editor-text-underline {
+ text-decoration: underline;
+}
+
+.doc-editor-text-strikethrough {
+ text-decoration: line-through;
+}
+
+.doc-editor-text-code {
+ background: #f3f4f6;
+ border-radius: 3px;
+ padding: 0.15em 0.35em;
+ font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
+ font-size: 0.9em;
+ color: #e11d48;
+}
+
+/* ---- Lists ---- */
+.doc-editor-list-ul {
+ padding-left: 1.5rem;
+ margin: 0.5rem 0;
+ list-style-type: disc;
+}
+
+.doc-editor-list-ol {
+ padding-left: 1.5rem;
+ margin: 0.5rem 0;
+ list-style-type: decimal;
+}
+
+.doc-editor-listitem {
+ margin: 0.25rem 0;
+ color: #374151;
+}
+
+.doc-editor-nested-listitem {
+ list-style-type: circle;
+}
+
+/* ---- Links ---- */
+.doc-editor-link {
+ color: #2563eb;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.doc-editor-link:hover {
+ color: #1d4ed8;
+}
+
+/* ---- Placeholder ---- */
+.doc-editor-placeholder {
+ color: #9ca3af;
+ position: absolute;
+ top: 1rem;
+ left: 0;
+ pointer-events: none;
+ font-size: 16px;
+ user-select: none;
+}
+
+/* ---- Content Editable wrapper ---- */
+.doc-editor-input {
+ position: relative;
+}
+
+.doc-editor-content-editable {
+ outline: none;
+ position: relative;
+}
+
+/* ---- Divider between title and body ---- */
+.doc-editor-title-divider {
+ height: 1px;
+ background: #e5e7eb;
+ margin: 0.5rem 0 1rem 0;
+ border: none;
+}
+
+/* ============================================
+ Auto-Save Countdown Bar
+ ============================================ */
+
+.autosave-bar {
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 50;
+ background: #fefce8;
+ border-top: 1px solid #fde68a;
+ padding: 0.5rem 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ font-size: 0.85rem;
+ color: #92400e;
+ transition: opacity 0.2s ease;
+}
+
+.autosave-bar-hidden {
+ opacity: 0;
+ pointer-events: none;
+ height: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+.autosave-bar-text {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+}
+
+.autosave-bar-text kbd {
+ background: #fef3c7;
+ border: 1px solid #fde68a;
+ border-radius: 3px;
+ padding: 0.1em 0.4em;
+ font-size: 0.8em;
+ font-family: inherit;
+}
+
+.autosave-bar-progress-track {
+ flex: 1;
+ height: 4px;
+ background: #fde68a;
+ border-radius: 2px;
+ overflow: hidden;
+ min-width: 80px;
+}
+
+.autosave-bar-progress-fill {
+ height: 100%;
+ background: #f59e0b;
+ border-radius: 2px;
+ transition: width 0.1s linear;
+}
+
+.autosave-bar-cancel {
+ background: none;
+ border: 1px solid #d97706;
+ border-radius: 4px;
+ color: #92400e;
+ padding: 0.2rem 0.6rem;
+ cursor: pointer;
+ font-size: 0.8rem;
+ white-space: nowrap;
+ transition: background 0.15s ease;
+}
+
+.autosave-bar-cancel:hover {
+ background: #fef3c7;
+}
+
+/* ============================================
+ Responsive
+ ============================================ */
+
+@media (max-width: 640px) {
+ .document-editor-container {
+ padding: 1rem 0.75rem;
+ }
+
+ .doc-editor-h1 {
+ font-size: 1.75rem;
+ }
+
+ .doc-editor-root {
+ font-size: 15px;
+ }
+
+ .autosave-bar {
+ font-size: 0.78rem;
+ padding: 0.4rem 0.75rem;
+ }
+}
diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx
new file mode 100644
index 0000000..1dce39e
--- /dev/null
+++ b/frontend/src/components/document/DocumentEditor.tsx
@@ -0,0 +1,154 @@
+import { useCallback, useRef } from 'react';
+import { LexicalComposer } from '@lexical/react/LexicalComposer';
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
+import { ContentEditable } from '@lexical/react/LexicalContentEditable';
+import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
+import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
+import { HeadingNode } from '@lexical/rich-text';
+import { ListNode, ListItemNode } from '@lexical/list';
+import { LinkNode } from '@lexical/link';
+import {
+ $getRoot,
+ $createParagraphNode,
+ $createTextNode,
+ type EditorState,
+ type LexicalEditor,
+} from 'lexical';
+import { $createHeadingNode } from '@lexical/rich-text';
+
+import editorTheme from './EditorTheme';
+import AutoSavePlugin from './AutoSavePlugin';
+import './DocumentEditor.css';
+
+interface DocumentEditorProps {
+ directiveId: string;
+ title: string;
+ goal: string;
+ onGoalChange?: (newGoal: string) => void;
+ onTitleChange?: (newTitle: string) => void;
+ readOnly?: boolean;
+}
+
+function buildInitialEditorState(title: string, goal: string) {
+ return () => {
+ const root = $getRoot();
+
+ // Title as H1
+ const heading = $createHeadingNode('h1');
+ heading.append($createTextNode(title));
+ root.append(heading);
+
+ // Goal as paragraph(s), split by newlines
+ const lines = goal.split('\n');
+ for (const line of lines) {
+ const paragraph = $createParagraphNode();
+ if (line.trim()) {
+ paragraph.append($createTextNode(line));
+ }
+ root.append(paragraph);
+ }
+ };
+}
+
+function onError(error: Error) {
+ console.error('[DocumentEditor] Lexical error:', error);
+}
+
+export default function DocumentEditor({
+ directiveId,
+ title,
+ goal,
+ onGoalChange,
+ onTitleChange,
+ readOnly = false,
+}: DocumentEditorProps) {
+ const editorRef = useRef<LexicalEditor | null>(null);
+ const latestGoalRef = useRef(goal);
+ const latestTitleRef = useRef(title);
+
+ const initialConfig = {
+ namespace: `DocumentEditor-${directiveId}`,
+ theme: editorTheme,
+ editorState: buildInitialEditorState(title, goal),
+ nodes: [HeadingNode, ListNode, ListItemNode, LinkNode],
+ onError,
+ editable: !readOnly,
+ };
+
+ const handleChange = useCallback(
+ (_editorState: EditorState, editor: LexicalEditor) => {
+ editorRef.current = editor;
+
+ editor.getEditorState().read(() => {
+ const root = $getRoot();
+ const children = root.getChildren();
+
+ let newTitle = '';
+ const goalLines: string[] = [];
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const text = child.getTextContent();
+
+ if (i === 0 && child.getType() === 'heading') {
+ newTitle = text;
+ } else {
+ goalLines.push(text);
+ }
+ }
+
+ const newGoal = goalLines.join('\n');
+
+ if (newTitle !== latestTitleRef.current) {
+ latestTitleRef.current = newTitle;
+ onTitleChange?.(newTitle);
+ }
+
+ if (newGoal !== latestGoalRef.current) {
+ latestGoalRef.current = newGoal;
+ onGoalChange?.(newGoal);
+ }
+ });
+ },
+ [onGoalChange, onTitleChange]
+ );
+
+ const getContent = useCallback(() => {
+ return latestGoalRef.current;
+ }, []);
+
+ const handleAutoSave = useCallback(
+ (content: string) => {
+ onGoalChange?.(content);
+ },
+ [onGoalChange]
+ );
+
+ return (
+ <div className="document-editor-container">
+ <LexicalComposer initialConfig={initialConfig}>
+ <div className="doc-editor-input">
+ <RichTextPlugin
+ contentEditable={
+ <ContentEditable className="doc-editor-content-editable doc-editor-root" />
+ }
+ placeholder={
+ <div className="doc-editor-placeholder">Start writing...</div>
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ </div>
+ <HistoryPlugin />
+ <OnChangePlugin onChange={handleChange} />
+ {!readOnly && (
+ <AutoSavePlugin
+ onAutoSave={handleAutoSave}
+ getContent={getContent}
+ enabled={!readOnly}
+ />
+ )}
+ </LexicalComposer>
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/EditorTheme.ts b/frontend/src/components/document/EditorTheme.ts
new file mode 100644
index 0000000..5b336ad
--- /dev/null
+++ b/frontend/src/components/document/EditorTheme.ts
@@ -0,0 +1,30 @@
+import type { EditorThemeClasses } from 'lexical';
+
+const editorTheme: EditorThemeClasses = {
+ root: 'doc-editor-root',
+ paragraph: 'doc-editor-paragraph',
+ heading: {
+ h1: 'doc-editor-h1',
+ h2: 'doc-editor-h2',
+ h3: 'doc-editor-h3',
+ },
+ text: {
+ bold: 'doc-editor-text-bold',
+ italic: 'doc-editor-text-italic',
+ underline: 'doc-editor-text-underline',
+ strikethrough: 'doc-editor-text-strikethrough',
+ code: 'doc-editor-text-code',
+ },
+ list: {
+ ul: 'doc-editor-list-ul',
+ ol: 'doc-editor-list-ol',
+ listitem: 'doc-editor-listitem',
+ nested: {
+ listitem: 'doc-editor-nested-listitem',
+ },
+ },
+ link: 'doc-editor-link',
+ placeholder: 'doc-editor-placeholder',
+};
+
+export default editorTheme;
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index 79408dc..1548170 100644
--- a/frontend/tsconfig.tsbuildinfo
+++ b/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/documenteditor.tsx","./src/components/document/editortheme.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file