|
| 1 | +import { isGroup, type LabelObject, type Page } from '../types/Group'; |
| 2 | +import type { ObjectChanges } from '../types/LabelObject'; |
| 3 | +import type { LocaleCode } from '../locales'; |
| 4 | +import { locales } from '../locales'; |
| 5 | +import { renameTemplateMarker } from '../lib/fnTemplate'; |
| 6 | +import { getObjectStringContent } from '../lib/variableBinding'; |
| 7 | +import { getEntry } from '../registry'; |
| 8 | + |
| 9 | +/** Meta fields that remain editable on a locked object so the user can |
| 10 | + * release the lock or annotate without unlocking first. Everything else |
| 11 | + * (position, props, rotation, positionType) is blocked. */ |
| 12 | +const LOCK_BYPASS_KEYS = new Set(['locked', 'visible', 'includeInExport', 'comment', 'name']); |
| 13 | + |
| 14 | +export function isLockBypass(changes: ObjectChanges): boolean { |
| 15 | + const keys = Object.keys(changes); |
| 16 | + return keys.length > 0 && keys.every((k) => LOCK_BYPASS_KEYS.has(k)); |
| 17 | +} |
| 18 | + |
| 19 | +/** Apply `renameTemplateMarker` to every leaf's `content` in a subtree. |
| 20 | + * Identity-preserving: returns the same array (and same node refs) |
| 21 | + * when no markers needed rewriting, so React memoisation downstream |
| 22 | + * stays effective for the common case where the rename touched no |
| 23 | + * templates. */ |
| 24 | +export function rewriteTemplateMarkers( |
| 25 | + objects: LabelObject[], |
| 26 | + oldName: string, |
| 27 | + newName: string, |
| 28 | +): LabelObject[] { |
| 29 | + let changed = false; |
| 30 | + const next = objects.map((obj) => { |
| 31 | + if (isGroup(obj)) { |
| 32 | + const nextChildren = rewriteTemplateMarkers(obj.children, oldName, newName); |
| 33 | + if (nextChildren === obj.children) return obj; |
| 34 | + changed = true; |
| 35 | + return { ...obj, children: nextChildren }; |
| 36 | + } |
| 37 | + const content = getObjectStringContent(obj); |
| 38 | + if (content === undefined) return obj; |
| 39 | + const renamed = renameTemplateMarker(content, oldName, newName); |
| 40 | + if (renamed === content) return obj; |
| 41 | + changed = true; |
| 42 | + const props = (obj as { props: object }).props; |
| 43 | + return { ...obj, props: { ...props, content: renamed } } as LabelObject; |
| 44 | + }); |
| 45 | + return changed ? next : objects; |
| 46 | +} |
| 47 | + |
| 48 | +export function applyObjectChanges( |
| 49 | + obj: LabelObject, |
| 50 | + changes: ObjectChanges, |
| 51 | + ancestorLocked = false, |
| 52 | +): LabelObject { |
| 53 | + // Lock cascades from any ancestor group: a leaf inside a locked group |
| 54 | + // accepts only bypass keys (locked / visible / includeInExport / |
| 55 | + // comment / name) so the user can still toggle visibility or release |
| 56 | + // the lock from the layers panel. Load-bearing — `expandSelection`- |
| 57 | + // driven callers (arrow-key nudges, shift-multi-drag) target the |
| 58 | + // group's leaf children directly and would otherwise sidestep the |
| 59 | + // group's own `locked` flag. |
| 60 | + if ((obj.locked || ancestorLocked) && !isLockBypass(changes)) return obj; |
| 61 | + if (isGroup(obj)) { |
| 62 | + // Groups have no registry entry (no normalize hook) and no props to |
| 63 | + // merge — apply top-level changes only. Children stay untouched; |
| 64 | + // tree updates reach them through their own mapObjectById call. |
| 65 | + return { ...obj, ...changes } as LabelObject; |
| 66 | + } |
| 67 | + const normalize = getEntry(obj.type)?.normalizeChanges; |
| 68 | + const normalized = normalize ? normalize(obj, changes) : changes; |
| 69 | + return { |
| 70 | + ...obj, |
| 71 | + ...normalized, |
| 72 | + props: normalized.props ? Object.assign({}, obj.props, normalized.props) : obj.props, |
| 73 | + } as LabelObject; |
| 74 | +} |
| 75 | + |
| 76 | +/** Immutable insert-at-index that clamps `idx` into the array's bounds. |
| 77 | + * Used by reparent flows to splice a node into a children list or the |
| 78 | + * top-level list without crashing on out-of-range indices coming from |
| 79 | + * ephemeral drag state. */ |
| 80 | +export function insertAt<T>(arr: readonly T[], idx: number, item: T): T[] { |
| 81 | + const clamped = Math.max(0, Math.min(idx, arr.length)); |
| 82 | + return [...arr.slice(0, clamped), item, ...arr.slice(clamped)]; |
| 83 | +} |
| 84 | + |
| 85 | +export function detectLocale(): LocaleCode { |
| 86 | + const lang = navigator.language.slice(0, 2).toLowerCase(); |
| 87 | + return (lang in locales ? lang : 'en') as LocaleCode; |
| 88 | +} |
| 89 | + |
| 90 | +export function detectInitialTheme(): 'light' | 'dark' { |
| 91 | + return window.matchMedia('(prefers-color-scheme: dark)').matches |
| 92 | + ? 'dark' |
| 93 | + : 'light'; |
| 94 | +} |
| 95 | + |
| 96 | +/** Build-time defaults for third-party services. Vite injects VITE_THIRD_PARTY_* |
| 97 | + * env values; missing values fall back to enabled. Tauri/Docker builds can flip |
| 98 | + * the default by setting VITE_THIRD_PARTY_LABELARY=false in their build env. */ |
| 99 | +export function thirdPartyDefaults(): { labelary: boolean } { |
| 100 | + return { |
| 101 | + labelary: import.meta.env.VITE_THIRD_PARTY_LABELARY !== 'false', |
| 102 | + }; |
| 103 | +} |
| 104 | + |
| 105 | +/** Base offset (in dots) used to stagger duplicate / paste copies so they |
| 106 | + * don't sit exactly on top of the source. 20 dots ≈ 2.5 mm at 8dpmm. */ |
| 107 | +export const DUPLICATE_OFFSET_DOTS = 20; |
| 108 | + |
| 109 | +/** Deep-clone a children list with fresh ids and shallow-cloned props on |
| 110 | + * every leaf. Recurses through nested groups. */ |
| 111 | +export function cloneChildrenFresh(children: LabelObject[]): LabelObject[] { |
| 112 | + return children.map((c) => { |
| 113 | + if (isGroup(c)) { |
| 114 | + return { |
| 115 | + ...c, |
| 116 | + id: crypto.randomUUID(), |
| 117 | + children: cloneChildrenFresh(c.children), |
| 118 | + }; |
| 119 | + } |
| 120 | + return { |
| 121 | + ...c, |
| 122 | + id: crypto.randomUUID(), |
| 123 | + props: { ...c.props }, |
| 124 | + } as LabelObject; |
| 125 | + }); |
| 126 | +} |
| 127 | + |
| 128 | +/** Build offset copies of objects identified by `ids`. Missing ids are |
| 129 | + * silently dropped. Props are shallow-cloned to avoid sharing the |
| 130 | + * reference with the original. */ |
| 131 | +export function buildOffsetCopies(objs: LabelObject[], ids: readonly string[]): LabelObject[] { |
| 132 | + const byId = new Map(objs.map((o) => [o.id, o])); |
| 133 | + return ids.flatMap((id) => { |
| 134 | + const src = byId.get(id); |
| 135 | + if (!src) return []; |
| 136 | + if (isGroup(src)) { |
| 137 | + return [{ |
| 138 | + ...src, |
| 139 | + id: crypto.randomUUID(), |
| 140 | + x: src.x + DUPLICATE_OFFSET_DOTS, |
| 141 | + y: src.y + DUPLICATE_OFFSET_DOTS, |
| 142 | + children: cloneChildrenFresh(src.children), |
| 143 | + }]; |
| 144 | + } |
| 145 | + return [{ |
| 146 | + ...src, |
| 147 | + id: crypto.randomUUID(), |
| 148 | + x: src.x + DUPLICATE_OFFSET_DOTS, |
| 149 | + y: src.y + DUPLICATE_OFFSET_DOTS, |
| 150 | + props: { ...src.props }, |
| 151 | + } as LabelObject]; |
| 152 | + }); |
| 153 | +} |
| 154 | + |
| 155 | +/** Subset of LabelState that paged mutators read. Slices that touch pages |
| 156 | + * via `set((state) => updateCurrentObjects(state, fn))` use this shape. */ |
| 157 | +export interface PageState { |
| 158 | + pages: Page[]; |
| 159 | + currentPageIndex: number; |
| 160 | +} |
| 161 | + |
| 162 | +export function updateCurrentObjects( |
| 163 | + state: PageState, |
| 164 | + fn: (objects: LabelObject[]) => LabelObject[] |
| 165 | +): { pages: Page[] } { |
| 166 | + return { |
| 167 | + pages: state.pages.map((p, i) => |
| 168 | + i === state.currentPageIndex ? { ...p, objects: fn(p.objects) } : p |
| 169 | + ), |
| 170 | + }; |
| 171 | +} |
0 commit comments