From 72ae4e6bbac1b47a32c9e196d39d6b8e0190b08b Mon Sep 17 00:00:00 2001 From: Soheel Date: Fri, 17 Jul 2026 01:43:57 +0200 Subject: [PATCH] fix: keep focused properties in sync with selection --- src/components/canvas-context-menu.tsx | 21 +- src/components/canvas-overlay.tsx | 100 +++- src/components/properties-panel.tsx | 477 +++++++++++------- src/components/ruler.tsx | 26 +- src/hooks/use-drawing-tool.ts | 11 +- src/hooks/use-element-drag.ts | 9 +- src/hooks/use-marquee-selection.ts | 4 +- src/hooks/use-multi-resize.ts | 15 +- src/lib/file-io/adapters/tauri.ts | 3 +- src/stores/editor-store.ts | 392 ++++++++++---- tests/components/properties-panel.test.tsx | 145 ++++++ tests/hooks/use-element-drag.test.ts | 101 ++++ .../stores/editor-store-property-edit.test.ts | 211 ++++++++ 13 files changed, 1172 insertions(+), 343 deletions(-) create mode 100644 tests/components/properties-panel.test.tsx create mode 100644 tests/hooks/use-element-drag.test.ts create mode 100644 tests/stores/editor-store-property-edit.test.ts diff --git a/src/components/canvas-context-menu.tsx b/src/components/canvas-context-menu.tsx index cb6bf484..545786b8 100644 --- a/src/components/canvas-context-menu.tsx +++ b/src/components/canvas-context-menu.tsx @@ -8,7 +8,7 @@ import { getUniqueName, } from "@/lib/form-element-model"; import { formatShortcut } from "@/lib/shortcuts"; -import { useEditorStore } from "@/stores/editor-store"; +import { getDisplayElements, useEditorStore } from "@/stores/editor-store"; import { AlignLeft, ChevronDown, @@ -119,7 +119,7 @@ export function CanvasContextMenu({ label: t("contextMenu.selectAll"), shortcut: formatShortcut("selectAll"), action: () => { - const pageElements = store.elements.filter( + const pageElements = getDisplayElements(store).filter( (el) => el.pageNumber === context.pageNumber, ); store.selectElements(new Set(pageElements.map((el) => el.id))); @@ -129,7 +129,7 @@ export function CanvasContextMenu({ } if (context.type === "canvas") { - const elements = store.elements; + const elements = getDisplayElements(store); const result: MenuEntry[] = []; if (clipboard.length > 0) { @@ -228,7 +228,10 @@ export function CanvasContextMenu({ x: context.pdfX, y: context.pdfY, pageNumber: context.pageNumber, - name: getUniqueName(`optionlist_${elements.length + 1}`, elements), + name: getUniqueName( + `optionlist_${elements.length + 1}`, + elements, + ), }); store.addElement(newEl); store.selectElements(new Set([newEl.id])); @@ -255,7 +258,7 @@ export function CanvasContextMenu({ label: t("contextMenu.selectAll"), shortcut: formatShortcut("selectAll"), action: () => { - const pageElements = store.elements.filter( + const pageElements = getDisplayElements(store).filter( (el) => el.pageNumber === context.pageNumber, ); store.selectElements(new Set(pageElements.map((el) => el.id))); @@ -386,7 +389,13 @@ export function CanvasContextMenu({ > {entries.map((entry, i) => { if ("separator" in entry) { - return
; + return ( +
+ ); } const Icon = entry.icon; diff --git a/src/components/canvas-overlay.tsx b/src/components/canvas-overlay.tsx index efef7d04..e2eccded 100644 --- a/src/components/canvas-overlay.tsx +++ b/src/components/canvas-overlay.tsx @@ -39,7 +39,11 @@ import { type PageLayout, } from "@/lib/page-layout"; import type { SnapContext } from "@/lib/snap-engine"; -import { useEditorStore } from "@/stores/editor-store"; +import { + getDisplayElements, + getDisplayGuides, + useEditorStore, +} from "@/stores/editor-store"; import { memo, useCallback, @@ -57,19 +61,43 @@ interface ElementOverlayListProps { visiblePages: Set; layouts: Map; selectedIds: Set; - dragLivePositions: Map | null; + dragLivePositions: Map< + string, + { x: number; y: number; width: number; height: number } + > | null; snapTargetIds: Set; zoom: number; dragOffset: { dx: number; dy: number } | null; dragDraggingIdRef: RefObject; dragSnapCorrection: { dx: number; dy: number } | null; resizeResizingIdRef: RefObject; - resizeSnapCorrection: { dx: number; dy: number; dw: number; dh: number } | null; + resizeSnapCorrection: { + dx: number; + dy: number; + dw: number; + dh: number; + } | null; multiResizeActiveRef: RefObject; onDragStart: (el: FormElement, e: React.MouseEvent) => void; - onDrag: (el: FormElement, screen: { x: number; y: number }, d: { x: number; y: number }, me: MouseEvent) => void; - onDragStop: (el: FormElement, screen: { x: number; y: number }, d: { x: number; y: number }, me: MouseEvent) => void; - onResize: (el: FormElement, dir: string, ref: HTMLElement, position: { x: number; y: number }, me: MouseEvent) => void; + onDrag: ( + el: FormElement, + screen: { x: number; y: number }, + d: { x: number; y: number }, + me: MouseEvent, + ) => void; + onDragStop: ( + el: FormElement, + screen: { x: number; y: number }, + d: { x: number; y: number }, + me: MouseEvent, + ) => void; + onResize: ( + el: FormElement, + dir: string, + ref: HTMLElement, + position: { x: number; y: number }, + me: MouseEvent, + ) => void; onResizeStop: (el: FormElement) => void; onResetResize: () => void; } @@ -103,7 +131,9 @@ const ElementOverlayList = memo(function ElementOverlayList({ if (!layout) return null; const isSelected = selectedIds.has(el.id); - const livePos = isSelected ? dragLivePositions?.get(el.id) ?? null : null; + const livePos = isSelected + ? (dragLivePositions?.get(el.id) ?? null) + : null; const isMultiResize = multiResizeActiveRef.current && !!livePos && @@ -124,9 +154,7 @@ const ElementOverlayList = memo(function ElementOverlayList({ isMultiResize={isMultiResize} isSnapTarget={snapTargetIds.has(el.id)} effectiveDragOffset={ - !isMultiResize && isSelected && !isDragging - ? dragOffset - : null + !isMultiResize && isSelected && !isDragging ? dragOffset : null } isDragging={isDragging} isResizing={isResizing} @@ -148,14 +176,14 @@ const ElementOverlayList = memo(function ElementOverlayList({ export function CanvasOverlay() { const { t } = useTranslation(); const scrollRef = useScrollContainerRef(); - const elements = useEditorStore((s) => s.elements); + const elements = useEditorStore(getDisplayElements); const activeTool = useEditorStore((s) => s.activeTool); const zoom = useEditorStore((s) => s.zoom); const pages = useEditorStore((s) => s.pages); const pdfBytes = useEditorStore((s) => s.pdfBytes); const selectedIds = useEditorStore((s) => s.selectedIds); const gridSize = useEditorStore((s) => s.gridSize); - const guides = useEditorStore((s) => s.guides); + const guides = useEditorStore(getDisplayGuides); const previewGuide = useEditorStore((s) => s.previewGuide); const visiblePages = useVisiblePages(); @@ -522,7 +550,12 @@ export function CanvasOverlay() { }; if (drawing.drawStartRef.current) { - drawing.updateDraw(currentX, currentY, modifiers, HORIZONTAL_DRAW_TOOLS.has(activeTool)); + drawing.updateDraw( + currentX, + currentY, + modifiers, + HORIZONTAL_DRAW_TOOLS.has(activeTool), + ); return; } @@ -582,9 +615,9 @@ export function CanvasOverlay() { const handleKeyDown = (e: KeyboardEvent) => { if ( e.target instanceof HTMLElement && - (e.target.closest( + e.target.closest( "input, textarea, select, [role='menu'], [role='menuitem']", - ) !== null) + ) !== null ) { return; } @@ -596,7 +629,7 @@ export function CanvasOverlay() { const state = useEditorStore.getState(); const page = getPageAtViewportCenter(scrollEl, state.pages, zoom); if (page === undefined) return; - const pageIds = state.elements + const pageIds = getDisplayElements(state) .filter((el) => el.pageNumber === page) .map((el) => el.id); selectElements(new Set(pageIds)); @@ -616,7 +649,7 @@ export function CanvasOverlay() { e.preventDefault(); const updates: Array<{ id: string; x: number; y: number }> = []; - for (const el of store.elements) { + for (const el of getDisplayElements(store)) { if (store.selectedIds.has(el.id)) { updates.push({ id: el.id, x: el.x + dx, y: el.y + dy }); } @@ -642,7 +675,9 @@ export function CanvasOverlay() { if (!store.selectedIds.has(elementId)) { selectElements(new Set([elementId])); } - const el = store.elements.find((el) => el.id === elementId); + const el = getDisplayElements(store).find( + (element) => element.id === elementId, + ); const rect = e.currentTarget.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; @@ -706,8 +741,7 @@ export function CanvasOverlay() { ); const totalContentHeight = useMemo( - () => - getTotalContentHeight(pages, zoom, scrollViewportHeight || undefined), + () => getTotalContentHeight(pages, zoom, scrollViewportHeight || undefined), [pages, zoom, scrollViewportHeight], ); @@ -726,7 +760,12 @@ export function CanvasOverlay() { if (multiResize.isActive.current && multiResize.currentBbox.current) { return multiResize.currentBbox.current; } - const items: Array<{ x: number; y: number; width: number; height: number }> = []; + const items: Array<{ + x: number; + y: number; + width: number; + height: number; + }> = []; for (const el of elements) { if (!selectedIds.has(el.id)) continue; const layout = layouts.get(el.pageNumber); @@ -736,11 +775,17 @@ export function CanvasOverlay() { const py = live?.y ?? el.y; const pw = live?.width ?? el.width; const ph = live?.height ?? el.height; - const tl = pdfToScreen({ x: px, y: py }, { zoom, pageX: layout.xOffset, pageY: layout.yOffset }); + const tl = pdfToScreen( + { x: px, y: py }, + { zoom, pageX: layout.xOffset, pageY: layout.yOffset }, + ); items.push({ x: tl.x, y: tl.y, width: pw * zoom, height: ph * zoom }); } if (items.length < 2) return null; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; for (const r of items) { if (r.x < minX) minX = r.x; if (r.y < minY) minY = r.y; @@ -748,7 +793,14 @@ export function CanvasOverlay() { if (r.y + r.height > maxY) maxY = r.y + r.height; } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; - }, [selectedIds, elements, layouts, zoom, dragLivePositions, multiResize.snapCorrection]); + }, [ + selectedIds, + elements, + layouts, + zoom, + dragLivePositions, + multiResize.snapCorrection, + ]); const anyHeightLocked = useMemo(() => { if (selectedIds.size < 2) return false; diff --git a/src/components/properties-panel.tsx b/src/components/properties-panel.tsx index 15a4ec59..d15823ea 100644 --- a/src/components/properties-panel.tsx +++ b/src/components/properties-panel.tsx @@ -35,7 +35,17 @@ import { type TextField, } from "@/lib/form-element-model"; import { resolveElementPosition } from "@/lib/page-coordinates"; -import { useEditorStore, type GuideLine } from "@/stores/editor-store"; +import { + createPropertyEditKey, + getDisplayElements, + getDisplayGuides, + getPropertyEditInputValue, + useEditorStore, + type ElementPropertyUpdate, + type EditorState, + type PropertyEditChanges, + type PropertyEditKey, +} from "@/stores/editor-store"; import { openUrl } from "@tauri-apps/plugin-opener"; import { AlignCenterHorizontal, @@ -77,99 +87,88 @@ const BASE_FONT_FAMILIES = [ { value: "ZapfDingbats", label: "Zapf Dingbats" }, ]; -function useDeferredValue( +function usePropertyInput( + identity: PropertyEditKey, storeValue: string | number, - onCommit: (raw: string) => void, + getChanges: (raw: string) => PropertyEditChanges, ) { - const [local, setLocal] = useState(String(storeValue ?? "")); - const activeRef = useRef(false); - const preEditRef = useRef<{ - elements: FormElement[]; - guides: GuideLine[]; - } | null>(null); - const originalValueRef = useRef(String(storeValue ?? "")); - const onCommitRef = useRef(onCommit); - onCommitRef.current = onCommit; + const value = useEditorStore((state) => + getPropertyEditInputValue(state, identity, storeValue), + ); + const beginPropertyEdit = useEditorStore((s) => s.beginPropertyEdit); + const previewPropertyEdit = useEditorStore((s) => s.previewPropertyEdit); + const commitPropertyEdit = useEditorStore((s) => s.commitPropertyEdit); + const discardPropertyEdit = useEditorStore((s) => s.discardPropertyEdit); - useEffect(() => { - if (!activeRef.current) { - setLocal(String(storeValue ?? "")); - } - }, [storeValue]); + const startEdit = useCallback(() => { + beginPropertyEdit(identity, String(storeValue ?? "")); + }, [beginPropertyEdit, identity, storeValue]); const onFocus = useCallback(() => { - if (activeRef.current) return; - activeRef.current = true; - originalValueRef.current = String(storeValue ?? ""); - preEditRef.current = { - elements: useEditorStore.getState().elements, - guides: useEditorStore.getState().guides, - }; - useEditorStore.temporal.getState().pause(); - }, [storeValue]); - - const onChange = useCallback((e: React.ChangeEvent) => { - setLocal(e.target.value); - onCommitRef.current(e.target.value); - }, []); - - const finishEdit = useCallback((revert: boolean) => { - if (!activeRef.current) return; - activeRef.current = false; - const preEdit = preEditRef.current; - preEditRef.current = null; - - if (revert && preEdit) { - useEditorStore.setState({ - elements: preEdit.elements, - guides: preEdit.guides, - }); - setLocal(originalValueRef.current); - } - - useEditorStore.temporal.getState().resume(); + startEdit(); + }, [startEdit]); + + const onChange = useCallback( + (e: React.ChangeEvent) => { + startEdit(); + const inputValue = e.target.value; + previewPropertyEdit(inputValue, getChanges(inputValue)); + }, + [getChanges, previewPropertyEdit, startEdit], + ); - if (!revert && preEdit) { - const current = { - elements: useEditorStore.getState().elements, - guides: useEditorStore.getState().guides, - }; - if ( - preEdit.elements !== current.elements || - preEdit.guides !== current.guides - ) { - const ts = useEditorStore.temporal.getState(); - const past = [...ts.pastStates, preEdit]; - if (past.length > 50) past.splice(0, past.length - 50); - useEditorStore.temporal.setState({ - pastStates: past, - futureStates: [], - }); - } - } - }, []); + useEffect(() => { + return () => commitPropertyEdit(identity); + }, [commitPropertyEdit, identity]); const onBlur = useCallback(() => { - finishEdit(false); - }, [finishEdit]); + commitPropertyEdit(identity); + }, [commitPropertyEdit, identity]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - finishEdit(false); + commitPropertyEdit(identity); (e.target as HTMLInputElement).select(); } if (e.key === "Escape") { e.preventDefault(); - finishEdit(true); + discardPropertyEdit(identity); (e.target as HTMLInputElement).blur(); } }, - [finishEdit], + [commitPropertyEdit, discardPropertyEdit, identity], + ); + + return { value, onFocus, onChange, onBlur, onKeyDown }; +} + +function useElementPropertyChanges(elementId: string) { + return useCallback( + (changes: Partial) => + ({ + elementUpdates: [{ id: elementId, changes }], + }) satisfies PropertyEditChanges, + [elementId], ); +} - return { value: local, onFocus, onChange, onBlur, onKeyDown }; +function useBatchPropertyChanges() { + return useCallback( + (elementUpdates: ElementPropertyUpdate[]) => + ({ elementUpdates }) satisfies PropertyEditChanges, + [], + ); +} + +function useDisplayElement(elementId: string) { + const selectElement = useCallback( + (state: EditorState) => + getDisplayElements(state).find((element) => element.id === elementId), + [elementId], + ); + return useEditorStore(selectElement); } function PropertyField({ @@ -530,19 +529,25 @@ function AppearanceSection({ function TypographySection({ element, onUpdate, + onPreview, }: { element: ElementWithTypography; onUpdate: (updates: Partial) => void; + onPreview: (updates: Partial) => PropertyEditChanges; }) { const { t } = useTranslation(); - const fontSizeField = useDeferredValue(element.fontSize, (v) => { - const fs = Number(v); - const updates: Partial = { fontSize: fs }; - if (isTextField(element) && !element.multiline) { - (updates as Partial).height = heightFromFontSize(fs); - } - onUpdate(updates); - }); + const fontSizeField = usePropertyInput( + createPropertyEditKey("fontSize", [element.id]), + element.fontSize, + (v) => { + const fs = Number(v); + const updates: Partial = { fontSize: fs }; + if (isTextField(element) && !element.multiline) { + (updates as Partial).height = heightFromFontSize(fs); + } + return onPreview(updates); + }, + ); return ( <> - s.elements.find((el) => el.id === elementId), - ); + const element = useDisplayElement(elementId); const updateElement = useEditorStore((s) => s.updateElement); + const getElementChanges = useElementPropertyChanges(elementId); if (!element || !isTextField(element)) return null; - const nameField = useDeferredValue(element.name, (v) => - updateElement(element.id, { name: v }), + const nameField = usePropertyInput( + createPropertyEditKey("name", [element.id]), + element.name, + (v) => getElementChanges({ name: v }), ); - const defaultValueField = useDeferredValue(element.defaultValue, (v) => - updateElement(element.id, { defaultValue: v }), + const defaultValueField = usePropertyInput( + createPropertyEditKey("defaultValue", [element.id]), + element.defaultValue, + (v) => getElementChanges({ defaultValue: v }), ); - const maxLengthField = useDeferredValue(element.maxLength ?? "", (v) => - updateElement(element.id, { - maxLength: v ? Number(v) : undefined, - }), + const maxLengthField = usePropertyInput( + createPropertyEditKey("maxLength", [element.id]), + element.maxLength ?? "", + (v) => getElementChanges({ maxLength: v ? Number(v) : undefined }), ); return ( @@ -629,6 +637,7 @@ function TextFieldProperties({ elementId }: { elementId: string }) { updateElement(element.id, updates)} + onPreview={getElementChanges} /> {!element.multiline && (
@@ -656,15 +665,16 @@ function TextFieldProperties({ elementId }: { elementId: string }) { function CheckboxProperties({ elementId }: { elementId: string }) { const { t } = useTranslation(); const defaultCheckedId = useId(); - const element = useEditorStore((s) => - s.elements.find((el) => el.id === elementId), - ); + const element = useDisplayElement(elementId); const updateElement = useEditorStore((s) => s.updateElement); + const getElementChanges = useElementPropertyChanges(elementId); if (!element || !isCheckbox(element)) return null; - const nameField = useDeferredValue(element.name, (v) => - updateElement(element.id, { name: v }), + const nameField = usePropertyInput( + createPropertyEditKey("name", [element.id]), + element.name, + (v) => getElementChanges({ name: v }), ); return ( @@ -700,21 +710,25 @@ function CheckboxProperties({ elementId }: { elementId: string }) { function RadioButtonProperties({ elementId }: { elementId: string }) { const { t } = useTranslation(); - const element = useEditorStore((s) => - s.elements.find((el) => el.id === elementId), - ); - const updateElement = useEditorStore((s) => s.updateElement); + const element = useDisplayElement(elementId); + const getElementChanges = useElementPropertyChanges(elementId); if (!element || !isRadioButton(element)) return null; - const groupNameField = useDeferredValue(element.groupName, (v) => - updateElement(element.id, { groupName: v }), + const groupNameField = usePropertyInput( + createPropertyEditKey("groupName", [element.id]), + element.groupName, + (v) => getElementChanges({ groupName: v }), ); - const valueField = useDeferredValue(element.value, (v) => - updateElement(element.id, { value: v }), + const valueField = usePropertyInput( + createPropertyEditKey("value", [element.id]), + element.value, + (v) => getElementChanges({ value: v }), ); - const labelField = useDeferredValue(element.label, (v) => - updateElement(element.id, { label: v }), + const labelField = usePropertyInput( + createPropertyEditKey("label", [element.id]), + element.label, + (v) => getElementChanges({ label: v }), ); return ( @@ -900,15 +914,16 @@ function DropdownProperties({ elementId }: { elementId: string }) { const { t } = useTranslation(); const requiredId = useId(); const editableId = useId(); - const element = useEditorStore((s) => - s.elements.find((el) => el.id === elementId), - ); + const element = useDisplayElement(elementId); const updateElement = useEditorStore((s) => s.updateElement); + const getElementChanges = useElementPropertyChanges(elementId); if (!element || !isDropdownField(element)) return null; - const nameField = useDeferredValue(element.name, (v) => - updateElement(element.id, { name: v }), + const nameField = usePropertyInput( + createPropertyEditKey("name", [element.id]), + element.name, + (v) => getElementChanges({ name: v }), ); const addOption = () => { @@ -968,6 +983,7 @@ function DropdownProperties({ elementId }: { elementId: string }) { updateElement(element.id, updates)} + onPreview={getElementChanges} />