From 61189b9c831c8cd8fe693abf2152ba8a8da03ba7 Mon Sep 17 00:00:00 2001 From: u8array Date: Tue, 12 May 2026 21:56:05 +0200 Subject: [PATCH 1/3] Add object locking and ZPL export exclusion Implement object locking to prevent accidental edits and allow users to exclude objects from ZPL output. - Locked objects are no longer draggable, resizable, or deletable. - The "Include in ZPL output" checkbox in the Properties panel controls whether an object is exported. - Added corresponding translations for the new features. - Updated tests to cover locking behavior and ZPL exclusion. --- src/components/Canvas/BarcodeObject.tsx | 8 +- src/components/Canvas/ImageObject.tsx | 4 +- src/components/Canvas/KonvaObject.tsx | 12 +-- src/components/Canvas/LabelCanvas.tsx | 50 +++++++++-- src/components/Canvas/LineObject.tsx | 8 +- src/components/Canvas/hooks/useCanvasLasso.ts | 6 +- .../Canvas/hooks/useKonvaTransformer.ts | 17 ++-- src/components/Properties/LayersPanel.tsx | 87 +++++++++++++++++-- src/components/Properties/PropertiesPanel.tsx | 38 ++++++++ src/hooks/useGlobalShortcuts.ts | 14 ++- src/lib/zplGenerator.test.ts | 14 +++ src/lib/zplGenerator.ts | 7 +- src/locales/ar.ts | 8 ++ src/locales/bg.ts | 8 ++ src/locales/cs.ts | 8 ++ src/locales/da.ts | 8 ++ src/locales/de.ts | 8 ++ src/locales/el.ts | 8 ++ src/locales/en.ts | 8 ++ src/locales/es.ts | 8 ++ src/locales/et.ts | 8 ++ src/locales/fa.ts | 8 ++ src/locales/fi.ts | 8 ++ src/locales/fr.ts | 8 ++ src/locales/he.ts | 8 ++ src/locales/hr.ts | 8 ++ src/locales/hu.ts | 8 ++ src/locales/it.ts | 8 ++ src/locales/ja.ts | 8 ++ src/locales/ko.ts | 8 ++ src/locales/lt.ts | 8 ++ src/locales/lv.ts | 8 ++ src/locales/nl.ts | 8 ++ src/locales/no.ts | 8 ++ src/locales/pl.ts | 8 ++ src/locales/pt.ts | 8 ++ src/locales/ro.ts | 8 ++ src/locales/sk.ts | 8 ++ src/locales/sl.ts | 8 ++ src/locales/sr.ts | 8 ++ src/locales/sv.ts | 8 ++ src/locales/tr.ts | 8 ++ src/locales/zh-hans.ts | 8 ++ src/locales/zh-hant.ts | 8 ++ src/store/labelStore.test.ts | 63 ++++++++++++++ src/store/labelStore.ts | 40 +++++++-- src/types/ObjectType.ts | 12 +++ 47 files changed, 589 insertions(+), 47 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index ebde5b77..50918c7f 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -427,7 +427,7 @@ export function BarcodeObject({ clipY={0} clipWidth={Math.max(w, 1) + clipLeft + clipRight} clipHeight={Math.max(h, 1) + textFontSize + textGap} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y())) @@ -517,7 +517,7 @@ export function BarcodeObject({ id={obj.id} x={x} y={y} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y())) @@ -712,7 +712,7 @@ export function BarcodeObject({ id={obj.id} x={x} y={y} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -741,7 +741,7 @@ export function BarcodeObject({ id={obj.id} x={x} y={y} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index b430788e..3e1391a6 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -94,7 +94,7 @@ export function ImageObject({ height={h} stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 2 : 0} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -107,7 +107,7 @@ export function ImageObject({ id={obj.id} x={x} y={y} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 6b62d461..4b0c41a2 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -193,7 +193,7 @@ function KonvaObjectInner({ x={x} y={y} rotation={zplRotationDeg[p.rotation]} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -230,7 +230,7 @@ function KonvaObjectInner({ fill="#000000" stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 1 : 0} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -263,7 +263,7 @@ function KonvaObjectInner({ fill="#000000" stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 1 : 0} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -342,7 +342,7 @@ function KonvaObjectInner({ id={obj.id} x={x} y={y} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -410,7 +410,7 @@ function KonvaObjectInner({ } strokeScaleEnabled={false} fill={fill} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={(e) => { // Center-anchored: snap the top-left corner, then re-add radius @@ -457,7 +457,7 @@ function KonvaObjectInner({ } strokeScaleEnabled={false} fill={fill} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={(e) => { const snapped = snapPos(e.target.x() - r, e.target.y() - r); diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 8c88805a..c4212acb 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -182,7 +182,8 @@ export const LabelCanvas = forwardRef(function LabelCa updateObjects( ids.flatMap((sid) => { const obj = objs.find((o) => o.id === sid); - return obj ? [{ id: sid, changes: { x: obj.x + dx, y: obj.y + dy } }] : []; + if (!obj || obj.locked) return []; + return [{ id: sid, changes: { x: obj.x + dx, y: obj.y + dy } }]; }), ); }; @@ -535,6 +536,43 @@ export const LabelCanvas = forwardRef(function LabelCa if (e.target === e.target.getStage()) selectObjects([]); }; + /** + * Click-passthrough for locked objects (Figma idiom). The locked node + * still receives the click (`listening=true` so Alt+click cycle keeps + * working), but its onSelect routes here instead of selecting itself. + * + * Resolve what's under the same pointer: getAllIntersections returns the + * stack at that point in z-order, top first. Walk each hit up to the + * registered Konva id (matches the alt-click-cycle pattern in + * useAltClickCycle), drop locked ids, pick the first survivor. If + * nothing's left, treat as background click and clear the selection. + */ + const handleLockedClick = (add: boolean) => { + const stage = stageRef.current; + const cr = containerRef.current?.getBoundingClientRect(); + if (!stage || !cr) return; + const point = { + x: lastPointerRef.current.x - cr.left, + y: lastPointerRef.current.y - cr.top, + }; + const nonLocked = new Set( + getCurrentObjects().filter((o) => !o.locked).map((o) => o.id), + ); + for (const shape of stage.getAllIntersections(point)) { + let n: Konva.Node | null = shape; + while (n) { + const id = n.id(); + if (id && nonLocked.has(id)) { + if (add) toggleSelectObject(id); + else selectObject(id); + return; + } + n = n.getParent(); + } + } + if (!add) selectObjects([]); + }; + const handleMouseMove = (e: React.MouseEvent) => { onPanMouseMove(e); onLassoMouseMove(e); @@ -737,7 +775,7 @@ export const LabelCanvas = forwardRef(function LabelCa /> )} - {objects.map((obj) => ( + {objects.map((obj) => obj.visible === false ? null : ( (function LabelCa offsetX={objectsOffsetX} offsetY={labelOffsetY} isSelected={selectedIds.includes(obj.id)} - onSelect={(add) => - add ? toggleSelectObject(obj.id) : selectObject(obj.id) - } + onSelect={(add) => { + if (obj.locked) handleLockedClick(add); + else if (add) toggleSelectObject(obj.id); + else selectObject(obj.id); + }} onChange={(changes) => handleObjectChange(obj.id, changes)} snap={snap} getOthersSnapshot={snapEnabled ? undefined : getOthersSnapshot} diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 6f81d4ec..297f3547 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -380,7 +380,7 @@ export function LineObject({ ]} stroke="transparent" strokeWidth={Math.max(lineStrokeWidth, 14)} - draggable + draggable={!obj.locked} {...selectionHandlers(onSelect)} onDragMove={(e) => { // Snap the absolute start-point position to the grid (not @@ -418,7 +418,7 @@ export function LineObject({ width={HANDLE_HIT_SIZE} height={HANDLE_HIT_SIZE} fill="transparent" - draggable + draggable={!obj.locked} onDragMove={(e) => { const endDotX = pxToDots(x2 - offsetX, scale, dpmm); const endDotY = pxToDots(y2 - offsetY, scale, dpmm); @@ -483,7 +483,7 @@ export function LineObject({ width={HANDLE_HIT_SIZE} height={HANDLE_HIT_SIZE} fill="transparent" - draggable + draggable={!obj.locked} onDragMove={(e) => { const r = endpointDrag( e.target.x() + HANDLE_HIT_SIZE / 2, @@ -542,7 +542,7 @@ export function LineObject({ width={HANDLE_HIT_SIZE} height={HANDLE_HIT_SIZE} fill="transparent" - draggable + draggable={!obj.locked} onDragMove={(e) => { const cursorX = e.target.x() + HANDLE_HIT_SIZE / 2; const cursorY = e.target.y() + HANDLE_HIT_SIZE / 2; diff --git a/src/components/Canvas/hooks/useCanvasLasso.ts b/src/components/Canvas/hooks/useCanvasLasso.ts index d62a1549..ea25461e 100644 --- a/src/components/Canvas/hooks/useCanvasLasso.ts +++ b/src/components/Canvas/hooks/useCanvasLasso.ts @@ -58,7 +58,11 @@ export function useCanvasLasso({ containerRef, stageRef, spaceDown, selectObject lassoRectRef.current = null; setLasso(null); if (!rect || !stageRef.current) return; - const ids = getCurrentObjects().map((o) => o.id); + // Figma-style: locked objects opt out of lasso selection — they can't + // be moved or transformed, so grabbing them into a marquee selection + // would make the post-lasso drag feel dead. Direct click and the + // LayersPanel still target locked items, so bulk-unlock stays possible. + const ids = getCurrentObjects().flatMap((o) => o.locked ? [] : [o.id]); selectObjects(getIdsIntersectingRect(stageRef.current, ids, rect)); }; diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 2f565157..3b91aa12 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -156,15 +156,17 @@ export function useKonvaTransformer({ } if (selectedIds.length === 1) { const selectedObj = objects.find((o) => o.id === selectedIds[0]); - const useTransformer = selectedObj && selectedObj.type !== "line"; + const useTransformer = + selectedObj && selectedObj.type !== "line" && !selectedObj.locked; const node = useTransformer ? stageRef.current.findOne(`#${selectedIds[0]}`) : null; transformerRef.current.nodes(node ? [node] : []); } else { const nodes = selectedIds - .filter((id) => objects.find((o) => o.id === id)?.type !== "line") - .map((id) => stageRef.current?.findOne(`#${id}`)) + .map((id) => objects.find((o) => o.id === id)) + .filter((o): o is LabelObject => !!o && o.type !== "line" && !o.locked) + .map((o) => stageRef.current?.findOne(`#${o.id}`)) .filter((n): n is Konva.Node => n != null); transformerRef.current.nodes(nodes); } @@ -178,11 +180,12 @@ export function useKonvaTransformer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIds, selectedTypesKey, selectedSignature, stageRef, transformerRef]); - const resizeEnabled = selectedIds.length <= 1; - const singleType = + const singleSelected = selectedIds.length === 1 - ? objects.find((o) => o.id === selectedIds[0])?.type ?? "" - : ""; + ? objects.find((o) => o.id === selectedIds[0]) + : undefined; + const resizeEnabled = selectedIds.length <= 1 && !singleSelected?.locked; + const singleType = singleSelected?.type ?? ""; const isUniformScale = !!ObjectRegistry[singleType]?.uniformScale; const enabledAnchors: string[] | undefined = selectedIds.length > 1 diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 1f77b39f..d28262eb 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -8,6 +8,7 @@ import { } from '@dnd-kit/core'; import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { EyeIcon, EyeSlashIcon, LockClosedIcon, LockOpenIcon } from '@heroicons/react/16/solid'; import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; import type { LabelObject } from '../../registry'; @@ -20,11 +21,38 @@ interface RowProps { isOver: boolean; onSelect: () => void; onToggle: () => void; + onToggleLock: () => void; + onToggleVisible: () => void; + tLock: string; + tUnlock: string; + tShow: string; + tHide: string; } -function SortableLayerRow({ obj, isSelected, isOver, onSelect, onToggle }: RowProps) { +function SortableLayerRow({ + obj, + isSelected, + isOver, + onSelect, + onToggle, + onToggleLock, + onToggleVisible, + tLock, + tUnlock, + tShow, + tHide, +}: RowProps) { const def = ObjectRegistry[obj.type]; - const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id: obj.id }); + const isLocked = !!obj.locked; + const isHidden = obj.visible === false; + // Locked rows opt out of @dnd-kit's sortable listeners so the drag handle + // can't reorder them; the row stays clickable for selection / toggles. + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ + id: obj.id, + disabled: isLocked, + }); + + const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); return ( <> @@ -35,20 +63,23 @@ function SortableLayerRow({ obj, isSelected, isOver, onSelect, onToggle }: RowPr ref={setNodeRef} style={{ touchAction: 'none' }} {...attributes} - {...listeners} + {...(isLocked ? {} : listeners)} onClick={(e) => { if (e.shiftKey || e.ctrlKey || e.metaKey) onToggle(); else onSelect(); }} className={` flex items-center gap-2 px-2 py-1.5 - cursor-grab active:cursor-grabbing + ${isLocked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} border-b border-border group transition-colors hover:bg-surface-2 ${isSelected ? 'bg-surface-2 border-l-2 border-l-accent' : 'border-l-2 border-l-transparent'} ${isDragging ? 'opacity-40' : ''} + ${isHidden ? 'opacity-50' : ''} `} > - + {def?.icon} @@ -56,6 +87,26 @@ function SortableLayerRow({ obj, isSelected, isOver, onSelect, onToggle }: RowPr {def?.label ?? obj.type} {obj.id.slice(0, 8)} + + ); @@ -63,8 +114,26 @@ function SortableLayerRow({ obj, isSelected, isOver, onSelect, onToggle }: RowPr export function LayersPanel() { const t = useT(); - const { selectedIds, selectObject, toggleSelectObject, reorderObject } = useLabelStore(); + const { selectedIds, selectObject, toggleSelectObject, reorderObject, updateObjects } = useLabelStore(); const objects = useCurrentObjects(); + + /** Figma-style bulk pattern: a per-row toggle on a row that's part of the + * active selection broadcasts to every selected object; clicking a row + * outside the selection acts only on that row. The new value is derived + * from the *clicked* object so the user gets the visual flip they + * initiated even when the rest of the selection was in mixed states. */ + const toggleField = (clickedId: string, field: 'locked' | 'visible') => { + const clicked = objects.find((o) => o.id === clickedId); + if (!clicked) return; + const currentlyOn = field === 'locked' ? !!clicked.locked : clicked.visible !== false; + const targets = selectedIds.includes(clickedId) ? selectedIds : [clickedId]; + // `locked` flips false ↔ true; `visible` flips true ↔ false. Both are + // simply the inverse of the clicked row's current state — broadcast as + // the same value to every target so the bulk action is predictable + // (mixed-state selections all converge on the flipped value). + const nextValue = !currentlyOn; + updateObjects(targets.map((id) => ({ id, changes: { [field]: nextValue } }))); + }; const [overId, setOverId] = useState(null); const sensors = useSensors( @@ -113,6 +182,12 @@ export function LayersPanel() { isOver={overId === obj.id} onSelect={() => selectObject(obj.id)} onToggle={() => toggleSelectObject(obj.id)} + onToggleLock={() => toggleField(obj.id, 'locked')} + onToggleVisible={() => toggleField(obj.id, 'visible')} + tLock={t.layers.lock} + tUnlock={t.layers.unlock} + tShow={t.layers.show} + tHide={t.layers.hide} /> ))} diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index b6fbde2e..81f83d03 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -170,6 +170,44 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { } /> + + {/* Lock — paired with the LayersPanel lock icon; mirroring it here + so a user already in PropertiesPanel can flip lock state without + jumping panels. Lock itself is a meta-field bypass in the store, + so the checkbox stays interactive even when the object is locked. */} + + + {/* Include in ZPL output — paired with the LayersPanel eye toggle: + visible controls editor render, includeInExport controls ZPL + emission. Stored as undefined when on so default state stays + absent from persisted JSON. */} + ); diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index 0a2ce7d5..c6c0d80d 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -9,6 +9,7 @@ export function useGlobalShortcuts() { const selectObjects = useLabelStore((s) => s.selectObjects); const setCanvasSettings = useLabelStore((s) => s.setCanvasSettings); const setCurrentPage = useLabelStore((s) => s.setCurrentPage); + const updateObjects = useLabelStore((s) => s.updateObjects); const { undo, redo } = useHistory(); useEffect(() => { @@ -45,6 +46,17 @@ export function useGlobalShortcuts() { pasteObjects(); return; } + if (mod && e.code === "KeyL") { + // Ctrl+L locks the current selection, Ctrl+Shift+L unlocks. No-op + // for an empty selection so the browser's default address-bar + // focus binding stays intact when nothing is selected. + const ids = useLabelStore.getState().selectedIds; + if (ids.length === 0) return; + e.preventDefault(); + const locked = !e.shiftKey; + updateObjects(ids.map((id) => ({ id, changes: { locked } }))); + return; + } if (e.code === "KeyG") { e.preventDefault(); setCanvasSettings({ showGrid: !useLabelStore.getState().canvasSettings.showGrid }); @@ -71,5 +83,5 @@ export function useGlobalShortcuts() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [undo, redo, duplicateSelectedObjects, copySelectedObjects, pasteObjects, selectObjects, setCanvasSettings, setCurrentPage]); + }, [undo, redo, duplicateSelectedObjects, copySelectedObjects, pasteObjects, selectObjects, setCanvasSettings, setCurrentPage, updateObjects]); } diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 5f8c2fc3..7141d990 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -52,6 +52,20 @@ describe('generateZPL — structure', () => { const zpl = generateZPL({ ...BASE_LABEL, labelShift: 5 }, []); expect(zpl).toContain('^LS5'); }); + + it('omits objects with includeInExport=false', () => { + const objs = [ + { id: 'a', type: 'text', x: 10, y: 10, rotation: 0, props: { content: 'KEEP', fontHeight: 30, fontWidth: 0, rotation: 'N', reverse: false } }, + { id: 'b', type: 'text', x: 20, y: 20, rotation: 0, includeInExport: false, props: { content: 'DROP', fontHeight: 30, fontWidth: 0, rotation: 'N', reverse: false } }, + // Casting around the registry's discriminated union — the generator only + // reads obj.type / obj.includeInExport / obj.comment, the shape per type + // is exercised by registry tests. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + const zpl = generateZPL(BASE_LABEL, objs); + expect(zpl).toContain('KEEP'); + expect(zpl).not.toContain('DROP'); + }); }); describe('generateZPL — printer params', () => { diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index f489d1fd..88c5a7b6 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -41,10 +41,11 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string } if (label.labelShift) lines.push(`^LS${label.labelShift}`); - lines.push(...objects.map((obj) => { + lines.push(...objects.flatMap((obj) => { + if (obj.includeInExport === false) return []; const zpl = ObjectRegistry[obj.type]?.toZPL(obj) ?? ''; - if (!obj.comment) return zpl; - return `^FX${stripZplCommandChars(obj.comment)}\n${zpl}`; + if (!obj.comment) return [zpl]; + return [`^FX${stripZplCommandChars(obj.comment)}\n${zpl}`]; })); if (label.printQuantity && label.printQuantity > 1) { diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 36c3a73a..3b2fde85 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -49,6 +49,10 @@ const ar = { x: 'X', y: 'Y', comment: 'تعليق', + includeInExport: 'ضمّن في إخراج ZPL', + includeInExportHint: 'ألغِ التحديد للاحتفاظ بهذا العنصر في التصميم مع استبعاده من ZPL المُولَّد.', + lock: 'قفل', + lockHint: 'يمنع التحريك وتغيير الحجم والحذف. التحديد يتم من قائمة الطبقات أو بـ Alt+النقر على اللوحة.', multipleSelectedFmt: '{n} عناصر مختارة', multipleSelectedHint: 'استخدم أسهم الاتجاه للتحريك', visualApproxHint: 'العرض المرئي تقريبي؛ الأبعاد تطابق طباعة ZPL', @@ -399,6 +403,10 @@ const ar = { forward: 'تقديم للأمام', backward: 'إرسال للخلف', toBack: 'إرسال للخلف', + lock: 'قفل', + unlock: 'إلغاء القفل', + show: 'إظهار', + hide: 'إخفاء', }, fonts: { heading: 'الخطوط', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 11f1bc89..0e0e8530 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -49,6 +49,10 @@ const bg = { x: 'X', y: 'Y', comment: 'Коментар', + includeInExport: 'Включи в изхода ZPL', + includeInExportHint: 'Премахнете отметката, за да запазите обекта в дизайна, но да го изключите от генерирания ZPL.', + lock: 'Заключване', + lockHint: 'Предотвратява преместване, преоразмеряване и изтриване. Изборът става от панела със слоеве или с Alt+клик върху платното.', multipleSelectedFmt: 'Избрани обекти: {n}', multipleSelectedHint: 'със стрелките местиш', visualApproxHint: 'Визуалното изобразяване е приблизително; размерите съответстват на ZPL отпечатъка', @@ -399,6 +403,10 @@ const bg = { forward: 'Премести напред', backward: 'Премести назад', toBack: 'Премести най-назад', + lock: 'Заключване', + unlock: 'Отключване', + show: 'Покажи', + hide: 'Скрий', }, fonts: { heading: 'Шрифтове', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index efdee411..34e769a0 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -49,6 +49,10 @@ const cs = { x: 'X', y: 'Y', comment: 'Komentář', + includeInExport: 'Zahrnout do výstupu ZPL', + includeInExportHint: 'Zrušte zaškrtnutí, chcete-li objekt ponechat v návrhu, ale vynechat ze generovaného ZPL.', + lock: 'Uzamknout', + lockHint: 'Brání přesouvání, změně velikosti a smazání. Výběr přes panel vrstev nebo Alt+klikem na plátně.', multipleSelectedFmt: 'Vybráno objektů: {n}', multipleSelectedHint: 'šipkami posunete', visualApproxHint: 'Vizuální zobrazení je přibližné; rozměry odpovídají tisku ZPL', @@ -399,6 +403,10 @@ const cs = { forward: 'Posunout dopředu', backward: 'Posunout dozadu', toBack: 'Odeslat do pozadí', + lock: 'Uzamknout', + unlock: 'Odemknout', + show: 'Zobrazit', + hide: 'Skrýt', }, fonts: { heading: 'Písma', diff --git a/src/locales/da.ts b/src/locales/da.ts index e37b138c..e8ad4dbb 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -49,6 +49,10 @@ const da = { x: 'X', y: 'Y', comment: 'Kommentar', + includeInExport: 'Inkludér i ZPL-output', + includeInExportHint: 'Fjern markeringen for at beholde objektet i designet, men udelade det fra det genererede ZPL.', + lock: 'Lås', + lockHint: 'Forhindrer flytning, ændring af størrelse og sletning. Markering via Lag-panelet eller Alt+klik på lærredet.', multipleSelectedFmt: '{n} objekter valgt', multipleSelectedHint: 'piletaster flytter', visualApproxHint: 'Visuel gengivelse er omtrentlig; dimensionerne svarer til ZPL-udskriften', @@ -399,6 +403,10 @@ const da = { forward: 'Flyt et lag frem', backward: 'Flyt et lag tilbage', toBack: 'Send til baggrunden', + lock: 'Lås', + unlock: 'Lås op', + show: 'Vis', + hide: 'Skjul', }, fonts: { heading: 'Skrifttyper', diff --git a/src/locales/de.ts b/src/locales/de.ts index b0f4232f..af231ce2 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -49,6 +49,10 @@ const de = { x: 'X', y: 'Y', comment: 'Kommentar', + includeInExport: 'In ZPL-Ausgabe einbeziehen', + includeInExportHint: 'Häkchen entfernen, um das Objekt im Design zu behalten, aber nicht in die ZPL-Ausgabe zu schreiben.', + lock: 'Sperren', + lockHint: 'Blockiert Verschieben, Größenänderung und Löschen. Auswahl per Ebenen-Panel oder Alt+Klick im Canvas.', multipleSelectedFmt: '{n} Objekte ausgewählt', multipleSelectedHint: 'Pfeiltasten zum Verschieben', visualApproxHint: 'Visuelle Darstellung näherungsweise; Maße entsprechen dem ZPL-Druck', @@ -420,6 +424,10 @@ const de = { forward: 'Eine Ebene nach vorne', backward: 'Eine Ebene nach hinten', toBack: 'In den Hintergrund', + lock: 'Sperren', + unlock: 'Entsperren', + show: 'Einblenden', + hide: 'Ausblenden', }, fonts: { heading: 'Schriften', diff --git a/src/locales/el.ts b/src/locales/el.ts index 1e2c9260..9f768832 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -49,6 +49,10 @@ const el = { x: 'X', y: 'Y', comment: 'Σχόλιο', + includeInExport: 'Συμπερίληψη στην έξοδο ZPL', + includeInExportHint: 'Καταργήστε την επιλογή για να διατηρήσετε το αντικείμενο στο σχέδιο αλλά να το παραλείψετε από το παραγόμενο ZPL.', + lock: 'Κλείδωμα', + lockHint: 'Αποτρέπει μετακίνηση, αλλαγή μεγέθους και διαγραφή. Επιλογή από το πλαίσιο επιπέδων ή με Alt+κλικ στον καμβά.', multipleSelectedFmt: '{n} αντικείμενα επιλέχθηκαν', multipleSelectedHint: 'τα βέλη μετακινούν', visualApproxHint: 'Η οπτική απόδοση είναι κατά προσέγγιση· οι διαστάσεις αντιστοιχούν στην εκτύπωση ZPL', @@ -399,6 +403,10 @@ const el = { forward: 'Ένα επίπεδο μπροστά', backward: 'Ένα επίπεδο πίσω', toBack: 'Αποστολή στο πίσω', + lock: 'Κλείδωμα', + unlock: 'Ξεκλείδωμα', + show: 'Εμφάνιση', + hide: 'Απόκρυψη', }, fonts: { heading: 'Γραμματοσειρές', diff --git a/src/locales/en.ts b/src/locales/en.ts index 81dccee2..e78ee52b 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -49,6 +49,10 @@ const en = { x: 'X', y: 'Y', comment: 'Comment', + includeInExport: 'Include in ZPL output', + includeInExportHint: 'Uncheck to keep this object in the design but omit it from the generated ZPL.', + lock: 'Lock', + lockHint: 'Prevents moving, resizing and deleting. Select via the Layers panel or Alt+click on the canvas.', multipleSelectedFmt: '{n} objects selected', multipleSelectedHint: 'use arrow keys to move', visualApproxHint: 'Visual rendering approximate; dimensions match the ZPL print', @@ -420,6 +424,10 @@ const en = { forward: 'Bring Forward', backward: 'Send Backward', toBack: 'Send to Back', + lock: 'Lock', + unlock: 'Unlock', + show: 'Show', + hide: 'Hide', }, fonts: { heading: 'Fonts', diff --git a/src/locales/es.ts b/src/locales/es.ts index d6cbf72a..00737690 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -49,6 +49,10 @@ const es = { x: 'X', y: 'Y', comment: 'Comentario', + includeInExport: 'Incluir en salida ZPL', + includeInExportHint: 'Desmarca para mantener el objeto en el diseño pero omitirlo del ZPL generado.', + lock: 'Bloquear', + lockHint: 'Impide mover, redimensionar y eliminar. Selecciona desde el panel Capas o con Alt+clic en el lienzo.', multipleSelectedFmt: '{n} objetos seleccionados', multipleSelectedHint: 'flechas para mover', visualApproxHint: 'Renderizado visual aproximado; las dimensiones coinciden con la impresión ZPL', @@ -399,6 +403,10 @@ const es = { forward: 'Avanzar una capa', backward: 'Retroceder una capa', toBack: 'Enviar al fondo', + lock: 'Bloquear', + unlock: 'Desbloquear', + show: 'Mostrar', + hide: 'Ocultar', }, fonts: { heading: 'Fuentes', diff --git a/src/locales/et.ts b/src/locales/et.ts index d75d635a..96c3a672 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -49,6 +49,10 @@ const et = { x: 'X', y: 'Y', comment: 'Kommentaar', + includeInExport: 'Kaasa ZPL-väljundis', + includeInExportHint: 'Märke eemaldamine jätab objekti kujundusse, kuid välja jätab loodud ZPL-ist.', + lock: 'Lukusta', + lockHint: 'Takistab liigutamist, suuruse muutmist ja kustutamist. Vali kihtide paanilt või Alt+klõpsuga lõuendil.', multipleSelectedFmt: '{n} objekti valitud', multipleSelectedHint: 'nooltega liigutad', visualApproxHint: 'Visuaalne kuva on ligikaudne; mõõtmed vastavad ZPL-väljatrükile', @@ -399,6 +403,10 @@ const et = { forward: 'Liiguta üks kiht ette', backward: 'Liiguta üks kiht taha', toBack: 'Saada taha', + lock: 'Lukusta', + unlock: 'Eemalda lukk', + show: 'Näita', + hide: 'Peida', }, fonts: { heading: 'Fondid', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index e7e8b467..e6b243ff 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -49,6 +49,10 @@ const fa = { x: 'X', y: 'Y', comment: 'توضیح', + includeInExport: 'گنجاندن در خروجی ZPL', + includeInExportHint: 'تیک را بردارید تا این شیء در طراحی بماند ولی در ZPL تولیدشده گنجانده نشود.', + lock: 'قفل', + lockHint: 'از جابه‌جایی، تغییر اندازه و حذف جلوگیری می‌کند. انتخاب از پنل لایه‌ها یا با Alt+کلیک روی بوم.', multipleSelectedFmt: '{n} مورد انتخاب شده', multipleSelectedHint: 'با کلیدهای جهت‌دار جابه‌جا کنید', visualApproxHint: 'نمایش بصری تقریبی است؛ ابعاد با چاپ ZPL مطابقت دارد', @@ -399,6 +403,10 @@ const fa = { forward: 'حرکت به جلو', backward: 'حرکت به عقب', toBack: 'ارسال به عقب', + lock: 'قفل', + unlock: 'بازکردن قفل', + show: 'نمایش', + hide: 'پنهان', }, fonts: { heading: 'فونت‌ها', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index ca0e54ba..fb4a15d8 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -49,6 +49,10 @@ const fi = { x: 'X', y: 'Y', comment: 'Kommentti', + includeInExport: 'Sisällytä ZPL-tulosteeseen', + includeInExportHint: 'Poista valinta säilyttääksesi objektin suunnitelmassa mutta jättääksesi sen pois luodusta ZPL:stä.', + lock: 'Lukitse', + lockHint: 'Estää siirtämisen, koon muuttamisen ja poistamisen. Valitse Tasot-paneelista tai Alt+napsautuksella kankaalla.', multipleSelectedFmt: '{n} objektia valittu', multipleSelectedHint: 'nuolinäppäimillä siirrät', visualApproxHint: 'Visuaalinen esitys on likimääräinen; mitat vastaavat ZPL-tulostetta', @@ -399,6 +403,10 @@ const fi = { forward: 'Siirrä taso eteenpäin', backward: 'Siirrä taso taaksepäin', toBack: 'Lähetä taustalle', + lock: 'Lukitse', + unlock: 'Avaa lukitus', + show: 'Näytä', + hide: 'Piilota', }, fonts: { heading: 'Fontit', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 7d2425d1..0e8f771e 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -49,6 +49,10 @@ const fr = { x: 'X', y: 'Y', comment: 'Commentaire', + includeInExport: 'Inclure dans la sortie ZPL', + includeInExportHint: 'Décochez pour conserver cet objet dans la conception mais l\'omettre du ZPL généré.', + lock: 'Verrouiller', + lockHint: 'Empêche le déplacement, le redimensionnement et la suppression. Sélection via le panneau Calques ou Alt+clic sur le canevas.', multipleSelectedFmt: '{n} objets sélectionnés', multipleSelectedHint: 'flèches pour déplacer', visualApproxHint: 'Rendu visuel approximatif ; les dimensions correspondent à l\'impression ZPL', @@ -399,6 +403,10 @@ const fr = { forward: "Avancer d'un calque", backward: "Reculer d'un calque", toBack: "Envoyer à l'arrière-plan", + lock: 'Verrouiller', + unlock: 'Déverrouiller', + show: 'Afficher', + hide: 'Masquer', }, fonts: { heading: 'Polices', diff --git a/src/locales/he.ts b/src/locales/he.ts index 2c72632b..aaa0a187 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -49,6 +49,10 @@ const he = { x: 'X', y: 'Y', comment: 'הערה', + includeInExport: 'כלול בפלט ZPL', + includeInExportHint: 'בטל סימון כדי להשאיר את האובייקט בעיצוב אך להשמיט אותו מ‑ZPL שנוצר.', + lock: 'נעל', + lockHint: 'מונע הזזה, שינוי גודל ומחיקה. בחירה דרך לוח השכבות או באמצעות Alt+לחיצה על הקנבס.', multipleSelectedFmt: '{n} פריטים נבחרו', multipleSelectedHint: 'מקשי החצים מזיזים', visualApproxHint: 'התצוגה החזותית מקורבת; הממדים תואמים את הדפסת ה-ZPL', @@ -399,6 +403,10 @@ const he = { forward: 'הבא קדימה', backward: 'שלח אחורה', toBack: 'שלח לאחור', + lock: 'נעל', + unlock: 'בטל נעילה', + show: 'הצג', + hide: 'הסתר', }, fonts: { heading: 'גופנים', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 5f01399b..f5d110e7 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -49,6 +49,10 @@ const hr = { x: 'X', y: 'Y', comment: 'Komentar', + includeInExport: 'Uključi u ZPL izlaz', + includeInExportHint: 'Odznačite kako bi objekt ostao u dizajnu, ali bio izostavljen iz generiranog ZPL-a.', + lock: 'Zaključaj', + lockHint: 'Sprječava pomicanje, promjenu veličine i brisanje. Odabir s ploče Slojevi ili Alt+klikom na platnu.', multipleSelectedFmt: 'Odabrano objekata: {n}', multipleSelectedHint: 'strelicama pomičeš', visualApproxHint: 'Vizualni prikaz je približan; dimenzije odgovaraju ZPL ispisu', @@ -399,6 +403,10 @@ const hr = { forward: 'Jedan sloj naprijed', backward: 'Jedan sloj nazad', toBack: 'Pomakni nazad', + lock: 'Zaključaj', + unlock: 'Otključaj', + show: 'Prikaži', + hide: 'Sakrij', }, fonts: { heading: 'Fontovi', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index c6e41d95..401a4a12 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -49,6 +49,10 @@ const hu = { x: 'X', y: 'Y', comment: 'Megjegyzés', + includeInExport: 'Beillesztés a ZPL kimenetbe', + includeInExportHint: 'Vegye ki a pipát, hogy az objektum a tervben maradjon, de kimaradjon a generált ZPL-ből.', + lock: 'Zárolás', + lockHint: 'Megakadályozza a mozgatást, átméretezést és törlést. Kijelölés a Rétegek panelről vagy Alt+kattintással a vásznon.', multipleSelectedFmt: '{n} objektum kijelölve', multipleSelectedHint: 'nyilakkal mozgasd', visualApproxHint: 'A vizuális megjelenítés közelítő; a méretek megegyeznek a ZPL nyomtatással', @@ -399,6 +403,10 @@ const hu = { forward: 'Egy réteggel előre', backward: 'Egy réteggel hátra', toBack: 'Hátra küldés', + lock: 'Zárolás', + unlock: 'Feloldás', + show: 'Megjelenítés', + hide: 'Elrejtés', }, fonts: { heading: 'Betűtípusok', diff --git a/src/locales/it.ts b/src/locales/it.ts index 88cc1b92..3e214943 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -49,6 +49,10 @@ const it = { x: 'X', y: 'Y', comment: 'Commento', + includeInExport: 'Includi nell\'output ZPL', + includeInExportHint: 'Deseleziona per mantenere l\'oggetto nel design ma escluderlo dallo ZPL generato.', + lock: 'Blocca', + lockHint: 'Impedisce spostamento, ridimensionamento e cancellazione. Seleziona dal pannello Livelli o con Alt+clic sulla tela.', multipleSelectedFmt: '{n} oggetti selezionati', multipleSelectedHint: 'frecce per spostare', visualApproxHint: 'Rendering visivo approssimato; le dimensioni corrispondono alla stampa ZPL', @@ -399,6 +403,10 @@ const it = { forward: 'Sposta avanti', backward: 'Sposta indietro', toBack: 'Porta in secondo piano', + lock: 'Blocca', + unlock: 'Sblocca', + show: 'Mostra', + hide: 'Nascondi', }, fonts: { heading: 'Caratteri', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 9a3adc59..cf8bfcc7 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -49,6 +49,10 @@ const ja = { x: 'X', y: 'Y', comment: 'コメント', + includeInExport: 'ZPL出力に含める', + includeInExportHint: 'チェックを外すと、デザイン上に残しつつ、生成された ZPL からは除外されます。', + lock: 'ロック', + lockHint: '移動・サイズ変更・削除を防ぎます。レイヤーパネルから、またはキャンバス上で Alt+クリックして選択します。', multipleSelectedFmt: '{n} 個のオブジェクトが選択されました', multipleSelectedHint: '矢印キーで移動', visualApproxHint: '視覚的表示は概略です。寸法は ZPL 印刷と一致します', @@ -399,6 +403,10 @@ const ja = { forward: '前面へ', backward: '背面へ', toBack: '最背面へ', + lock: 'ロック', + unlock: 'ロック解除', + show: '表示', + hide: '非表示', }, fonts: { heading: 'フォント', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 9723835a..9fe55019 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -49,6 +49,10 @@ const ko = { x: 'X', y: 'Y', comment: '설명', + includeInExport: 'ZPL 출력에 포함', + includeInExportHint: '선택을 해제하면 디자인에는 남지만 생성된 ZPL에서는 제외됩니다.', + lock: '잠금', + lockHint: '이동, 크기 조정, 삭제를 차단합니다. 레이어 패널 또는 캔버스에서 Alt+클릭으로 선택하세요.', multipleSelectedFmt: '{n}개 항목 선택됨', multipleSelectedHint: '화살표 키로 이동', visualApproxHint: '시각적 표시는 근사치이며, 치수는 ZPL 인쇄와 일치합니다', @@ -399,6 +403,10 @@ const ko = { forward: '앞으로', backward: '뒤로', toBack: '맨 뒤로', + lock: '잠금', + unlock: '잠금 해제', + show: '표시', + hide: '숨기기', }, fonts: { heading: '글꼴', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index d7acb9ba..4e7ab18f 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -49,6 +49,10 @@ const lt = { x: 'X', y: 'Y', comment: 'Komentaras', + includeInExport: 'Įtraukti į ZPL išvestį', + includeInExportHint: 'Atžymėkite, jei norite palikti objektą maketo, bet praleisti jį generuojamame ZPL.', + lock: 'Užrakinti', + lockHint: 'Neleidžia perkelti, keisti dydžio ir ištrinti. Pasirinkite iš sluoksnių skydelio arba Alt+spustelėjimu drobėje.', multipleSelectedFmt: 'Pasirinkta objektų: {n}', multipleSelectedHint: 'rodyklėmis perkeli', visualApproxHint: 'Vaizdavimas apytikslis; matmenys atitinka ZPL spaudinį', @@ -399,6 +403,10 @@ const lt = { forward: 'Vienas sluoksnis į priekį', backward: 'Vienas sluoksnis atgal', toBack: 'Perkelti į galą', + lock: 'Užrakinti', + unlock: 'Atrakinti', + show: 'Rodyti', + hide: 'Slėpti', }, fonts: { heading: 'Šriftai', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 7157c0c9..0791f93d 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -49,6 +49,10 @@ const lv = { x: 'X', y: 'Y', comment: 'Komentārs', + includeInExport: 'Iekļaut ZPL izvadē', + includeInExportHint: 'Noņemiet atzīmi, lai objekts paliktu dizainā, taču netiktu iekļauts ģenerētajā ZPL.', + lock: 'Bloķēt', + lockHint: 'Neļauj pārvietot, mainīt izmēru un dzēst. Atlasiet no slāņu paneļa vai ar Alt+klikšķi audeklā.', multipleSelectedFmt: 'Atlasīti {n} objekti', multipleSelectedHint: 'ar bultiņām pārvietot', visualApproxHint: 'Vizuālais attēlojums ir aptuvens; izmēri atbilst ZPL izdrukai', @@ -399,6 +403,10 @@ const lv = { forward: 'Pārvietot vienu slāni uz priekšu', backward: 'Pārvietot vienu slāni atpakaļ', toBack: 'Pārvietot uz aizmuguri', + lock: 'Bloķēt', + unlock: 'Atbloķēt', + show: 'Rādīt', + hide: 'Slēpt', }, fonts: { heading: 'Fonti', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 92808090..4862eb0d 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -49,6 +49,10 @@ const nl = { x: 'X', y: 'Y', comment: 'Opmerking', + includeInExport: 'Opnemen in ZPL-uitvoer', + includeInExportHint: 'Vink uit om dit object in het ontwerp te behouden, maar uit de gegenereerde ZPL weg te laten.', + lock: 'Vergrendelen', + lockHint: 'Voorkomt verplaatsen, vergroten/verkleinen en verwijderen. Selecteer via het Lagen-paneel of Alt+klik op het canvas.', multipleSelectedFmt: '{n} objecten geselecteerd', multipleSelectedHint: 'pijltoetsen om te verplaatsen', visualApproxHint: 'Visuele weergave bij benadering; afmetingen komen overeen met de ZPL-afdruk', @@ -399,6 +403,10 @@ const nl = { forward: 'Één laag naar voren', backward: 'Één laag naar achteren', toBack: 'Naar de achtergrond', + lock: 'Vergrendelen', + unlock: 'Ontgrendelen', + show: 'Tonen', + hide: 'Verbergen', }, fonts: { heading: 'Lettertypen', diff --git a/src/locales/no.ts b/src/locales/no.ts index dcb93026..84698c4c 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -49,6 +49,10 @@ const no = { x: 'X', y: 'Y', comment: 'Kommentar', + includeInExport: 'Inkluder i ZPL-utdata', + includeInExportHint: 'Fjern haken for å beholde objektet i designet, men utelate det fra generert ZPL.', + lock: 'Lås', + lockHint: 'Hindrer flytting, størrelsesendring og sletting. Velg fra Lag-panelet eller med Alt+klikk på lerretet.', multipleSelectedFmt: '{n} objekter valgt', multipleSelectedHint: 'piltaster flytter', visualApproxHint: 'Visuell gjengivelse er omtrentlig; dimensjonene samsvarer med ZPL-utskriften', @@ -399,6 +403,10 @@ const no = { forward: 'Flytt ett lag fremover', backward: 'Flytt ett lag bakover', toBack: 'Flytt bakerst', + lock: 'Lås', + unlock: 'Lås opp', + show: 'Vis', + hide: 'Skjul', }, fonts: { heading: 'Skrifter', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 20beffc8..d6f4cd0d 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -49,6 +49,10 @@ const pl = { x: 'X', y: 'Y', comment: 'Komentarz', + includeInExport: 'Dołącz do wyjścia ZPL', + includeInExportHint: 'Odznacz, aby zachować obiekt w projekcie, ale pominąć go w wygenerowanym ZPL.', + lock: 'Zablokuj', + lockHint: 'Blokuje przesuwanie, zmianę rozmiaru i usuwanie. Wybór z panelu Warstwy lub Alt+kliknięciem na płótnie.', multipleSelectedFmt: 'Wybrano obiektów: {n}', multipleSelectedHint: 'strzałki przesuwają', visualApproxHint: 'Renderowanie wizualne jest przybliżone; wymiary odpowiadają wydrukowi ZPL', @@ -399,6 +403,10 @@ const pl = { forward: 'Przesuń do przodu', backward: 'Przesuń do tyłu', toBack: 'Przesuń na spód', + lock: 'Zablokuj', + unlock: 'Odblokuj', + show: 'Pokaż', + hide: 'Ukryj', }, fonts: { heading: 'Czcionki', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index fff28e87..db1019d4 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -49,6 +49,10 @@ const pt = { x: 'X', y: 'Y', comment: 'Comentário', + includeInExport: 'Incluir na saída ZPL', + includeInExportHint: 'Desmarque para manter o objeto no design mas omiti-lo do ZPL gerado.', + lock: 'Bloquear', + lockHint: 'Impede mover, redimensionar e excluir. Selecione no painel Camadas ou com Alt+clique no canvas.', multipleSelectedFmt: '{n} objetos selecionados', multipleSelectedHint: 'setas para mover', visualApproxHint: 'Renderização visual aproximada; as dimensões correspondem à impressão ZPL', @@ -399,6 +403,10 @@ const pt = { forward: 'Avançar uma camada', backward: 'Recuar uma camada', toBack: 'Enviar para o fundo', + lock: 'Bloquear', + unlock: 'Desbloquear', + show: 'Mostrar', + hide: 'Ocultar', }, fonts: { heading: 'Fontes', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index c7b7eb7c..75abf0ca 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -49,6 +49,10 @@ const ro = { x: 'X', y: 'Y', comment: 'Comentariu', + includeInExport: 'Include în ieșirea ZPL', + includeInExportHint: 'Debifează pentru a păstra obiectul în design, dar a-l omite din ZPL-ul generat.', + lock: 'Blochează', + lockHint: 'Împiedică mutarea, redimensionarea și ștergerea. Selectează din panoul Straturi sau cu Alt+clic pe pânză.', multipleSelectedFmt: '{n} obiecte selectate', multipleSelectedHint: 'săgeți pentru mutare', visualApproxHint: 'Randarea vizuală este aproximativă; dimensiunile corespund tipăririi ZPL', @@ -399,6 +403,10 @@ const ro = { forward: 'Avansare un strat', backward: 'Retragere un strat', toBack: 'Trimite în fundal', + lock: 'Blochează', + unlock: 'Deblochează', + show: 'Afișează', + hide: 'Ascunde', }, fonts: { heading: 'Fonturi', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index de06147e..f77f3981 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -49,6 +49,10 @@ const sk = { x: 'X', y: 'Y', comment: 'Komentár', + includeInExport: 'Zahrnúť do výstupu ZPL', + includeInExportHint: 'Zrušte označenie, ak chcete objekt ponechať v návrhu, ale vynechať z generovaného ZPL.', + lock: 'Uzamknúť', + lockHint: 'Bráni presúvaniu, zmene veľkosti a odstráneniu. Výber z panela Vrstvy alebo Alt+klikom na plátno.', multipleSelectedFmt: 'Vybraných objektov: {n}', multipleSelectedHint: 'šípkami posuniete', visualApproxHint: 'Vizuálne zobrazenie je približné; rozmery zodpovedajú tlači ZPL', @@ -399,6 +403,10 @@ const sk = { forward: 'Posunúť dopredu', backward: 'Posunúť dozadu', toBack: 'Odoslať do pozadia', + lock: 'Uzamknúť', + unlock: 'Odomknúť', + show: 'Zobraziť', + hide: 'Skryť', }, fonts: { heading: 'Písma', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 8eedf3ea..6b7871a0 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -49,6 +49,10 @@ const sl = { x: 'X', y: 'Y', comment: 'Komentar', + includeInExport: 'Vključi v izhod ZPL', + includeInExportHint: 'Odznačite, da objekt ostane v zasnovi, vendar bo izpuščen iz ustvarjenega ZPL-ja.', + lock: 'Zakleni', + lockHint: 'Onemogoča premikanje, spreminjanje velikosti in brisanje. Izberite s plošče Sloji ali z Alt+klikom na platnu.', multipleSelectedFmt: 'Izbranih objektov: {n}', multipleSelectedHint: 's puščicami premikaš', visualApproxHint: 'Vizualni prikaz je približen; mere se ujemajo s tiskom ZPL', @@ -399,6 +403,10 @@ const sl = { forward: 'Premakni eno plast naprej', backward: 'Premakni eno plast nazaj', toBack: 'Premakni v ozadje', + lock: 'Zakleni', + unlock: 'Odkleni', + show: 'Prikaži', + hide: 'Skrij', }, fonts: { heading: 'Pisave', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 4e10ad87..ab075789 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -49,6 +49,10 @@ const sr = { x: 'X', y: 'Y', comment: 'Коментар', + includeInExport: 'Укључи у ZPL излаз', + includeInExportHint: 'Поништите ознаку да објекат остане у дизајну, али буде изостављен из генерисаног ZPL-а.', + lock: 'Закључај', + lockHint: 'Спречава померање, промену величине и брисање. Избор преко панела Слојеви или Alt+кликом на платну.', multipleSelectedFmt: 'Изабрано објеката: {n}', multipleSelectedHint: 'стрелицама померај', visualApproxHint: 'Визуелни приказ је приближан; димензије одговарају ZPL отиску', @@ -399,6 +403,10 @@ const sr = { forward: 'Jedan sloj napred', backward: 'Jedan sloj nazad', toBack: 'Pomeri nazad', + lock: 'Закључај', + unlock: 'Откључај', + show: 'Прикажи', + hide: 'Сакриј', }, fonts: { heading: 'Фонтови', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index d1fff207..61593b4c 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -49,6 +49,10 @@ const sv = { x: 'X', y: 'Y', comment: 'Kommentar', + includeInExport: 'Inkludera i ZPL-utdata', + includeInExportHint: 'Avmarkera för att behålla objektet i designen men utelämna det från genererad ZPL.', + lock: 'Lås', + lockHint: 'Hindrar flytt, storleksändring och borttagning. Markera via Lager-panelen eller Alt+klick på arbetsytan.', multipleSelectedFmt: '{n} objekt markerade', multipleSelectedHint: 'pilar för att flytta', visualApproxHint: 'Visuell återgivning är ungefärlig; måtten matchar ZPL-utskriften', @@ -399,6 +403,10 @@ const sv = { forward: 'Flytta ett lager framåt', backward: 'Flytta ett lager bakåt', toBack: 'Flytta längst bak', + lock: 'Lås', + unlock: 'Lås upp', + show: 'Visa', + hide: 'Dölj', }, fonts: { heading: 'Typsnitt', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index a321ef7f..264727b4 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -49,6 +49,10 @@ const tr = { x: 'X', y: 'Y', comment: 'Yorum', + includeInExport: 'ZPL çıktısına dahil et', + includeInExportHint: 'Nesneyi tasarımda tutmak ama oluşturulan ZPL\'ye dahil etmemek için işareti kaldırın.', + lock: 'Kilitle', + lockHint: 'Taşıma, yeniden boyutlandırma ve silmeyi engeller. Katmanlar panelinden veya tuvalde Alt+tıklama ile seçin.', multipleSelectedFmt: '{n} nesne seçildi', multipleSelectedHint: 'oklarla taşı', visualApproxHint: 'Görsel render yaklaşıktır; boyutlar ZPL çıktısıyla eşleşir', @@ -399,6 +403,10 @@ const tr = { forward: 'İleriye Taşı', backward: 'Geriye Taşı', toBack: 'Arkaya Gönder', + lock: 'Kilitle', + unlock: 'Kilidi aç', + show: 'Göster', + hide: 'Gizle', }, fonts: { heading: 'Yazı Tipleri', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index b9a5c05a..78be7b3a 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -49,6 +49,10 @@ const zhHans = { x: 'X', y: 'Y', comment: '备注', + includeInExport: '包含在 ZPL 输出中', + includeInExportHint: '取消勾选可将该对象保留在设计中,但不输出到生成的 ZPL。', + lock: '锁定', + lockHint: '禁止移动、缩放与删除。可在“图层”面板中选择,或在画布上 Alt+点击。', multipleSelectedFmt: '已选择 {n} 个对象', multipleSelectedHint: '方向键移动', visualApproxHint: '视觉渲染为近似值;尺寸与 ZPL 打印输出一致', @@ -399,6 +403,10 @@ const zhHans = { forward: '上移一层', backward: '下移一层', toBack: '置于底层', + lock: '锁定', + unlock: '解锁', + show: '显示', + hide: '隐藏', }, fonts: { heading: '字体', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index ae17bc03..b9a15d07 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -49,6 +49,10 @@ const zhHant = { x: 'X', y: 'Y', comment: '備註', + includeInExport: '納入 ZPL 輸出', + includeInExportHint: '取消勾選可保留設計中的物件,但不寫入產生的 ZPL。', + lock: '鎖定', + lockHint: '禁止移動、調整大小與刪除。可從「圖層」面板選取,或在畫布上 Alt+點擊。', multipleSelectedFmt: '已選擇 {n} 個物件', multipleSelectedHint: '方向鍵移動', visualApproxHint: '視覺呈現為近似值;尺寸與 ZPL 列印輸出一致', @@ -399,6 +403,10 @@ const zhHant = { forward: '上移一層', backward: '下移一層', toBack: '移至最下層', + lock: '鎖定', + unlock: '解鎖', + show: '顯示', + hide: '隱藏', }, fonts: { heading: '字體', diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index 1caeee65..ed2f3c9b 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -107,6 +107,69 @@ describe('removeObject', () => { }); }); +// ── locking ─────────────────────────────────────────────────────────────────── + +describe('lock', () => { + it('blocks position changes on a locked object', () => { + state().addObject('text', { x: 100, y: 100 }); + const id = defined(objs()[0]).id; + state().updateObject(id, { locked: true }); + state().updateObject(id, { x: 999, y: 999 }); + expect(defined(objs()[0]).x).toBe(100); + expect(defined(objs()[0]).y).toBe(100); + }); + + it('blocks prop changes on a locked object', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + state().updateObject(id, { locked: true }); + state().updateObject(id, { props: { fontHeight: 99 } }); + expect(props(defined(objs()[0])).fontHeight).not.toBe(99); + }); + + it('allows the lock itself to be toggled off', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + state().updateObject(id, { locked: true }); + state().updateObject(id, { locked: false }); + state().updateObject(id, { x: 500 }); + expect(defined(objs()[0]).x).toBe(500); + }); + + it('allows visibility, includeInExport and comment edits while locked', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + state().updateObject(id, { locked: true }); + state().updateObject(id, { visible: false }); + state().updateObject(id, { includeInExport: false }); + state().updateObject(id, { comment: 'note' }); + const o = defined(objs()[0]); + expect(o.visible).toBe(false); + expect(o.includeInExport).toBe(false); + expect(o.comment).toBe('note'); + }); + + it('protects locked objects from removeObject', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + state().updateObject(id, { locked: true }); + state().removeObject(id); + expect(objs()).toHaveLength(1); + }); + + it('protects locked objects from removeSelectedObjects but removes unlocked siblings', () => { + state().addObject('text', { x: 0, y: 0 }); + state().addObject('text', { x: 10, y: 10 }); + const lockedId = defined(objs()[0]).id; + const otherId = defined(objs()[1]).id; + state().updateObject(lockedId, { locked: true }); + state().selectObjects([lockedId, otherId]); + state().removeSelectedObjects(); + expect(ids()).toEqual([lockedId]); + expect(state().selectedIds).toEqual([lockedId]); + }); +}); + // ── duplicateObject ─────────────────────────────────────────────────────────── describe('duplicateObject', () => { diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index b7b429c8..bb8d6e92 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -16,7 +16,18 @@ export interface Page { objects: LabelObject[]; } +/** Meta fields that remain editable on a locked object so the user can + * release the lock or annotate without unlocking first. Everything else + * (position, props, rotation, positionType) is blocked. */ +const LOCK_BYPASS_KEYS = new Set(['locked', 'visible', 'includeInExport', 'comment']); + +function isLockBypass(changes: ObjectChanges): boolean { + const keys = Object.keys(changes); + return keys.length > 0 && keys.every((k) => LOCK_BYPASS_KEYS.has(k)); +} + function applyObjectChanges(obj: LabelObject, changes: ObjectChanges): LabelObject { + if (obj.locked && !isLockBypass(changes)) return obj; const normalize = ObjectRegistry[obj.type]?.normalizeChanges; const normalized = normalize ? normalize(obj, changes) : changes; return { @@ -241,10 +252,14 @@ export const useLabelStore = create()( }), removeObject: (id) => - set((state) => ({ - ...updateCurrentObjects(state, (objs) => objs.filter((obj) => obj.id !== id)), - selectedIds: state.selectedIds.filter((s) => s !== id), - })), + set((state) => { + const obj = currentObjects(state).find((o) => o.id === id); + if (obj?.locked) return {}; + return { + ...updateCurrentObjects(state, (objs) => objs.filter((o) => o.id !== id)), + selectedIds: state.selectedIds.filter((s) => s !== id), + }; + }), duplicateObject: (id) => set((state) => { @@ -321,10 +336,19 @@ export const useLabelStore = create()( }), removeSelectedObjects: () => - set((state) => ({ - ...updateCurrentObjects(state, (objs) => objs.filter((o) => !state.selectedIds.includes(o.id))), - selectedIds: [], - })), + set((state) => { + const sel = new Set(state.selectedIds); + // Locked objects survive a Delete keystroke / bulk-remove; the + // multi-select clears down to whichever locked items remain. + const removable = (o: LabelObject) => sel.has(o.id) && !o.locked; + return { + ...updateCurrentObjects(state, (objs) => objs.filter((o) => !removable(o))), + selectedIds: state.selectedIds.filter((id) => { + const obj = currentObjects(state).find((o) => o.id === id); + return obj?.locked; + }), + }; + }), moveObjectToFront: (id) => set((state) => { diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index dc89108f..2aeb41a7 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -29,6 +29,18 @@ export const labelObjectBaseSchema = z.object({ positionType: z.enum(['FO', 'FT']).optional(), /** Emitted as ^FX before this field in ZPL output. Carries no print output. */ comment: z.string().optional(), + /** When true, blocks position/size/prop edits, drag, resize and deletion. + * Editor still allows selection and toggling of locked/visible/includeInExport + * themselves so the lock can be released. Persisted; not exported to ZPL. */ + locked: z.boolean().optional(), + /** When false, the object is omitted from the canvas render. Distinct from + * includeInExport so a designer can hide reference geometry while still + * shipping it. Defaults to true. */ + visible: z.boolean().optional(), + /** When false, the object is skipped during ZPL generation. Distinct from + * visible so a designer can preview placement without shipping. Defaults + * to true. */ + includeInExport: z.boolean().optional(), }); export type LabelObjectBase = z.infer; From 6a50fb771f30f27e08b8faba12fe48aed0bb8b5d Mon Sep 17 00:00:00 2001 From: u8array Date: Tue, 12 May 2026 21:56:14 +0200 Subject: [PATCH 2/3] Refactor object hit testing and bulk toggle logic Introduces `objectIdsAtPoint` to abstract Konva hit testing and a new `buildBulkToggleUpdates` helper to encapsulate the logic for toggling properties in the LayersPanel. This refactoring improves code clarity and reusability. --- src/components/Canvas/LabelCanvas.tsx | 34 +++---- src/components/Canvas/hitTesting.ts | 36 +++++++ .../Canvas/hooks/useAltClickCycle.ts | 27 +----- src/components/Properties/LayersPanel.tsx | 25 ++--- src/lib/bulkToggle.test.ts | 96 +++++++++++++++++++ src/lib/bulkToggle.ts | 43 +++++++++ src/store/labelStore.ts | 12 +-- 7 files changed, 207 insertions(+), 66 deletions(-) create mode 100644 src/components/Canvas/hitTesting.ts create mode 100644 src/lib/bulkToggle.test.ts create mode 100644 src/lib/bulkToggle.ts diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index c4212acb..06c32af0 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -27,6 +27,7 @@ import { Ruler, RULER_SIZE } from "./Ruler"; import { ObjectRegistry } from "../../registry"; import type { LabelObject } from "../../registry"; import { useColorScheme } from "../../lib/useColorScheme"; +import { objectIdsAtPoint } from "./hitTesting"; import { useT } from "../../lib/useT"; import { useCanvasPanZoom } from "./hooks/useCanvasPanZoom"; import { useCanvasLasso } from "./hooks/useCanvasLasso"; @@ -538,14 +539,15 @@ export const LabelCanvas = forwardRef(function LabelCa /** * Click-passthrough for locked objects (Figma idiom). The locked node - * still receives the click (`listening=true` so Alt+click cycle keeps - * working), but its onSelect routes here instead of selecting itself. + * still listens (so the Alt+click cycle keeps reaching it), but its + * onSelect routes here instead of selecting itself. The next non-locked + * hit at the same point wins; if nothing's left, treat as background. * - * Resolve what's under the same pointer: getAllIntersections returns the - * stack at that point in z-order, top first. Walk each hit up to the - * registered Konva id (matches the alt-click-cycle pattern in - * useAltClickCycle), drop locked ids, pick the first survivor. If - * nothing's left, treat as background click and clear the selection. + * Pointer source is `lastPointerRef` rather than the original click + * event because `onSelect` deliberately drops the event to keep the + * KonvaObjectProps surface narrow. The document-level pointermove + * listener updates the ref every frame, so by the time the click + * handler fires the ref is at the click position. */ const handleLockedClick = (add: boolean) => { const stage = stageRef.current; @@ -556,19 +558,13 @@ export const LabelCanvas = forwardRef(function LabelCa y: lastPointerRef.current.y - cr.top, }; const nonLocked = new Set( - getCurrentObjects().filter((o) => !o.locked).map((o) => o.id), + getCurrentObjects().flatMap((o) => o.locked ? [] : [o.id]), ); - for (const shape of stage.getAllIntersections(point)) { - let n: Konva.Node | null = shape; - while (n) { - const id = n.id(); - if (id && nonLocked.has(id)) { - if (add) toggleSelectObject(id); - else selectObject(id); - return; - } - n = n.getParent(); - } + const hit = objectIdsAtPoint(stage, point, nonLocked)[0]; + if (hit) { + if (add) toggleSelectObject(hit); + else selectObject(hit); + return; } if (!add) selectObjects([]); }; diff --git a/src/components/Canvas/hitTesting.ts b/src/components/Canvas/hitTesting.ts new file mode 100644 index 00000000..371222ab --- /dev/null +++ b/src/components/Canvas/hitTesting.ts @@ -0,0 +1,36 @@ +import type Konva from "konva"; + +/** + * Resolve the registered object ids at a stage-relative point, top first. + * + * `stage.getAllIntersections` returns Konva nodes in z-order (front first), + * which may be child shapes of a registered object Group rather than the + * Group itself — every `KonvaObject` puts `id={obj.id}` on the *outer* + * Group. So we walk each hit upward until we land on a candidate id, dedupe, + * and emit in the same z-order Konva produced. + * + * Used by: + * - Alt+click cycle: cycles through every overlapping object at a point. + * - Click-passthrough for locked objects: finds the next non-locked hit. + */ +export function objectIdsAtPoint( + stage: Konva.Stage, + point: { x: number; y: number }, + candidates: ReadonlySet, +): string[] { + const hits: string[] = []; + const seen = new Set(); + for (const shape of stage.getAllIntersections(point)) { + let n: Konva.Node | null = shape; + while (n) { + const id = n.id(); + if (id && candidates.has(id) && !seen.has(id)) { + hits.push(id); + seen.add(id); + break; + } + n = n.getParent(); + } + } + return hits; +} diff --git a/src/components/Canvas/hooks/useAltClickCycle.ts b/src/components/Canvas/hooks/useAltClickCycle.ts index f59b8be3..8abf557a 100644 --- a/src/components/Canvas/hooks/useAltClickCycle.ts +++ b/src/components/Canvas/hooks/useAltClickCycle.ts @@ -6,6 +6,7 @@ import { nextCycleIndex, type CycleAnchor, } from "../altClickCycle"; +import { objectIdsAtPoint } from "../hitTesting"; interface Options { containerRef: React.RefObject; @@ -34,29 +35,11 @@ export function useAltClickCycle({ containerRef, stageRef, selectObject }: Optio if (!stage) return; const rect = el.getBoundingClientRect(); const point = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - // Use Konva's own hit-graph: it accounts for view rotation, pan - // offset, per-shape transforms and the listening flag, all of - // which our own bbox math would have to mirror by hand. - const intersections = stage.getAllIntersections(point); - if (intersections.length === 0) return; + // Konva's own hit-graph respects view rotation, pan offset, + // per-shape transforms and the listening flag — far cheaper than + // mirroring all of that in our own bbox math. const objIds = new Set(getCurrentObjects().map((o) => o.id)); - const hits: string[] = []; - const seen = new Set(); - for (const shape of intersections) { - // Walk up to the registered object Group (each KonvaObject sets - // `id={obj.id}` on its outer Group; intersections may land on a - // child Rect/Text/etc.). - let n: Konva.Node | null = shape; - while (n) { - const id = n.id(); - if (id && objIds.has(id) && !seen.has(id)) { - hits.push(id); - seen.add(id); - break; - } - n = n.getParent(); - } - } + const hits = objectIdsAtPoint(stage, point, objIds); if (hits.length === 0) return; e.stopPropagation(); e.preventDefault(); diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index d28262eb..c30d3e08 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -13,6 +13,7 @@ import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; import type { LabelObject } from '../../registry'; import { useT } from '../../lib/useT'; +import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { DragHandleIcon } from '../ui/DragHandleIcon'; interface RowProps { @@ -116,30 +117,16 @@ export function LayersPanel() { const t = useT(); const { selectedIds, selectObject, toggleSelectObject, reorderObject, updateObjects } = useLabelStore(); const objects = useCurrentObjects(); - - /** Figma-style bulk pattern: a per-row toggle on a row that's part of the - * active selection broadcasts to every selected object; clicking a row - * outside the selection acts only on that row. The new value is derived - * from the *clicked* object so the user gets the visual flip they - * initiated even when the rest of the selection was in mixed states. */ - const toggleField = (clickedId: string, field: 'locked' | 'visible') => { - const clicked = objects.find((o) => o.id === clickedId); - if (!clicked) return; - const currentlyOn = field === 'locked' ? !!clicked.locked : clicked.visible !== false; - const targets = selectedIds.includes(clickedId) ? selectedIds : [clickedId]; - // `locked` flips false ↔ true; `visible` flips true ↔ false. Both are - // simply the inverse of the clicked row's current state — broadcast as - // the same value to every target so the bulk action is predictable - // (mixed-state selections all converge on the flipped value). - const nextValue = !currentlyOn; - updateObjects(targets.map((id) => ({ id, changes: { [field]: nextValue } }))); - }; const [overId, setOverId] = useState(null); - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); + const toggleField = (clickedId: string, field: ToggleField) => { + const updates = buildBulkToggleUpdates(objects, selectedIds, clickedId, field); + if (updates.length > 0) updateObjects(updates); + }; + if (objects.length === 0) { return (
diff --git a/src/lib/bulkToggle.test.ts b/src/lib/bulkToggle.test.ts new file mode 100644 index 00000000..8fc1216e --- /dev/null +++ b/src/lib/bulkToggle.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { buildBulkToggleUpdates } from './bulkToggle'; + +describe('buildBulkToggleUpdates', () => { + describe('lock', () => { + it('flips a single unlocked row to locked', () => { + const updates = buildBulkToggleUpdates( + [{ id: 'a' }], + [], + 'a', + 'locked', + ); + expect(updates).toEqual([{ id: 'a', changes: { locked: true } }]); + }); + + it('flips a single locked row back to unlocked', () => { + const updates = buildBulkToggleUpdates( + [{ id: 'a', locked: true }], + [], + 'a', + 'locked', + ); + expect(updates).toEqual([{ id: 'a', changes: { locked: false } }]); + }); + + it('broadcasts the clicked row state to the whole selection', () => { + const updates = buildBulkToggleUpdates( + [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + ['a', 'b', 'c'], + 'a', + 'locked', + ); + expect(updates).toEqual([ + { id: 'a', changes: { locked: true } }, + { id: 'b', changes: { locked: true } }, + { id: 'c', changes: { locked: true } }, + ]); + }); + + it('converges a mixed-state selection on the flipped value of the clicked row', () => { + // Clicked row is unlocked → next = locked → every selected row goes + // to locked, including ones that were already locked. + const updates = buildBulkToggleUpdates( + [ + { id: 'a' }, // unlocked → clicked + { id: 'b', locked: true }, + { id: 'c' }, + ], + ['a', 'b', 'c'], + 'a', + 'locked', + ); + expect(updates).toEqual([ + { id: 'a', changes: { locked: true } }, + { id: 'b', changes: { locked: true } }, + { id: 'c', changes: { locked: true } }, + ]); + }); + + it('targets only the clicked row when it is not part of the selection', () => { + const updates = buildBulkToggleUpdates( + [{ id: 'a' }, { id: 'b' }], + ['b'], + 'a', + 'locked', + ); + expect(updates).toEqual([{ id: 'a', changes: { locked: true } }]); + }); + }); + + describe('visible', () => { + it('treats undefined visible as on, so the first toggle hides', () => { + const updates = buildBulkToggleUpdates( + [{ id: 'a' }], + [], + 'a', + 'visible', + ); + expect(updates).toEqual([{ id: 'a', changes: { visible: false } }]); + }); + + it('flips a hidden row back to visible', () => { + const updates = buildBulkToggleUpdates( + [{ id: 'a', visible: false }], + [], + 'a', + 'visible', + ); + expect(updates).toEqual([{ id: 'a', changes: { visible: true } }]); + }); + }); + + it('returns empty when the clicked id is unknown', () => { + expect(buildBulkToggleUpdates([{ id: 'a' }], ['a'], 'missing', 'locked')).toEqual([]); + }); +}); diff --git a/src/lib/bulkToggle.ts b/src/lib/bulkToggle.ts new file mode 100644 index 00000000..09030fbc --- /dev/null +++ b/src/lib/bulkToggle.ts @@ -0,0 +1,43 @@ +import type { ObjectChanges } from "../store/labelStore"; + +/** Object shape this helper cares about. Kept narrow on purpose so the + * function stays decoupled from the full LabelObject union. */ +export interface ToggleTarget { + id: string; + locked?: boolean; + visible?: boolean; +} + +export type ToggleField = "locked" | "visible"; + +/** + * Figma-style bulk toggle. + * + * A click on a per-row Lock or Eye icon broadcasts the resulting state to + * the whole active selection when the clicked row is part of it; otherwise + * the click acts only on that row. The next value is derived from the + * *clicked* object's current state so mixed-state selections converge on + * one predictable value rather than each item toggling independently. + * + * Returns the patch list to feed into `useLabelStore.updateObjects`. Empty + * array if the clicked id is unknown. + */ +export function buildBulkToggleUpdates( + objects: readonly ToggleTarget[], + selectedIds: readonly string[], + clickedId: string, + field: ToggleField, +): { id: string; changes: ObjectChanges }[] { + const clicked = objects.find((o) => o.id === clickedId); + if (!clicked) return []; + + // `locked` is on when truthy; `visible` defaults to on when undefined, so + // only an explicit `false` counts as off. Inverting whichever is current + // gives the new value. + const currentlyOn = + field === "locked" ? !!clicked.locked : clicked.visible !== false; + const nextValue = !currentlyOn; + + const targets = selectedIds.includes(clickedId) ? selectedIds : [clickedId]; + return targets.map((id) => ({ id, changes: { [field]: nextValue } })); +} diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index bb8d6e92..359a7316 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -338,15 +338,15 @@ export const useLabelStore = create()( removeSelectedObjects: () => set((state) => { const sel = new Set(state.selectedIds); + const objs = currentObjects(state); // Locked objects survive a Delete keystroke / bulk-remove; the // multi-select clears down to whichever locked items remain. - const removable = (o: LabelObject) => sel.has(o.id) && !o.locked; + const lockedIds = objs.flatMap((o) => sel.has(o.id) && o.locked ? [o.id] : []); return { - ...updateCurrentObjects(state, (objs) => objs.filter((o) => !removable(o))), - selectedIds: state.selectedIds.filter((id) => { - const obj = currentObjects(state).find((o) => o.id === id); - return obj?.locked; - }), + ...updateCurrentObjects(state, (curr) => + curr.filter((o) => !sel.has(o.id) || o.locked), + ), + selectedIds: lockedIds, }; }), From 459070198f037b75fe81fe04417b677f1a28b2a9 Mon Sep 17 00:00:00 2001 From: u8array Date: Tue, 12 May 2026 22:09:28 +0200 Subject: [PATCH 3/3] Refactor bulk toggle to use undefined for default state --- src/lib/bulkToggle.test.ts | 11 +++++++---- src/lib/bulkToggle.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/lib/bulkToggle.test.ts b/src/lib/bulkToggle.test.ts index 8fc1216e..302731b5 100644 --- a/src/lib/bulkToggle.test.ts +++ b/src/lib/bulkToggle.test.ts @@ -13,14 +13,17 @@ describe('buildBulkToggleUpdates', () => { expect(updates).toEqual([{ id: 'a', changes: { locked: true } }]); }); - it('flips a single locked row back to unlocked', () => { + it('flips a single locked row back to its default state (undefined)', () => { + // Default-state values persist as `undefined` to match the + // PropertiesPanel checkbox pattern and keep saved JSON free of + // boilerplate `locked: false` keys. const updates = buildBulkToggleUpdates( [{ id: 'a', locked: true }], [], 'a', 'locked', ); - expect(updates).toEqual([{ id: 'a', changes: { locked: false } }]); + expect(updates).toEqual([{ id: 'a', changes: { locked: undefined } }]); }); it('broadcasts the clicked row state to the whole selection', () => { @@ -79,14 +82,14 @@ describe('buildBulkToggleUpdates', () => { expect(updates).toEqual([{ id: 'a', changes: { visible: false } }]); }); - it('flips a hidden row back to visible', () => { + it('flips a hidden row back to its default state (undefined)', () => { const updates = buildBulkToggleUpdates( [{ id: 'a', visible: false }], [], 'a', 'visible', ); - expect(updates).toEqual([{ id: 'a', changes: { visible: true } }]); + expect(updates).toEqual([{ id: 'a', changes: { visible: undefined } }]); }); }); diff --git a/src/lib/bulkToggle.ts b/src/lib/bulkToggle.ts index 09030fbc..0c2fa70f 100644 --- a/src/lib/bulkToggle.ts +++ b/src/lib/bulkToggle.ts @@ -38,6 +38,17 @@ export function buildBulkToggleUpdates( field === "locked" ? !!clicked.locked : clicked.visible !== false; const nextValue = !currentlyOn; + // Match the PropertiesPanel checkbox pattern: persist `undefined` for + // each field's default state (locked=off, visible=on) so the same toggle + // produces the same JSON regardless of which UI path triggered it. + // Without this, LayersPanel toggles would leave `locked: false` / + // `visible: true` in saved design files while PropertiesPanel toggles + // omit the key — producing churn in version-controlled design files. + const patchValue: boolean | undefined = + field === "locked" + ? (nextValue ? true : undefined) + : (nextValue ? undefined : false); + const targets = selectedIds.includes(clickedId) ? selectedIds : [clickedId]; - return targets.map((id) => ({ id, changes: { [field]: nextValue } })); + return targets.map((id) => ({ id, changes: { [field]: patchValue } })); }