From accbe2e0f3be74165ab06e356fcd334456ad99ff Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 11:17:44 +0530 Subject: [PATCH] =?UTF-8?q?fix(markdown):=20polish=20editor=20UX=20?= =?UTF-8?q?=E2=80=94=20side=20panel,=20overlay=20sync,=20paste=20segregati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/package-lock.json | 310 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/canvas/MarkdownOverlay.ts | 33 +- frontend/src/canvas/image-drop.ts | 4 + frontend/src/canvas/operations.ts | 33 +- frontend/src/components/MarkdownEditView.tsx | 90 +++-- .../src/components/MarkdownFormatToolbar.tsx | 128 ++++---- frontend/src/components/MarkdownReadView.tsx | 5 +- frontend/src/pages/Editor.tsx | 69 +++- 9 files changed, 531 insertions(+), 142 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f794f9..d9ee91e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@blocknote/core": "^0.47.1", + "@blocknote/mantine": "^0.47.1", "@blocknote/react": "^0.47.1", "axios": "^1.7.9", "pixi-filters": "^6.1.5", @@ -266,6 +267,16 @@ "@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": { "version": "7.28.6", "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": { "version": "0.47.1", "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.47.1.tgz", @@ -962,6 +991,46 @@ "@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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -2013,6 +2082,16 @@ "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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2102,6 +2181,13 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -2449,6 +2535,16 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4500,6 +4596,17 @@ "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": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4510,6 +4617,55 @@ "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": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -4542,6 +4698,47 @@ "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", @@ -4909,6 +5106,13 @@ "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": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", @@ -4918,6 +5122,19 @@ "@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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5070,6 +5287,99 @@ "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": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 58a99ed..0f06a3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@blocknote/core": "^0.47.1", + "@blocknote/mantine": "^0.47.1", "@blocknote/react": "^0.47.1", "axios": "^1.7.9", "pixi-filters": "^6.1.5", diff --git a/frontend/src/canvas/MarkdownOverlay.ts b/frontend/src/canvas/MarkdownOverlay.ts index 62671a4..9839100 100644 --- a/frontend/src/canvas/MarkdownOverlay.ts +++ b/frontend/src/canvas/MarkdownOverlay.ts @@ -21,6 +21,8 @@ interface CardEntry { contentMount: HTMLDivElement; /** Current item ID. */ id: string; + /** ResizeObserver for auto-height updates. */ + resizeObserver: ResizeObserver; } export class MarkdownOverlay { @@ -33,8 +35,8 @@ export class MarkdownOverlay { /** Callback when a card's height changes (from DOM measurement). */ onHeightChange: ((id: string, newHeight: number) => void) | null = null; - /** Callback to enter edit mode for a card (wired by Editor.tsx). */ - onRequestEdit: ((id: string) => void) | null = null; + /** Callback to enter/exit edit mode for a card (wired by Editor.tsx). */ + onRequestEdit: ((id: string | null) => void) | null = null; /** Callback when checkbox is toggled in read mode. */ onCheckboxToggle: ((id: string, newContent: string) => void) | null = null; @@ -64,6 +66,8 @@ export class MarkdownOverlay { for (const [cardId, entry] of this._cards) { entry.el.style.pointerEvents = cardId === id ? 'auto' : 'none'; } + // Notify React to open/close the side panel editor + this.onRequestEdit?.(id); } get editingId(): string | null { @@ -171,8 +175,14 @@ export class MarkdownOverlay { const contentMount = document.createElement('div'); 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._cards.set(item.id, { el, contentMount, id: item.id }); + this._cards.set(item.id, { el, contentMount, id: item.id, resizeObserver }); this.onChange?.(); } @@ -191,9 +201,26 @@ export class MarkdownOverlay { 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 { const entry = this._cards.get(id); if (!entry) return; + entry.resizeObserver.disconnect(); entry.el.remove(); this._cards.delete(id); this.onChange?.(); diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 3fdaff5..145a780 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -330,6 +330,10 @@ export function setupPaste( }, ): () => void { 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; if (!items) return; diff --git a/frontend/src/canvas/operations.ts b/frontend/src/canvas/operations.ts index d6fba90..06a3f36 100644 --- a/frontend/src/canvas/operations.ts +++ b/frontend/src/canvas/operations.ts @@ -11,6 +11,11 @@ import { applyImageDisplayTransform, getImageDisplayTransform } from './imageTra // ─── 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 { return item.data.w * item.data.sx; } @@ -182,9 +187,11 @@ export function distributeVertical(objects: SceneItem[]) { export function normalizeSize(objects: SceneItem[]) { 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; - objects.forEach((item) => { + scalable.forEach((item) => { const currentArea = scaledW(item) * scaledH(item); if (currentArea <= 0) return; const ratio = Math.sqrt(avgArea / currentArea); @@ -196,9 +203,11 @@ export function normalizeSize(objects: SceneItem[]) { export function normalizeScale(objects: SceneItem[]) { if (objects.length < 2) return; - const avgSX = objects.reduce((s, item) => s + item.data.sx, 0) / objects.length; - const avgSY = objects.reduce((s, item) => s + item.data.sy, 0) / objects.length; - objects.forEach((item) => { + const scalable = objects.filter(i => !isFixedSize(i)); + if (scalable.length < 2) return; + 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.sy = avgSY; syncScale(item); @@ -207,8 +216,10 @@ export function normalizeScale(objects: SceneItem[]) { export function normalizeHeight(objects: SceneItem[]) { if (objects.length < 2) return; - const avgH = objects.reduce((s, item) => s + scaledH(item), 0) / objects.length; - objects.forEach((item) => { + const scalable = objects.filter(i => !isFixedSize(i)); + if (scalable.length < 2) return; + const avgH = scalable.reduce((s, item) => s + scaledH(item), 0) / scalable.length; + scalable.forEach((item) => { const h = scaledH(item); if (h <= 0) return; const ratio = avgH / h; @@ -220,8 +231,10 @@ export function normalizeHeight(objects: SceneItem[]) { export function normalizeWidth(objects: SceneItem[]) { if (objects.length < 2) return; - const avgW = objects.reduce((s, item) => s + scaledW(item), 0) / objects.length; - objects.forEach((item) => { + const scalable = objects.filter(i => !isFixedSize(i)); + if (scalable.length < 2) return; + const avgW = scalable.reduce((s, item) => s + scaledW(item), 0) / scalable.length; + scalable.forEach((item) => { const w = scaledW(item); if (w <= 0) return; const ratio = avgW / w; @@ -367,6 +380,7 @@ function layoutAsGrid(sorted: SceneItem[], anchor: { x: number; y: number }) { export function flipHorizontal(objects: SceneItem[]) { objects.forEach((item) => { + if (isFixedSize(item)) return; item.data.flipX = !item.data.flipX; if (item.type === 'image') { applyImageDisplayTransform(item.displayObject, item.data as ImageObject); @@ -378,6 +392,7 @@ export function flipHorizontal(objects: SceneItem[]) { export function flipVertical(objects: SceneItem[]) { objects.forEach((item) => { + if (isFixedSize(item)) return; item.data.flipY = !item.data.flipY; if (item.type === 'image') { applyImageDisplayTransform(item.displayObject, item.data as ImageObject); diff --git a/frontend/src/components/MarkdownEditView.tsx b/frontend/src/components/MarkdownEditView.tsx index 0a08698..26c3e78 100644 --- a/frontend/src/components/MarkdownEditView.tsx +++ b/frontend/src/components/MarkdownEditView.tsx @@ -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(). */ import React, { useCallback, useEffect, useRef } from 'react'; import { useCreateBlockNote } from '@blocknote/react'; -import { BlockNoteViewRaw } from '@blocknote/react'; -import '@blocknote/react/style.css'; +import { BlockNoteView } from '@blocknote/mantine'; +import '@blocknote/mantine/style.css'; import { MD_BLOCKNOTE_DARK_CSS } from '../canvas/markdownStyles'; interface MarkdownEditViewProps { @@ -16,11 +16,6 @@ interface MarkdownEditViewProps { 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) { const { initialContent, accentColor, onSave, onCancel } = props; const savedRef = useRef(false); @@ -74,70 +69,67 @@ export default function MarkdownEditView(props: MarkdownEditViewProps) { doCancel(); } }; - document.addEventListener('keydown', onKeyDown, true); // capture phase + document.addEventListener('keydown', onKeyDown, true); return () => document.removeEventListener('keydown', onKeyDown, true); }, [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 (
-
- Editing + {/* Header */} +
+ Markdown Editor
- Esc cancel + Esc cancel · Ctrl+Enter save +
-
- + + {/* Editor */} +
+
); diff --git a/frontend/src/components/MarkdownFormatToolbar.tsx b/frontend/src/components/MarkdownFormatToolbar.tsx index d9ca1d0..a74980a 100644 --- a/frontend/src/components/MarkdownFormatToolbar.tsx +++ b/frontend/src/components/MarkdownFormatToolbar.tsx @@ -1,6 +1,6 @@ /** * 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'; @@ -8,22 +8,37 @@ import React, { useState, useEffect } from 'react'; interface MarkdownFormatToolbarProps { x: number; y: number; - bgColor: string; accentColor: string; width: number; - onBgColorChange: (color: string) => void; - onAccentColorChange: (color: string) => void; + name: string; + onColorChange: (accent: string, bg: string) => void; onWidthChange: (width: number) => void; + onNameChange: (name: string) => void; } -const PRESET_COLORS = [ - '#232336', '#1e1e2e', '#2a2a3a', '#1a2332', '#2a1a2a', - '#1a2a1a', '#2a2a1a', '#333333', '#1a1a1a', '#3a2a42', -]; +/** Derive a dark card background from an accent color. */ +function deriveBg(accent: string): string { + // 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 = [ - '#7950f2', '#4dabf7', '#69db7c', '#ffd43b', '#ff6b6b', - '#e64980', '#ffa94d', '#868e96', '#ffffff', '#f783ac', +const CARD_COLORS = [ + { accent: '#7950f2', label: 'Purple' }, + { 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 = [ @@ -33,12 +48,11 @@ const WIDTH_PRESETS = [ ]; export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps) { - const { x, y, bgColor, accentColor, width, onBgColorChange, onAccentColorChange, onWidthChange } = props; - const [showBgPicker, setShowBgPicker] = useState(false); - const [showAccentPicker, setShowAccentPicker] = useState(false); + const { x, y, accentColor, width, name, onColorChange, onWidthChange, onNameChange } = props; + const [showPicker, setShowPicker] = useState(false); useEffect(() => { - const onDown = () => { setShowBgPicker(false); setShowAccentPicker(false); }; + const onDown = () => setShowPicker(false); window.addEventListener('pointerdown', onDown); return () => window.removeEventListener('pointerdown', onDown); }, []); @@ -62,19 +76,40 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps) }} onPointerDown={(e) => e.stopPropagation()} > - {/* Background color */} + {/* Title / name */} + 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(); }} + /> + +
+ + {/* Card color */}
- {showBgPicker && ( + {showPicker && (
e.stopPropagation()} >
- {PRESET_COLORS.map((c) => ( + {CARD_COLORS.map((c) => ( ))}
- -
- - {/* Accent color */} -
- - {showAccentPicker && ( -
e.stopPropagation()} - > -
- {ACCENT_COLORS.map((c) => ( -
-
- )} -
); } diff --git a/frontend/src/components/MarkdownReadView.tsx b/frontend/src/components/MarkdownReadView.tsx index 23b2745..12b54cd 100644 --- a/frontend/src/components/MarkdownReadView.tsx +++ b/frontend/src/components/MarkdownReadView.tsx @@ -21,6 +21,7 @@ interface MarkdownReadViewProps { accentColor: string; bgColor: string; padding: number; + name?: string; onCheckboxToggle?: (newContent: string) => void; } @@ -70,13 +71,13 @@ function toggleCheckbox(content: string, index: number): string { } 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 ? content.slice(0, MD_MAX_CONTENT_LENGTH) + '\n\n---\n*Content truncated*' : content; - const title = extractTitle(content); + const title = name || extractTitle(content); const checkboxIndexRef = React.useRef(0); checkboxIndexRef.current = 0; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 1cc3de5..754f363 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -40,6 +40,7 @@ import type { TextObject, StickyObject, MarkdownObject } from '../canvas/scene-f import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { TextSprite } from '../canvas/sprites/TextSprite'; import { StickySprite } from '../canvas/sprites/StickySprite'; +import { MarkdownSprite } from '../canvas/sprites/MarkdownSprite'; import * as ops from '../canvas/operations'; import ReactDOM from 'react-dom'; import MarkdownReadView from '../components/MarkdownReadView'; @@ -88,6 +89,7 @@ export default function Editor({ isPublicView }: EditorProps) { const inboxZoneRef = useRef(null); const clipboardRef = useRef([]); const canvasContainerRef = useRef(null); + const mdOverlayRef = useRef(null); const [uploadManager] = useState(() => new UploadManager()); // Reset upload manager when switching boards @@ -173,6 +175,8 @@ export default function Editor({ isPublicView }: EditorProps) { const onCanvasChange = useCallback((changedIds?: string[]) => { scheduleSave(); setSceneVersion(v => v + 1); + // Reposition all markdown overlay divs to match canvas items + mdOverlayRef.current?.refreshAll(); if (changedIds && changedIds.length > 0) { // Incremental: broadcast only changed elements syncRef.current?.broadcastElements(changedIds); @@ -256,9 +260,11 @@ export default function Editor({ isPublicView }: EditorProps) { // ── Markdown overlay — sync visible card IDs for React portal rendering ── const [mdCardIds, setMdCardIds] = useState([]); const [editingMdId, setEditingMdId] = useState(null); + const [mdRevision, setMdRevision] = useState(0); useEffect(() => { if (!mdOverlay) return; + mdOverlayRef.current = mdOverlay; const syncIds = () => { const scene = canvasRef.current?.getScene(); @@ -276,7 +282,14 @@ export default function Editor({ isPublicView }: EditorProps) { }; 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(); return () => { mdOverlay.onChange = null; @@ -1144,22 +1157,37 @@ export default function Editor({ isPublicView }: EditorProps) { { - (mdToolbar.item.data as MarkdownObject).bgColor = color; - onCanvasChange([mdToolbar.item.id]); - }} - onAccentColorChange={(color) => { - (mdToolbar.item.data as MarkdownObject).accentColor = color; + name={mdToolbar.item.data.name || ''} + onColorChange={(accent, bg) => { + const data = mdToolbar.item.data as MarkdownObject; + data.accentColor = accent; + data.bgColor = bg; + if (mdToolbar.item.displayObject instanceof MarkdownSprite) { + mdToolbar.item.displayObject.updateFromData(data); + } + mdOverlay?.updateItem(mdToolbar.item.id); onCanvasChange([mdToolbar.item.id]); + updateOverlays(); }} onWidthChange={(w) => { - mdToolbar.item.data.w = w; - onCanvasChange([mdToolbar.item.id]); + const data = mdToolbar.item.data as MarkdownObject; + data.w = w; + if (mdToolbar.item.displayObject instanceof MarkdownSprite) { + mdToolbar.item.displayObject.updateFromData(data); + } + mdOverlay?.updateItem(mdToolbar.item.id); mdOverlay?.measureHeight(mdToolbar.item.id); + onCanvasChange([mdToolbar.item.id]); 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; return (
{ + const savedId = editingMdId!; data.content = newContent; + const itemRef = canvasRef.current?.getScene()?.getById(savedId); + if (itemRef?.displayObject instanceof MarkdownSprite) { + itemRef.displayObject.updateFromData(data); + } setEditingMdId(null); mdOverlay?.setEditing(null); selectionRef.current?.setEnabled(true); - onCanvasChange([editingMdId]); - mdOverlay?.measureHeight(editingMdId); + setMdRevision(r => r + 1); + 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={() => { setEditingMdId(null); @@ -1544,8 +1584,9 @@ export default function Editor({ isPublicView }: EditorProps) { const data = item.data as MarkdownObject; return ReactDOM.createPortal(