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:
@@ -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?.();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user