fix(markdown): polish editor UX — side panel, overlay sync, paste segregation
- Move markdown editor from canvas overlay to side panel (70% width) for better performance and editing experience - Add @blocknote/mantine for full BlockNoteView with default UI components - Fix socket reconnect loop caused by unstable pasteOpts object reference (memoize with useMemo) - Add ResizeObserver to markdown overlay cards for automatic height sync - Call mdOverlay.refreshAll() on every canvas change so overlays track pack/grid/arrange/save operations - Paste goes to BlockNote editor when contentEditable is focused - Simplify toolbar: single color picker (accent + auto-derived bg), title/name field, width presets S/M/L - Guard normalize/flip operations to skip markdown and sticky items - Fix title not updating on card preview (pass name prop, bump revision) - Fix preview not refreshing after editor save (revision counter + overlay refresh)
This commit is contained in:
Generated
+310
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/core": "^0.47.1",
|
"@blocknote/core": "^0.47.1",
|
||||||
|
"@blocknote/mantine": "^0.47.1",
|
||||||
"@blocknote/react": "^0.47.1",
|
"@blocknote/react": "^0.47.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"pixi-filters": "^6.1.5",
|
"pixi-filters": "^6.1.5",
|
||||||
@@ -266,6 +267,16 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -370,6 +381,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@blocknote/mantine": {
|
||||||
|
"version": "0.47.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.47.1.tgz",
|
||||||
|
"integrity": "sha512-lcmHo/o35lVrCoY6qhTLn3Rs+tiZTktG2fYffe8LK4/qqs1hId4Xp2pbARLOGnDEREKvCL1PUZ9JKifgzA0DhQ==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@blocknote/core": "0.47.1",
|
||||||
|
"@blocknote/react": "0.47.1",
|
||||||
|
"react-icons": "^5.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/core": "^8.3.11",
|
||||||
|
"@mantine/hooks": "^8.3.11",
|
||||||
|
"@mantine/utils": "^6.0.22",
|
||||||
|
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
|
||||||
|
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@blocknote/react": {
|
"node_modules/@blocknote/react": {
|
||||||
"version": "0.47.1",
|
"version": "0.47.1",
|
||||||
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.47.1.tgz",
|
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.47.1.tgz",
|
||||||
@@ -962,6 +991,46 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mantine/core": {
|
||||||
|
"version": "8.3.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.16.tgz",
|
||||||
|
"integrity": "sha512-YgmoYrb0I9UUDYfs9OVLvBUTgkA5vn5KZTfxlOaOSFxzhyYjrtwMQK+P7fvEIkenNQbbikhelj/IRcC2yNQyFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.16",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react-number-format": "^5.4.4",
|
||||||
|
"react-remove-scroll": "^2.7.1",
|
||||||
|
"react-textarea-autosize": "8.5.9",
|
||||||
|
"type-fest": "^4.41.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/hooks": "8.3.16",
|
||||||
|
"react": "^18.x || ^19.x",
|
||||||
|
"react-dom": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mantine/hooks": {
|
||||||
|
"version": "8.3.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.16.tgz",
|
||||||
|
"integrity": "sha512-DthR8/KDCLIip3KoXyn370HEiKxiOwH+ZeXs1UoSv/e58xR88OE85Slyb5b5pRsDa4ndYnES+j9wyRyrRXLWfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mantine/utils": {
|
||||||
|
"version": "6.0.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.22.tgz",
|
||||||
|
"integrity": "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mixmark-io/domino": {
|
"node_modules/@mixmark-io/domino": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
@@ -2013,6 +2082,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -2102,6 +2181,13 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
@@ -2449,6 +2535,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
@@ -4500,6 +4596,17 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-number-format": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -4510,6 +4617,55 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||||
@@ -4542,6 +4698,47 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-textarea-autosize": {
|
||||||
|
"version": "8.5.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||||
|
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"use-composed-ref": "^1.3.0",
|
||||||
|
"use-latest": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rehype-format": {
|
"node_modules/rehype-format": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
||||||
@@ -4909,6 +5106,13 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/turndown": {
|
"node_modules/turndown": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
|
||||||
@@ -4918,6 +5122,19 @@
|
|||||||
"@mixmark-io/domino": "^2.2.0"
|
"@mixmark-io/domino": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -5070,6 +5287,99 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-composed-ref": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-latest": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"use-isomorphic-layout-effect": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/core": "^0.47.1",
|
"@blocknote/core": "^0.47.1",
|
||||||
|
"@blocknote/mantine": "^0.47.1",
|
||||||
"@blocknote/react": "^0.47.1",
|
"@blocknote/react": "^0.47.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"pixi-filters": "^6.1.5",
|
"pixi-filters": "^6.1.5",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface CardEntry {
|
|||||||
contentMount: HTMLDivElement;
|
contentMount: HTMLDivElement;
|
||||||
/** Current item ID. */
|
/** Current item ID. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** ResizeObserver for auto-height updates. */
|
||||||
|
resizeObserver: ResizeObserver;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MarkdownOverlay {
|
export class MarkdownOverlay {
|
||||||
@@ -33,8 +35,8 @@ export class MarkdownOverlay {
|
|||||||
/** Callback when a card's height changes (from DOM measurement). */
|
/** Callback when a card's height changes (from DOM measurement). */
|
||||||
onHeightChange: ((id: string, newHeight: number) => void) | null = null;
|
onHeightChange: ((id: string, newHeight: number) => void) | null = null;
|
||||||
|
|
||||||
/** Callback to enter edit mode for a card (wired by Editor.tsx). */
|
/** Callback to enter/exit edit mode for a card (wired by Editor.tsx). */
|
||||||
onRequestEdit: ((id: string) => void) | null = null;
|
onRequestEdit: ((id: string | null) => void) | null = null;
|
||||||
|
|
||||||
/** Callback when checkbox is toggled in read mode. */
|
/** Callback when checkbox is toggled in read mode. */
|
||||||
onCheckboxToggle: ((id: string, newContent: string) => void) | null = null;
|
onCheckboxToggle: ((id: string, newContent: string) => void) | null = null;
|
||||||
@@ -64,6 +66,8 @@ export class MarkdownOverlay {
|
|||||||
for (const [cardId, entry] of this._cards) {
|
for (const [cardId, entry] of this._cards) {
|
||||||
entry.el.style.pointerEvents = cardId === id ? 'auto' : 'none';
|
entry.el.style.pointerEvents = cardId === id ? 'auto' : 'none';
|
||||||
}
|
}
|
||||||
|
// Notify React to open/close the side panel editor
|
||||||
|
this.onRequestEdit?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
get editingId(): string | null {
|
get editingId(): string | null {
|
||||||
@@ -171,8 +175,14 @@ export class MarkdownOverlay {
|
|||||||
const contentMount = document.createElement('div');
|
const contentMount = document.createElement('div');
|
||||||
el.appendChild(contentMount);
|
el.appendChild(contentMount);
|
||||||
|
|
||||||
|
// Auto-measure height whenever DOM content changes size
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
this._syncHeight(item.id);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(el);
|
||||||
|
|
||||||
this._container.appendChild(el);
|
this._container.appendChild(el);
|
||||||
this._cards.set(item.id, { el, contentMount, id: item.id });
|
this._cards.set(item.id, { el, contentMount, id: item.id, resizeObserver });
|
||||||
this.onChange?.();
|
this.onChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,9 +201,26 @@ export class MarkdownOverlay {
|
|||||||
entry.el.style.transform = `scale(${zoom})`;
|
entry.el.style.transform = `scale(${zoom})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sync data.h from DOM measurement — called by ResizeObserver. */
|
||||||
|
private _syncHeight(id: string): void {
|
||||||
|
const entry = this._cards.get(id);
|
||||||
|
const item = this._scene.getById(id);
|
||||||
|
if (!entry || !item || item.type !== 'markdown') return;
|
||||||
|
|
||||||
|
const h = entry.el.offsetHeight;
|
||||||
|
if (h > 0 && Math.abs(h - item.data.h) > 1) {
|
||||||
|
item.data.h = h;
|
||||||
|
if (item.displayObject instanceof MarkdownSprite) {
|
||||||
|
item.displayObject.updateFromData(item.data as MarkdownObject);
|
||||||
|
}
|
||||||
|
this.onHeightChange?.(id, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _removeCard(id: string): void {
|
private _removeCard(id: string): void {
|
||||||
const entry = this._cards.get(id);
|
const entry = this._cards.get(id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
entry.resizeObserver.disconnect();
|
||||||
entry.el.remove();
|
entry.el.remove();
|
||||||
this._cards.delete(id);
|
this._cards.delete(id);
|
||||||
this.onChange?.();
|
this.onChange?.();
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ export function setupPaste(
|
|||||||
},
|
},
|
||||||
): () => void {
|
): () => void {
|
||||||
async function onPaste(e: ClipboardEvent) {
|
async function onPaste(e: ClipboardEvent) {
|
||||||
|
// If focus is inside a contentEditable (e.g. BlockNote editor), let native paste through
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active instanceof HTMLElement && (active.isContentEditable || active.closest('[contenteditable]'))) return;
|
||||||
|
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { applyImageDisplayTransform, getImageDisplayTransform } from './imageTra
|
|||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
|
/** Items with fixed dimensions — scale must stay at 1. */
|
||||||
|
function isFixedSize(item: SceneItem): boolean {
|
||||||
|
return item.type === 'markdown' || item.type === 'sticky';
|
||||||
|
}
|
||||||
|
|
||||||
function scaledW(item: SceneItem): number {
|
function scaledW(item: SceneItem): number {
|
||||||
return item.data.w * item.data.sx;
|
return item.data.w * item.data.sx;
|
||||||
}
|
}
|
||||||
@@ -182,9 +187,11 @@ export function distributeVertical(objects: SceneItem[]) {
|
|||||||
|
|
||||||
export function normalizeSize(objects: SceneItem[]) {
|
export function normalizeSize(objects: SceneItem[]) {
|
||||||
if (objects.length < 2) return;
|
if (objects.length < 2) return;
|
||||||
const areas = objects.map((item) => scaledW(item) * scaledH(item));
|
const scalable = objects.filter(i => !isFixedSize(i));
|
||||||
|
if (scalable.length < 2) return;
|
||||||
|
const areas = scalable.map((item) => scaledW(item) * scaledH(item));
|
||||||
const avgArea = areas.reduce((a, b) => a + b, 0) / areas.length;
|
const avgArea = areas.reduce((a, b) => a + b, 0) / areas.length;
|
||||||
objects.forEach((item) => {
|
scalable.forEach((item) => {
|
||||||
const currentArea = scaledW(item) * scaledH(item);
|
const currentArea = scaledW(item) * scaledH(item);
|
||||||
if (currentArea <= 0) return;
|
if (currentArea <= 0) return;
|
||||||
const ratio = Math.sqrt(avgArea / currentArea);
|
const ratio = Math.sqrt(avgArea / currentArea);
|
||||||
@@ -196,9 +203,11 @@ export function normalizeSize(objects: SceneItem[]) {
|
|||||||
|
|
||||||
export function normalizeScale(objects: SceneItem[]) {
|
export function normalizeScale(objects: SceneItem[]) {
|
||||||
if (objects.length < 2) return;
|
if (objects.length < 2) return;
|
||||||
const avgSX = objects.reduce((s, item) => s + item.data.sx, 0) / objects.length;
|
const scalable = objects.filter(i => !isFixedSize(i));
|
||||||
const avgSY = objects.reduce((s, item) => s + item.data.sy, 0) / objects.length;
|
if (scalable.length < 2) return;
|
||||||
objects.forEach((item) => {
|
const avgSX = scalable.reduce((s, item) => s + item.data.sx, 0) / scalable.length;
|
||||||
|
const avgSY = scalable.reduce((s, item) => s + item.data.sy, 0) / scalable.length;
|
||||||
|
scalable.forEach((item) => {
|
||||||
item.data.sx = avgSX;
|
item.data.sx = avgSX;
|
||||||
item.data.sy = avgSY;
|
item.data.sy = avgSY;
|
||||||
syncScale(item);
|
syncScale(item);
|
||||||
@@ -207,8 +216,10 @@ export function normalizeScale(objects: SceneItem[]) {
|
|||||||
|
|
||||||
export function normalizeHeight(objects: SceneItem[]) {
|
export function normalizeHeight(objects: SceneItem[]) {
|
||||||
if (objects.length < 2) return;
|
if (objects.length < 2) return;
|
||||||
const avgH = objects.reduce((s, item) => s + scaledH(item), 0) / objects.length;
|
const scalable = objects.filter(i => !isFixedSize(i));
|
||||||
objects.forEach((item) => {
|
if (scalable.length < 2) return;
|
||||||
|
const avgH = scalable.reduce((s, item) => s + scaledH(item), 0) / scalable.length;
|
||||||
|
scalable.forEach((item) => {
|
||||||
const h = scaledH(item);
|
const h = scaledH(item);
|
||||||
if (h <= 0) return;
|
if (h <= 0) return;
|
||||||
const ratio = avgH / h;
|
const ratio = avgH / h;
|
||||||
@@ -220,8 +231,10 @@ export function normalizeHeight(objects: SceneItem[]) {
|
|||||||
|
|
||||||
export function normalizeWidth(objects: SceneItem[]) {
|
export function normalizeWidth(objects: SceneItem[]) {
|
||||||
if (objects.length < 2) return;
|
if (objects.length < 2) return;
|
||||||
const avgW = objects.reduce((s, item) => s + scaledW(item), 0) / objects.length;
|
const scalable = objects.filter(i => !isFixedSize(i));
|
||||||
objects.forEach((item) => {
|
if (scalable.length < 2) return;
|
||||||
|
const avgW = scalable.reduce((s, item) => s + scaledW(item), 0) / scalable.length;
|
||||||
|
scalable.forEach((item) => {
|
||||||
const w = scaledW(item);
|
const w = scaledW(item);
|
||||||
if (w <= 0) return;
|
if (w <= 0) return;
|
||||||
const ratio = avgW / w;
|
const ratio = avgW / w;
|
||||||
@@ -367,6 +380,7 @@ function layoutAsGrid(sorted: SceneItem[], anchor: { x: number; y: number }) {
|
|||||||
|
|
||||||
export function flipHorizontal(objects: SceneItem[]) {
|
export function flipHorizontal(objects: SceneItem[]) {
|
||||||
objects.forEach((item) => {
|
objects.forEach((item) => {
|
||||||
|
if (isFixedSize(item)) return;
|
||||||
item.data.flipX = !item.data.flipX;
|
item.data.flipX = !item.data.flipX;
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
|
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
|
||||||
@@ -378,6 +392,7 @@ export function flipHorizontal(objects: SceneItem[]) {
|
|||||||
|
|
||||||
export function flipVertical(objects: SceneItem[]) {
|
export function flipVertical(objects: SceneItem[]) {
|
||||||
objects.forEach((item) => {
|
objects.forEach((item) => {
|
||||||
|
if (isFixedSize(item)) return;
|
||||||
item.data.flipY = !item.data.flipY;
|
item.data.flipY = !item.data.flipY;
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
|
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* MarkdownEditView — BlockNote editor wrapper for markdown card edit mode.
|
* MarkdownEditView — BlockNote editor in a side panel.
|
||||||
* Lazy-loaded on first double-click via dynamic import().
|
* Lazy-loaded on first double-click via dynamic import().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useCreateBlockNote } from '@blocknote/react';
|
import { useCreateBlockNote } from '@blocknote/react';
|
||||||
import { BlockNoteViewRaw } from '@blocknote/react';
|
import { BlockNoteView } from '@blocknote/mantine';
|
||||||
import '@blocknote/react/style.css';
|
import '@blocknote/mantine/style.css';
|
||||||
import { MD_BLOCKNOTE_DARK_CSS } from '../canvas/markdownStyles';
|
import { MD_BLOCKNOTE_DARK_CSS } from '../canvas/markdownStyles';
|
||||||
|
|
||||||
interface MarkdownEditViewProps {
|
interface MarkdownEditViewProps {
|
||||||
@@ -16,11 +16,6 @@ interface MarkdownEditViewProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown conversion is built into the BlockNote editor instance:
|
|
||||||
// - editor.tryParseMarkdownToBlocks(md) — markdown → blocks
|
|
||||||
// - editor.blocksToMarkdownLossy(blocks) — blocks → markdown
|
|
||||||
// No separate @blocknote/xl-markdown package needed.
|
|
||||||
|
|
||||||
export default function MarkdownEditView(props: MarkdownEditViewProps) {
|
export default function MarkdownEditView(props: MarkdownEditViewProps) {
|
||||||
const { initialContent, accentColor, onSave, onCancel } = props;
|
const { initialContent, accentColor, onSave, onCancel } = props;
|
||||||
const savedRef = useRef(false);
|
const savedRef = useRef(false);
|
||||||
@@ -74,70 +69,67 @@ export default function MarkdownEditView(props: MarkdownEditViewProps) {
|
|||||||
doCancel();
|
doCancel();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', onKeyDown, true); // capture phase
|
document.addEventListener('keydown', onKeyDown, true);
|
||||||
return () => document.removeEventListener('keydown', onKeyDown, true);
|
return () => document.removeEventListener('keydown', onKeyDown, true);
|
||||||
}, [doSave, doCancel]);
|
}, [doSave, doCancel]);
|
||||||
|
|
||||||
// Click outside to save
|
|
||||||
useEffect(() => {
|
|
||||||
const onClick = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (!target.closest('[data-markdown-edit]')) {
|
|
||||||
doSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
document.addEventListener('pointerdown', onClick);
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
document.removeEventListener('pointerdown', onClick);
|
|
||||||
};
|
|
||||||
}, [doSave]);
|
|
||||||
|
|
||||||
// Header with Done/Cancel controls
|
|
||||||
const headerStyle: React.CSSProperties = {
|
|
||||||
padding: '7px 14px',
|
|
||||||
background: '#2a2a42',
|
|
||||||
borderBottom: '1px solid #333',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
fontSize: '11px',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-markdown-edit
|
data-markdown-edit
|
||||||
style={{
|
style={{
|
||||||
border: `1.5px solid ${accentColor}`,
|
display: 'flex',
|
||||||
borderRadius: '10px',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
height: '100%',
|
||||||
boxShadow: `0 4px 24px ${accentColor}33`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={headerStyle}>
|
{/* Header */}
|
||||||
<span style={{ color: '#ccc' }}>Editing</span>
|
<div style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
borderBottom: `2px solid ${accentColor}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#ccc', fontSize: '12px', fontWeight: 500 }}>Markdown Editor</span>
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
<span style={{ color: '#666', fontSize: '10px' }}>Esc cancel</span>
|
<span style={{ color: '#666', fontSize: '10px' }}>Esc cancel · Ctrl+Enter save</span>
|
||||||
|
<button
|
||||||
|
onClick={doCancel}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#aaa',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: '11px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={doSave}
|
onClick={doSave}
|
||||||
style={{
|
style={{
|
||||||
background: accentColor,
|
background: accentColor,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '6px',
|
||||||
padding: '3px 10px',
|
padding: '4px 12px',
|
||||||
fontSize: '10px',
|
fontSize: '11px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minHeight: '100px' }}>
|
|
||||||
<BlockNoteViewRaw editor={editor} theme="dark" />
|
{/* Editor */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<BlockNoteView editor={editor} theme="dark" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* MarkdownFormatToolbar — contextual toolbar for selected markdown cards.
|
* MarkdownFormatToolbar — contextual toolbar for selected markdown cards.
|
||||||
* Shows background color picker, width presets, and accent color picker.
|
* Shows name, color picker (sets accent + auto-derives bg), and width presets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
@@ -8,22 +8,37 @@ import React, { useState, useEffect } from 'react';
|
|||||||
interface MarkdownFormatToolbarProps {
|
interface MarkdownFormatToolbarProps {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
bgColor: string;
|
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
width: number;
|
width: number;
|
||||||
onBgColorChange: (color: string) => void;
|
name: string;
|
||||||
onAccentColorChange: (color: string) => void;
|
onColorChange: (accent: string, bg: string) => void;
|
||||||
onWidthChange: (width: number) => void;
|
onWidthChange: (width: number) => void;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
/** Derive a dark card background from an accent color. */
|
||||||
'#232336', '#1e1e2e', '#2a2a3a', '#1a2332', '#2a1a2a',
|
function deriveBg(accent: string): string {
|
||||||
'#1a2a1a', '#2a2a1a', '#333333', '#1a1a1a', '#3a2a42',
|
// Parse hex to RGB, then darken heavily
|
||||||
];
|
const hex = accent.replace('#', '');
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16) || 0;
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16) || 0;
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16) || 0;
|
||||||
|
// Mix ~15% accent into a dark base
|
||||||
|
const mix = (c: number) => Math.round(28 + c * 0.12);
|
||||||
|
return `#${mix(r).toString(16).padStart(2, '0')}${mix(g).toString(16).padStart(2, '0')}${mix(b).toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
const ACCENT_COLORS = [
|
const CARD_COLORS = [
|
||||||
'#7950f2', '#4dabf7', '#69db7c', '#ffd43b', '#ff6b6b',
|
{ accent: '#7950f2', label: 'Purple' },
|
||||||
'#e64980', '#ffa94d', '#868e96', '#ffffff', '#f783ac',
|
{ accent: '#4dabf7', label: 'Blue' },
|
||||||
|
{ accent: '#69db7c', label: 'Green' },
|
||||||
|
{ accent: '#ffd43b', label: 'Yellow' },
|
||||||
|
{ accent: '#ff6b6b', label: 'Red' },
|
||||||
|
{ accent: '#e64980', label: 'Pink' },
|
||||||
|
{ accent: '#ffa94d', label: 'Orange' },
|
||||||
|
{ accent: '#868e96', label: 'Gray' },
|
||||||
|
{ accent: '#f783ac', label: 'Rose' },
|
||||||
|
{ accent: '#20c997', label: 'Teal' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const WIDTH_PRESETS = [
|
const WIDTH_PRESETS = [
|
||||||
@@ -33,12 +48,11 @@ const WIDTH_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps) {
|
export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps) {
|
||||||
const { x, y, bgColor, accentColor, width, onBgColorChange, onAccentColorChange, onWidthChange } = props;
|
const { x, y, accentColor, width, name, onColorChange, onWidthChange, onNameChange } = props;
|
||||||
const [showBgPicker, setShowBgPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
const [showAccentPicker, setShowAccentPicker] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDown = () => { setShowBgPicker(false); setShowAccentPicker(false); };
|
const onDown = () => setShowPicker(false);
|
||||||
window.addEventListener('pointerdown', onDown);
|
window.addEventListener('pointerdown', onDown);
|
||||||
return () => window.removeEventListener('pointerdown', onDown);
|
return () => window.removeEventListener('pointerdown', onDown);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -62,19 +76,40 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps)
|
|||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Background color */}
|
{/* Title / name */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
placeholder="Untitled card"
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: '1px solid transparent',
|
||||||
|
borderRadius: '5px', color: '#ccc', fontSize: '11px',
|
||||||
|
padding: '2px 8px', width: '120px', outline: 'none',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { (e.target as HTMLInputElement).style.borderColor = '#555'; }}
|
||||||
|
onBlur={(e) => { (e.target as HTMLInputElement).style.borderColor = 'transparent'; }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Card color */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
style={{ ...btnStyle, gap: '4px', padding: '0 8px' }}
|
||||||
onClick={(e) => { e.stopPropagation(); setShowBgPicker((v) => !v); setShowAccentPicker(false); }}
|
onClick={(e) => { e.stopPropagation(); setShowPicker((v) => !v); }}
|
||||||
title="Card background"
|
title="Card color"
|
||||||
>
|
>
|
||||||
<div style={{ width: '14px', height: '14px', borderRadius: '3px', background: bgColor, border: '1px solid #555' }} />
|
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: accentColor, border: '1px solid #555' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#888' }}>Color</span>
|
||||||
</button>
|
</button>
|
||||||
{showBgPicker && (
|
{showPicker && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginTop: '4px',
|
||||||
background: 'rgba(22, 22, 22, 0.98)', border: '1px solid #333',
|
background: 'rgba(22, 22, 22, 0.98)', border: '1px solid #333',
|
||||||
borderRadius: '8px', padding: '8px', zIndex: 200,
|
borderRadius: '8px', padding: '8px', zIndex: 200,
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
@@ -82,13 +117,14 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps)
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px' }}>
|
||||||
{PRESET_COLORS.map((c) => (
|
{CARD_COLORS.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c}
|
key={c.accent}
|
||||||
onClick={() => { onBgColorChange(c); setShowBgPicker(false); }}
|
onClick={() => { onColorChange(c.accent, deriveBg(c.accent)); setShowPicker(false); }}
|
||||||
|
title={c.label}
|
||||||
style={{
|
style={{
|
||||||
width: '22px', height: '22px', borderRadius: '4px', background: c,
|
width: '22px', height: '22px', borderRadius: '4px', background: c.accent,
|
||||||
border: c === bgColor ? '2px solid #4a90d9' : '1px solid #444',
|
border: c.accent === accentColor ? '2px solid #fff' : '1px solid #444',
|
||||||
cursor: 'pointer', padding: 0,
|
cursor: 'pointer', padding: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -117,44 +153,6 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps)
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
|
||||||
|
|
||||||
{/* Accent color */}
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setShowAccentPicker((v) => !v); setShowBgPicker(false); }}
|
|
||||||
title="Accent color"
|
|
||||||
>
|
|
||||||
<div style={{ width: '14px', height: '14px', borderRadius: '6px', background: accentColor, border: '1px solid #555' }} />
|
|
||||||
</button>
|
|
||||||
{showAccentPicker && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
|
||||||
background: 'rgba(22, 22, 22, 0.98)', border: '1px solid #333',
|
|
||||||
borderRadius: '8px', padding: '8px', zIndex: 200,
|
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px' }}>
|
|
||||||
{ACCENT_COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
onClick={() => { onAccentColorChange(c); setShowAccentPicker(false); }}
|
|
||||||
style={{
|
|
||||||
width: '22px', height: '22px', borderRadius: '4px', background: c,
|
|
||||||
border: c === accentColor ? '2px solid #4a90d9' : '1px solid #444',
|
|
||||||
cursor: 'pointer', padding: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface MarkdownReadViewProps {
|
|||||||
accentColor: string;
|
accentColor: string;
|
||||||
bgColor: string;
|
bgColor: string;
|
||||||
padding: number;
|
padding: number;
|
||||||
|
name?: string;
|
||||||
onCheckboxToggle?: (newContent: string) => void;
|
onCheckboxToggle?: (newContent: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +71,13 @@ function toggleCheckbox(content: string, index: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MarkdownReadView(props: MarkdownReadViewProps) {
|
export default function MarkdownReadView(props: MarkdownReadViewProps) {
|
||||||
const { content, textColor, accentColor, bgColor: _bgColor, padding, onCheckboxToggle } = props;
|
const { content, textColor, accentColor, bgColor: _bgColor, padding, name, onCheckboxToggle } = props;
|
||||||
|
|
||||||
const displayContent = content.length > MD_MAX_CONTENT_LENGTH
|
const displayContent = content.length > MD_MAX_CONTENT_LENGTH
|
||||||
? content.slice(0, MD_MAX_CONTENT_LENGTH) + '\n\n---\n*Content truncated*'
|
? content.slice(0, MD_MAX_CONTENT_LENGTH) + '\n\n---\n*Content truncated*'
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
const title = extractTitle(content);
|
const title = name || extractTitle(content);
|
||||||
|
|
||||||
const checkboxIndexRef = React.useRef(0);
|
const checkboxIndexRef = React.useRef(0);
|
||||||
checkboxIndexRef.current = 0;
|
checkboxIndexRef.current = 0;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type { TextObject, StickyObject, MarkdownObject } from '../canvas/scene-f
|
|||||||
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||||
import { TextSprite } from '../canvas/sprites/TextSprite';
|
import { TextSprite } from '../canvas/sprites/TextSprite';
|
||||||
import { StickySprite } from '../canvas/sprites/StickySprite';
|
import { StickySprite } from '../canvas/sprites/StickySprite';
|
||||||
|
import { MarkdownSprite } from '../canvas/sprites/MarkdownSprite';
|
||||||
import * as ops from '../canvas/operations';
|
import * as ops from '../canvas/operations';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import MarkdownReadView from '../components/MarkdownReadView';
|
import MarkdownReadView from '../components/MarkdownReadView';
|
||||||
@@ -88,6 +89,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const inboxZoneRef = useRef<InboxZone | null>(null);
|
const inboxZoneRef = useRef<InboxZone | null>(null);
|
||||||
const clipboardRef = useRef<SceneItem[]>([]);
|
const clipboardRef = useRef<SceneItem[]>([]);
|
||||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mdOverlayRef = useRef<any>(null);
|
||||||
const [uploadManager] = useState(() => new UploadManager());
|
const [uploadManager] = useState(() => new UploadManager());
|
||||||
|
|
||||||
// Reset upload manager when switching boards
|
// Reset upload manager when switching boards
|
||||||
@@ -173,6 +175,8 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const onCanvasChange = useCallback((changedIds?: string[]) => {
|
const onCanvasChange = useCallback((changedIds?: string[]) => {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
setSceneVersion(v => v + 1);
|
setSceneVersion(v => v + 1);
|
||||||
|
// Reposition all markdown overlay divs to match canvas items
|
||||||
|
mdOverlayRef.current?.refreshAll();
|
||||||
if (changedIds && changedIds.length > 0) {
|
if (changedIds && changedIds.length > 0) {
|
||||||
// Incremental: broadcast only changed elements
|
// Incremental: broadcast only changed elements
|
||||||
syncRef.current?.broadcastElements(changedIds);
|
syncRef.current?.broadcastElements(changedIds);
|
||||||
@@ -256,9 +260,11 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
// ── Markdown overlay — sync visible card IDs for React portal rendering ──
|
// ── Markdown overlay — sync visible card IDs for React portal rendering ──
|
||||||
const [mdCardIds, setMdCardIds] = useState<string[]>([]);
|
const [mdCardIds, setMdCardIds] = useState<string[]>([]);
|
||||||
const [editingMdId, setEditingMdId] = useState<string | null>(null);
|
const [editingMdId, setEditingMdId] = useState<string | null>(null);
|
||||||
|
const [mdRevision, setMdRevision] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mdOverlay) return;
|
if (!mdOverlay) return;
|
||||||
|
mdOverlayRef.current = mdOverlay;
|
||||||
|
|
||||||
const syncIds = () => {
|
const syncIds = () => {
|
||||||
const scene = canvasRef.current?.getScene();
|
const scene = canvasRef.current?.getScene();
|
||||||
@@ -276,7 +282,14 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
mdOverlay.onChange = syncIds;
|
mdOverlay.onChange = syncIds;
|
||||||
mdOverlay.onRequestEdit = (id) => setEditingMdId(id);
|
mdOverlay.onRequestEdit = (id) => {
|
||||||
|
setEditingMdId(id);
|
||||||
|
if (id) {
|
||||||
|
selectionRef.current?.setEnabled(false);
|
||||||
|
} else {
|
||||||
|
selectionRef.current?.setEnabled(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
syncIds();
|
syncIds();
|
||||||
return () => {
|
return () => {
|
||||||
mdOverlay.onChange = null;
|
mdOverlay.onChange = null;
|
||||||
@@ -1144,22 +1157,37 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
<MarkdownFormatToolbar
|
<MarkdownFormatToolbar
|
||||||
x={mdToolbar.x}
|
x={mdToolbar.x}
|
||||||
y={mdToolbar.y}
|
y={mdToolbar.y}
|
||||||
bgColor={(mdToolbar.item.data as MarkdownObject).bgColor}
|
|
||||||
accentColor={(mdToolbar.item.data as MarkdownObject).accentColor}
|
accentColor={(mdToolbar.item.data as MarkdownObject).accentColor}
|
||||||
width={mdToolbar.item.data.w}
|
width={mdToolbar.item.data.w}
|
||||||
onBgColorChange={(color) => {
|
name={mdToolbar.item.data.name || ''}
|
||||||
(mdToolbar.item.data as MarkdownObject).bgColor = color;
|
onColorChange={(accent, bg) => {
|
||||||
onCanvasChange([mdToolbar.item.id]);
|
const data = mdToolbar.item.data as MarkdownObject;
|
||||||
}}
|
data.accentColor = accent;
|
||||||
onAccentColorChange={(color) => {
|
data.bgColor = bg;
|
||||||
(mdToolbar.item.data as MarkdownObject).accentColor = color;
|
if (mdToolbar.item.displayObject instanceof MarkdownSprite) {
|
||||||
|
mdToolbar.item.displayObject.updateFromData(data);
|
||||||
|
}
|
||||||
|
mdOverlay?.updateItem(mdToolbar.item.id);
|
||||||
onCanvasChange([mdToolbar.item.id]);
|
onCanvasChange([mdToolbar.item.id]);
|
||||||
|
updateOverlays();
|
||||||
}}
|
}}
|
||||||
onWidthChange={(w) => {
|
onWidthChange={(w) => {
|
||||||
mdToolbar.item.data.w = w;
|
const data = mdToolbar.item.data as MarkdownObject;
|
||||||
onCanvasChange([mdToolbar.item.id]);
|
data.w = w;
|
||||||
|
if (mdToolbar.item.displayObject instanceof MarkdownSprite) {
|
||||||
|
mdToolbar.item.displayObject.updateFromData(data);
|
||||||
|
}
|
||||||
|
mdOverlay?.updateItem(mdToolbar.item.id);
|
||||||
mdOverlay?.measureHeight(mdToolbar.item.id);
|
mdOverlay?.measureHeight(mdToolbar.item.id);
|
||||||
|
onCanvasChange([mdToolbar.item.id]);
|
||||||
selectionRef.current?.transformBox.update([mdToolbar.item]);
|
selectionRef.current?.transformBox.update([mdToolbar.item]);
|
||||||
|
updateOverlays();
|
||||||
|
}}
|
||||||
|
onNameChange={(n) => {
|
||||||
|
mdToolbar.item.data.name = n;
|
||||||
|
setMdToolbar({ ...mdToolbar });
|
||||||
|
setMdRevision(r => r + 1);
|
||||||
|
onCanvasChange([mdToolbar.item.id]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1478,7 +1506,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const data = item.data as MarkdownObject;
|
const data = item.data as MarkdownObject;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '420px', minWidth: '420px',
|
width: '70%', minWidth: '400px', maxWidth: '900px',
|
||||||
background: '#1a1a2e',
|
background: '#1a1a2e',
|
||||||
borderLeft: '1px solid #333',
|
borderLeft: '1px solid #333',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
@@ -1492,12 +1520,24 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
initialContent={data.content}
|
initialContent={data.content}
|
||||||
accentColor={data.accentColor}
|
accentColor={data.accentColor}
|
||||||
onSave={(newContent) => {
|
onSave={(newContent) => {
|
||||||
|
const savedId = editingMdId!;
|
||||||
data.content = newContent;
|
data.content = newContent;
|
||||||
|
const itemRef = canvasRef.current?.getScene()?.getById(savedId);
|
||||||
|
if (itemRef?.displayObject instanceof MarkdownSprite) {
|
||||||
|
itemRef.displayObject.updateFromData(data);
|
||||||
|
}
|
||||||
setEditingMdId(null);
|
setEditingMdId(null);
|
||||||
mdOverlay?.setEditing(null);
|
mdOverlay?.setEditing(null);
|
||||||
selectionRef.current?.setEnabled(true);
|
selectionRef.current?.setEnabled(true);
|
||||||
onCanvasChange([editingMdId]);
|
setMdRevision(r => r + 1);
|
||||||
mdOverlay?.measureHeight(editingMdId);
|
onCanvasChange([savedId]);
|
||||||
|
mdOverlay?.updateItem(savedId);
|
||||||
|
// Measure height after React re-renders the portal with new content.
|
||||||
|
// Multiple passes: react-markdown renders async, DOM needs layout time.
|
||||||
|
const measure = () => mdOverlay?.measureHeight(savedId);
|
||||||
|
setTimeout(measure, 50);
|
||||||
|
setTimeout(measure, 200);
|
||||||
|
setTimeout(measure, 500);
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setEditingMdId(null);
|
setEditingMdId(null);
|
||||||
@@ -1544,8 +1584,9 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const data = item.data as MarkdownObject;
|
const data = item.data as MarkdownObject;
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<MarkdownReadView
|
<MarkdownReadView
|
||||||
key={id}
|
key={`${id}-${mdRevision}`}
|
||||||
content={data.content}
|
content={data.content}
|
||||||
|
name={data.name}
|
||||||
textColor={data.textColor}
|
textColor={data.textColor}
|
||||||
accentColor={data.accentColor}
|
accentColor={data.accentColor}
|
||||||
bgColor={data.bgColor}
|
bgColor={data.bgColor}
|
||||||
|
|||||||
Reference in New Issue
Block a user