@open-aippt/core 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,1218 @@
1
+ import { Crosshair } from 'lucide-react';
2
+ import {
3
+ createContext,
4
+ type ReactNode,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import { toast } from 'sonner';
13
+ import { useHistory } from '@/components/history-provider';
14
+ import { Button } from '@/components/ui/button';
15
+ import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
16
+ import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
17
+ import { useLocale } from '@/lib/use-locale';
18
+ import { AssetPickerDialog } from './asset-picker-dialog';
19
+ import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
20
+
21
+ export type SelectedTarget = {
22
+ line: number;
23
+ column: number;
24
+ anchor: HTMLElement;
25
+ };
26
+
27
+ type AssetAttrOp = { assetPath: string; previewUrl: string };
28
+ type Sequenced<T> = T & { seq: number };
29
+ type StyleOp = { value: string | null; prevText?: string };
30
+ type TextRangeStyleOp = {
31
+ instanceId: string;
32
+ start: number;
33
+ end: number;
34
+ key: string;
35
+ value: string | null;
36
+ prevText?: string;
37
+ };
38
+
39
+ type Bucket = {
40
+ line: number;
41
+ column: number;
42
+ styleOps: Map<string, Sequenced<StyleOp>>;
43
+ rangeStyleOps: Map<string, Sequenced<TextRangeStyleOp>>;
44
+ // Text edits are scoped per DOM instance: a reused component renders
45
+ // the same JSX `<h2>{title}</h2>` at multiple call sites with the same
46
+ // `data-slide-loc`, but each call site's prop literal is independent.
47
+ // Style/attr ops stay shared because they edit the JSX definition.
48
+ textOps: Map<string /* instanceId */, Sequenced<{ value: string }>>;
49
+ attrOps: Map<string, Sequenced<AssetAttrOp>>;
50
+ // Pre-edit snapshot of the DOM, captured the first time we touch
51
+ // each style key / text / attribute. Used by `cancelEdits` to revert.
52
+ origStyle: Map<string, string>;
53
+ origTexts: Map<string /* instanceId */, { value: string }>;
54
+ origHtmls: Map<string /* instanceId */, string>;
55
+ origAttrs: Map<string, string | null>;
56
+ };
57
+
58
+ const INSTANCE_ID_ATTR = 'data-slide-instance-id';
59
+
60
+ function readInstanceId(el: HTMLElement): string | null {
61
+ return el.getAttribute(INSTANCE_ID_ATTR);
62
+ }
63
+
64
+ type DomTextPart = { node: Text | HTMLBRElement; current: string };
65
+
66
+ function readEditableText(el: HTMLElement): string {
67
+ const parts: DomTextPart[] = [];
68
+ collectDomTextParts(el, parts);
69
+ return parts.map((part) => part.current).join('');
70
+ }
71
+
72
+ function collectDomTextParts(node: Node, out: DomTextPart[]): void {
73
+ const parts: DomTextPart[] = [];
74
+ collectDomTextPartsRaw(node, parts);
75
+ out.push(...normalizeDomTextParts(parts));
76
+ }
77
+
78
+ function collectDomTextPartsRaw(node: Node, out: DomTextPart[]): void {
79
+ for (const child of Array.from(node.childNodes)) {
80
+ if (child instanceof Text) {
81
+ const current = renderedTextNodeValue(child);
82
+ if (current) out.push({ node: child, current });
83
+ } else if (child instanceof HTMLBRElement) {
84
+ out.push({ node: child, current: '\n' });
85
+ } else if (child instanceof HTMLElement) {
86
+ collectDomTextPartsRaw(child, out);
87
+ }
88
+ }
89
+ }
90
+
91
+ function normalizeDomTextParts(parts: DomTextPart[]): DomTextPart[] {
92
+ return parts.flatMap((part, index) => {
93
+ if (part.current === '\n') return [part];
94
+ let current = part.current;
95
+ if (parts[index - 1]?.current === '\n') current = current.replace(/^\s+/, '');
96
+ if (parts[index + 1]?.current === '\n') current = current.replace(/\s+$/, '');
97
+ return current ? [{ ...part, current }] : [];
98
+ });
99
+ }
100
+
101
+ function renderedTextNodeValue(node: Text): string {
102
+ const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
103
+ if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
104
+ return node.data;
105
+ }
106
+ return node.data.replace(/\s+/g, ' ');
107
+ }
108
+
109
+ function textDiff(prevText: string, nextText: string) {
110
+ let start = 0;
111
+ while (
112
+ start < prevText.length &&
113
+ start < nextText.length &&
114
+ prevText[start] === nextText[start]
115
+ ) {
116
+ start += 1;
117
+ }
118
+
119
+ let prevEnd = prevText.length;
120
+ let nextEnd = nextText.length;
121
+ while (prevEnd > start && nextEnd > start && prevText[prevEnd - 1] === nextText[nextEnd - 1]) {
122
+ prevEnd -= 1;
123
+ nextEnd -= 1;
124
+ }
125
+
126
+ return { start, end: prevEnd, value: nextText.slice(start, nextEnd) };
127
+ }
128
+
129
+ function textFragment(value: string): DocumentFragment {
130
+ const fragment = document.createDocumentFragment();
131
+ const lines = value.split('\n');
132
+ for (let i = 0; i < lines.length; i++) {
133
+ if (lines[i]) fragment.append(document.createTextNode(lines[i]));
134
+ if (i < lines.length - 1) fragment.append(document.createElement('br'));
135
+ }
136
+ return fragment;
137
+ }
138
+
139
+ function replaceDomTextPart(part: DomTextPart, value: string) {
140
+ if (part.node instanceof Text && !value.includes('\n')) {
141
+ part.node.data = value;
142
+ return;
143
+ }
144
+ const fragment = textFragment(value);
145
+ part.node.replaceWith(fragment);
146
+ }
147
+
148
+ function setEditableText(el: HTMLElement, value: string) {
149
+ const parts: DomTextPart[] = [];
150
+ collectDomTextParts(el, parts);
151
+ const current = parts.map((part) => part.current).join('');
152
+ if (current === value) return;
153
+ if (parts.length === 0) {
154
+ el.replaceChildren(textFragment(value));
155
+ return;
156
+ }
157
+
158
+ const diff = textDiff(current, value);
159
+ let offset = 0;
160
+ let inserted = false;
161
+ for (const part of parts) {
162
+ const partStart = offset;
163
+ const partEnd = partStart + part.current.length;
164
+ offset = partEnd;
165
+
166
+ const overlaps = diff.start < partEnd && diff.end > partStart;
167
+ const insertsHere =
168
+ diff.start === diff.end && !inserted && diff.start >= partStart && diff.start <= partEnd;
169
+ if (!overlaps && !insertsHere) continue;
170
+
171
+ if (part.node instanceof Text) {
172
+ const localStart = Math.max(diff.start, partStart) - partStart;
173
+ const localEnd = overlaps ? Math.min(diff.end, partEnd) - partStart : localStart;
174
+ replaceDomTextPart(
175
+ part,
176
+ `${part.current.slice(0, localStart)}${inserted ? '' : diff.value}${part.current.slice(localEnd)}`,
177
+ );
178
+ } else if (overlaps) {
179
+ replaceDomTextPart(part, inserted ? '' : diff.value);
180
+ } else {
181
+ const fragment = textFragment(diff.value);
182
+ if (diff.start === partStart) part.node.before(fragment);
183
+ else part.node.after(fragment);
184
+ }
185
+
186
+ inserted = true;
187
+ }
188
+
189
+ if (!inserted && diff.start === diff.end && diff.start === offset) {
190
+ el.append(textFragment(diff.value));
191
+ }
192
+ }
193
+
194
+ function rangeStyleKey(
195
+ instanceId: string,
196
+ op: { start: number; end: number; key: string },
197
+ ): string {
198
+ return `${instanceId}:${op.start}:${op.end}:${op.key}`;
199
+ }
200
+
201
+ function applyDomTextRangeStyle(
202
+ el: HTMLElement,
203
+ op: Pick<TextRangeStyleOp, 'start' | 'end' | 'key' | 'value'>,
204
+ ) {
205
+ const value = op.value ?? resetValueForRangeStyle(op.key);
206
+ if (value === null) return;
207
+ const parts: DomTextPart[] = [];
208
+ collectDomTextParts(el, parts);
209
+ let offset = 0;
210
+ for (const part of parts) {
211
+ const partStart = offset;
212
+ const partEnd = partStart + part.current.length;
213
+ offset = partEnd;
214
+ if (!(part.node instanceof Text)) continue;
215
+ const selectedStart = Math.max(op.start, partStart);
216
+ const selectedEnd = Math.min(op.end, partEnd);
217
+ if (selectedStart >= selectedEnd) continue;
218
+
219
+ const localStart = selectedStart - partStart;
220
+ const localEnd = selectedEnd - partStart;
221
+ const before = part.current.slice(0, localStart);
222
+ const selected = part.current.slice(localStart, localEnd);
223
+ const after = part.current.slice(localEnd);
224
+ const span = document.createElement('span');
225
+ (span.style as unknown as Record<string, string>)[op.key] = value;
226
+ span.textContent = selected;
227
+ part.node.replaceWith(document.createTextNode(before), span, document.createTextNode(after));
228
+ }
229
+ }
230
+
231
+ function resetValueForRangeStyle(key: string): string | null {
232
+ if (key === 'fontWeight') return '400';
233
+ if (key === 'fontStyle') return 'normal';
234
+ return null;
235
+ }
236
+
237
+ function replayDomTextRangeStyles(el: HTMLElement, html: string, ops: TextRangeStyleOp[]) {
238
+ const preview = document.createElement('span');
239
+ preview.innerHTML = html;
240
+ for (const op of ops) applyDomTextRangeStyle(preview, op);
241
+ if (el.innerHTML !== preview.innerHTML) el.innerHTML = preview.innerHTML;
242
+ }
243
+
244
+ type InspectorCtx = {
245
+ slideId: string;
246
+ active: boolean;
247
+ activate: () => void;
248
+ toggle: () => void;
249
+ cancel: () => void;
250
+ comments: SlideComment[];
251
+ error: string | null;
252
+ refetch: () => Promise<void>;
253
+ add: (line: number, column: number, text: string) => Promise<void>;
254
+ remove: (id: string) => Promise<void>;
255
+ selected: SelectedTarget | null;
256
+ setSelected: (s: SelectedTarget | null) => void;
257
+ applyEdit: (line: number, column: number, ops: EditOp[]) => Promise<void>;
258
+ applyEdits: (edits: Edit[]) => Promise<EditResult[]>;
259
+ // Mutate the DOM optimistically, snapshot the pre-edit values, and
260
+ // remember the ops. `commitEdits` (manual Save or auto-flush on
261
+ // close) is what actually writes to disk; `cancelEdits` reverts.
262
+ bufferOps: (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => void;
263
+ pendingCount: number;
264
+ commitEdits: () => Promise<void>;
265
+ cancelEdits: () => void;
266
+ committing: boolean;
267
+ openCrop: (anchor: HTMLImageElement) => void;
268
+ openReplace: (anchor: HTMLElement) => void;
269
+ };
270
+
271
+ const Ctx = createContext<InspectorCtx | null>(null);
272
+
273
+ export function useInspector(): InspectorCtx {
274
+ const v = useContext(Ctx);
275
+ if (!v) throw new Error('useInspector must be used inside <InspectorProvider>');
276
+ return v;
277
+ }
278
+
279
+ export function InspectorProvider({
280
+ slideId,
281
+ pageIndex,
282
+ children,
283
+ }: {
284
+ slideId: string;
285
+ pageIndex: number;
286
+ children: ReactNode;
287
+ }) {
288
+ const [manualActive, setManualActive] = useState(false);
289
+ const [heldActive, setHeldActive] = useState(false);
290
+ const [selected, setSelected] = useState<SelectedTarget | null>(null);
291
+ const { comments, error, refetch, add, remove } = useComments(slideId);
292
+ const { applyEdit, applyEdits } = useEditor(slideId);
293
+ const history = useHistory();
294
+ const active = manualActive || heldActive;
295
+ const manualActiveRef = useRef(manualActive);
296
+
297
+ const pendingRef = useRef<Map<string, Bucket>>(new Map());
298
+ const instanceCounterRef = useRef(0);
299
+ const pendingSeqRef = useRef(0);
300
+ const [pendingCount, setPendingCount] = useState(0);
301
+ const [committing, setCommitting] = useState(false);
302
+ const [cropTarget, setCropTarget] = useState<{
303
+ line: number;
304
+ column: number;
305
+ anchor: HTMLImageElement;
306
+ src: string;
307
+ targetWidth: number;
308
+ targetHeight: number;
309
+ initialFit: 'cover' | 'contain';
310
+ initialPosition: { x: number; y: number };
311
+ initialRect: ImageCropRect | null;
312
+ } | null>(null);
313
+ const [replaceTarget, setReplaceTarget] = useState<{
314
+ line: number;
315
+ column: number;
316
+ anchor: HTMLElement;
317
+ } | null>(null);
318
+ const t = useLocale();
319
+
320
+ const ensureInstanceId = useCallback((el: HTMLElement): string => {
321
+ const existing = el.getAttribute(INSTANCE_ID_ATTR);
322
+ if (existing) return existing;
323
+ const next = `inst-${++instanceCounterRef.current}`;
324
+ el.setAttribute(INSTANCE_ID_ATTR, next);
325
+ return next;
326
+ }, []);
327
+
328
+ const refreshCount = useCallback(() => {
329
+ let n = 0;
330
+ for (const b of pendingRef.current.values()) {
331
+ if (
332
+ b.styleOps.size > 0 ||
333
+ b.rangeStyleOps.size > 0 ||
334
+ b.textOps.size > 0 ||
335
+ b.attrOps.size > 0
336
+ ) {
337
+ n++;
338
+ }
339
+ }
340
+ setPendingCount(n);
341
+ }, []);
342
+
343
+ // Find the live anchor for a buffered loc. Used by history undo/redo
344
+ // since the original `anchor` reference may have unmounted. With an
345
+ // instance id, prefer the matching DOM node so per-instance text edits
346
+ // round-trip onto the right element.
347
+ const findAnchor = useCallback((line: number, column: number, instanceId?: string) => {
348
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
349
+ if (!root) return null;
350
+ if (instanceId) {
351
+ const byInstance = root.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`);
352
+ if (byInstance) return byInstance;
353
+ }
354
+ return root.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`);
355
+ }, []);
356
+
357
+ // Mutate bucket + DOM without recording history. Shared by `bufferOps`
358
+ // (the public, history-recording entry point) and by `redo` closures.
359
+ const applyOpsRaw = useCallback(
360
+ (line: number, column: number, anchor: HTMLElement | null, ops: EditOp[]) => {
361
+ const key = `${line}:${column}`;
362
+ let bucket = pendingRef.current.get(key);
363
+ if (!bucket) {
364
+ bucket = {
365
+ line,
366
+ column,
367
+ styleOps: new Map(),
368
+ rangeStyleOps: new Map(),
369
+ textOps: new Map(),
370
+ attrOps: new Map(),
371
+ origStyle: new Map(),
372
+ origTexts: new Map(),
373
+ origHtmls: new Map(),
374
+ origAttrs: new Map(),
375
+ };
376
+ pendingRef.current.set(key, bucket);
377
+ }
378
+ const style = (anchor?.style ?? {}) as unknown as Record<string, string>;
379
+ for (const op of ops) {
380
+ const seq = ++pendingSeqRef.current;
381
+ if (op.kind === 'set-style') {
382
+ if (anchor && !bucket.origStyle.has(op.key)) {
383
+ bucket.origStyle.set(op.key, style[op.key] ?? '');
384
+ }
385
+ bucket.styleOps.set(op.key, { value: op.value, prevText: op.prevText, seq });
386
+ if (anchor?.isConnected) style[op.key] = op.value ?? '';
387
+ } else if (op.kind === 'set-text-range-style') {
388
+ if (!anchor) continue;
389
+ const instanceId = ensureInstanceId(anchor);
390
+ if (!bucket.origHtmls.has(instanceId)) bucket.origHtmls.set(instanceId, anchor.innerHTML);
391
+ const nextOp: Sequenced<TextRangeStyleOp> = {
392
+ instanceId,
393
+ start: op.start,
394
+ end: op.end,
395
+ key: op.key,
396
+ value: op.value,
397
+ prevText: op.prevText ?? readEditableText(anchor),
398
+ seq,
399
+ };
400
+ bucket.rangeStyleOps.set(rangeStyleKey(instanceId, op), nextOp);
401
+ if (anchor.isConnected) {
402
+ replayDomTextRangeStyles(
403
+ anchor,
404
+ bucket.origHtmls.get(instanceId) ?? anchor.innerHTML,
405
+ Array.from(bucket.rangeStyleOps.values()).filter(
406
+ (item) => item.instanceId === instanceId,
407
+ ),
408
+ );
409
+ }
410
+ } else if (op.kind === 'set-text') {
411
+ // Reused JSX renders multiple DOM nodes with the same
412
+ // `data-slide-loc` but distinct call-site literals; without an
413
+ // anchor we can't tell which instance to route to, so skip.
414
+ if (!anchor) continue;
415
+ const instanceId = ensureInstanceId(anchor);
416
+ if (!bucket.origTexts.has(instanceId)) {
417
+ bucket.origTexts.set(instanceId, { value: readEditableText(anchor) });
418
+ }
419
+ bucket.textOps.set(instanceId, { value: op.value, seq });
420
+ if (anchor.isConnected) setEditableText(anchor, op.value);
421
+ } else if (op.kind === 'set-attr-asset') {
422
+ if (anchor && !bucket.origAttrs.has(op.attr)) {
423
+ bucket.origAttrs.set(
424
+ op.attr,
425
+ anchor.hasAttribute(op.attr) ? anchor.getAttribute(op.attr) : null,
426
+ );
427
+ }
428
+ bucket.attrOps.set(op.attr, {
429
+ assetPath: op.assetPath,
430
+ previewUrl: op.previewUrl,
431
+ seq,
432
+ });
433
+ if (anchor?.isConnected) anchor.setAttribute(op.attr, op.previewUrl);
434
+ }
435
+ }
436
+ refreshCount();
437
+ },
438
+ [refreshCount, ensureInstanceId],
439
+ );
440
+
441
+ // Pre-edit snapshot for history: capture the *currently effective* value of
442
+ // each touched field so undo can restore exactly the prior state, including
443
+ // the case where the bucket already had a buffered edit before this op.
444
+ type StyleSnap = {
445
+ kind: 'style';
446
+ key: string;
447
+ value: Sequenced<StyleOp> | string | null;
448
+ existed: boolean;
449
+ };
450
+ type RangeStyleSnap = {
451
+ kind: 'range-style';
452
+ id: string;
453
+ instanceId: string;
454
+ value: Sequenced<TextRangeStyleOp> | null;
455
+ existed: boolean;
456
+ };
457
+ type TextSnap = {
458
+ kind: 'text';
459
+ instanceId: string;
460
+ value: string | null;
461
+ existed: boolean;
462
+ };
463
+ type AttrSnap = {
464
+ kind: 'attr';
465
+ attr: string;
466
+ value: Sequenced<AssetAttrOp> | string | null;
467
+ source: 'op' | 'orig' | 'dom-missing' | 'dom-present';
468
+ };
469
+ type Snap = StyleSnap | RangeStyleSnap | TextSnap | AttrSnap;
470
+
471
+ const snapshotForOps = useCallback(
472
+ (line: number, column: number, anchor: HTMLElement, ops: EditOp[]): Snap[] => {
473
+ const key = `${line}:${column}`;
474
+ const bucket = pendingRef.current.get(key);
475
+ const style = anchor.style as unknown as Record<string, string>;
476
+ const snaps: Snap[] = [];
477
+ for (const op of ops) {
478
+ if (op.kind === 'set-style') {
479
+ const existing = bucket?.styleOps.get(op.key);
480
+ if (existing) {
481
+ snaps.push({
482
+ kind: 'style',
483
+ key: op.key,
484
+ value: { ...existing },
485
+ existed: true,
486
+ });
487
+ } else {
488
+ snaps.push({
489
+ kind: 'style',
490
+ key: op.key,
491
+ value: style[op.key] ?? '',
492
+ existed: false,
493
+ });
494
+ }
495
+ } else if (op.kind === 'set-text-range-style') {
496
+ const instanceId = ensureInstanceId(anchor);
497
+ const id = rangeStyleKey(instanceId, op);
498
+ const existing = bucket?.rangeStyleOps.get(id);
499
+ snaps.push({
500
+ kind: 'range-style',
501
+ id,
502
+ instanceId,
503
+ value: existing ? { ...existing } : null,
504
+ existed: !!existing,
505
+ });
506
+ } else if (op.kind === 'set-text') {
507
+ const instanceId = ensureInstanceId(anchor);
508
+ const existing = bucket?.textOps.get(instanceId);
509
+ if (existing) {
510
+ snaps.push({ kind: 'text', instanceId, value: existing.value, existed: true });
511
+ } else {
512
+ snaps.push({
513
+ kind: 'text',
514
+ instanceId,
515
+ value: readEditableText(anchor),
516
+ existed: false,
517
+ });
518
+ }
519
+ } else if (op.kind === 'set-attr-asset') {
520
+ const prev = bucket?.attrOps.get(op.attr);
521
+ if (prev) {
522
+ snaps.push({ kind: 'attr', attr: op.attr, value: prev, source: 'op' });
523
+ } else if (bucket?.origAttrs.has(op.attr)) {
524
+ snaps.push({
525
+ kind: 'attr',
526
+ attr: op.attr,
527
+ value: bucket.origAttrs.get(op.attr) ?? null,
528
+ source: 'orig',
529
+ });
530
+ } else if (anchor.hasAttribute(op.attr)) {
531
+ snaps.push({
532
+ kind: 'attr',
533
+ attr: op.attr,
534
+ value: anchor.getAttribute(op.attr),
535
+ source: 'dom-present',
536
+ });
537
+ } else {
538
+ snaps.push({ kind: 'attr', attr: op.attr, value: null, source: 'dom-missing' });
539
+ }
540
+ }
541
+ }
542
+ return snaps;
543
+ },
544
+ [ensureInstanceId],
545
+ );
546
+
547
+ // Restore the snapshotted values to bucket + DOM. Mirrors the bucket-empty
548
+ // logic of `cancelEdits` so an undo back to the absolute baseline cleans up.
549
+ const restoreSnapshot = useCallback(
550
+ (line: number, column: number, snaps: Snap[]) => {
551
+ const key = `${line}:${column}`;
552
+ const bucket = pendingRef.current.get(key);
553
+ if (!bucket) return;
554
+ // Style/attr snaps share the loc-level anchor (first match);
555
+ // text snaps look up their per-instance node below.
556
+ const sharedAnchor = findAnchor(line, column);
557
+ const sharedStyle = (sharedAnchor?.style ?? {}) as unknown as Record<string, string>;
558
+ for (const snap of snaps) {
559
+ if (snap.kind === 'style') {
560
+ if (snap.existed) {
561
+ const prev =
562
+ typeof snap.value === 'object' && snap.value !== null
563
+ ? snap.value
564
+ : { value: snap.value };
565
+ const v = prev.value ?? '';
566
+ bucket.styleOps.set(snap.key, { ...prev, seq: ++pendingSeqRef.current });
567
+ if (sharedAnchor?.isConnected) sharedStyle[snap.key] = v;
568
+ } else {
569
+ bucket.styleOps.delete(snap.key);
570
+ const orig = bucket.origStyle.get(snap.key);
571
+ if (sharedAnchor?.isConnected) sharedStyle[snap.key] = orig ?? '';
572
+ }
573
+ } else if (snap.kind === 'range-style') {
574
+ const textAnchor = findAnchor(line, column, snap.instanceId);
575
+ if (snap.existed && snap.value) {
576
+ bucket.rangeStyleOps.set(snap.id, { ...snap.value, seq: ++pendingSeqRef.current });
577
+ } else {
578
+ bucket.rangeStyleOps.delete(snap.id);
579
+ }
580
+ const html = bucket.origHtmls.get(snap.instanceId);
581
+ if (textAnchor?.isConnected && html !== undefined) {
582
+ replayDomTextRangeStyles(
583
+ textAnchor,
584
+ html,
585
+ Array.from(bucket.rangeStyleOps.values()).filter(
586
+ (op) => op.instanceId === snap.instanceId,
587
+ ),
588
+ );
589
+ }
590
+ } else if (snap.kind === 'text') {
591
+ const textAnchor = findAnchor(line, column, snap.instanceId);
592
+ if (snap.existed) {
593
+ bucket.textOps.set(snap.instanceId, {
594
+ value: snap.value ?? '',
595
+ seq: ++pendingSeqRef.current,
596
+ });
597
+ if (textAnchor?.isConnected) setEditableText(textAnchor, snap.value ?? '');
598
+ } else {
599
+ bucket.textOps.delete(snap.instanceId);
600
+ const orig = bucket.origTexts.get(snap.instanceId);
601
+ if (textAnchor?.isConnected) setEditableText(textAnchor, orig?.value ?? '');
602
+ }
603
+ } else if (snap.kind === 'attr') {
604
+ if (snap.source === 'op') {
605
+ const op = snap.value as Sequenced<AssetAttrOp>;
606
+ bucket.attrOps.set(snap.attr, { ...op, seq: ++pendingSeqRef.current });
607
+ if (sharedAnchor?.isConnected) sharedAnchor.setAttribute(snap.attr, op.previewUrl);
608
+ } else {
609
+ bucket.attrOps.delete(snap.attr);
610
+ const orig = bucket.origAttrs.get(snap.attr);
611
+ if (sharedAnchor?.isConnected) {
612
+ if (orig === null || orig === undefined) sharedAnchor.removeAttribute(snap.attr);
613
+ else sharedAnchor.setAttribute(snap.attr, orig);
614
+ }
615
+ }
616
+ }
617
+ }
618
+ if (
619
+ bucket.styleOps.size === 0 &&
620
+ bucket.rangeStyleOps.size === 0 &&
621
+ bucket.textOps.size === 0 &&
622
+ bucket.attrOps.size === 0
623
+ ) {
624
+ pendingRef.current.delete(key);
625
+ }
626
+ refreshCount();
627
+ },
628
+ [findAnchor, refreshCount],
629
+ );
630
+
631
+ const bufferOps = useCallback(
632
+ (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => {
633
+ const instanceId = ops.some(
634
+ (op) => op.kind === 'set-text' || op.kind === 'set-text-range-style',
635
+ )
636
+ ? ensureInstanceId(anchor)
637
+ : undefined;
638
+ const snaps = snapshotForOps(line, column, anchor, ops);
639
+ applyOpsRaw(line, column, anchor, ops);
640
+ const first = ops[0];
641
+ const opKey = first
642
+ ? first.kind === 'set-style'
643
+ ? first.key
644
+ : first.kind === 'set-attr-asset'
645
+ ? first.attr
646
+ : 'text'
647
+ : 'noop';
648
+ const coalesceKey = `inspector:${line}:${column}:${first?.kind ?? 'noop'}:${opKey}`;
649
+ history.record({
650
+ coalesceKey,
651
+ undo: () => restoreSnapshot(line, column, snaps),
652
+ redo: () => applyOpsRaw(line, column, findAnchor(line, column, instanceId), ops),
653
+ });
654
+ },
655
+ [applyOpsRaw, snapshotForOps, restoreSnapshot, findAnchor, history, ensureInstanceId],
656
+ );
657
+
658
+ const commitEdits = useCallback(async () => {
659
+ const buckets = pendingRef.current;
660
+ if (buckets.size === 0) return;
661
+ type PendingItem = {
662
+ key: string;
663
+ seq: number;
664
+ edit: Edit;
665
+ onSuccess: (bucket: Bucket) => void;
666
+ };
667
+ const pending: PendingItem[] = [];
668
+ for (const [key, bucket] of buckets) {
669
+ const { line, column, styleOps, rangeStyleOps, textOps, attrOps, origTexts } = bucket;
670
+ for (const [k, op] of styleOps) {
671
+ pending.push({
672
+ key,
673
+ seq: op.seq,
674
+ edit: {
675
+ line,
676
+ column,
677
+ ops: [{ kind: 'set-style', key: k, value: op.value, prevText: op.prevText }],
678
+ },
679
+ onSuccess: (b) => {
680
+ b.styleOps.delete(k);
681
+ },
682
+ });
683
+ }
684
+ for (const [attr, op] of attrOps) {
685
+ pending.push({
686
+ key,
687
+ seq: op.seq,
688
+ edit: {
689
+ line,
690
+ column,
691
+ ops: [
692
+ {
693
+ kind: 'set-attr-asset',
694
+ attr,
695
+ assetPath: op.assetPath,
696
+ previewUrl: op.previewUrl,
697
+ },
698
+ ],
699
+ },
700
+ onSuccess: (b) => {
701
+ b.attrOps.delete(attr);
702
+ },
703
+ });
704
+ }
705
+ for (const [id, op] of rangeStyleOps) {
706
+ pending.push({
707
+ key,
708
+ seq: op.seq,
709
+ edit: {
710
+ line,
711
+ column,
712
+ ops: [
713
+ {
714
+ kind: 'set-text-range-style',
715
+ start: op.start,
716
+ end: op.end,
717
+ key: op.key,
718
+ value: op.value,
719
+ prevText: op.prevText,
720
+ },
721
+ ],
722
+ },
723
+ onSuccess: (b) => {
724
+ b.rangeStyleOps.delete(id);
725
+ },
726
+ });
727
+ }
728
+ // Per-instance text edits — one Edit per call site, each with its
729
+ // own prevText so the server can disambiguate among siblings.
730
+ for (const [instanceId, textOp] of textOps) {
731
+ const orig = origTexts.get(instanceId);
732
+ pending.push({
733
+ key,
734
+ seq: textOp.seq,
735
+ edit: {
736
+ line,
737
+ column,
738
+ ops: [{ kind: 'set-text', value: textOp.value, prevText: orig?.value }],
739
+ },
740
+ onSuccess: (b) => {
741
+ b.textOps.delete(instanceId);
742
+ },
743
+ });
744
+ }
745
+ }
746
+ pending.sort((a, b) => a.seq - b.seq);
747
+ if (pending.length === 0) {
748
+ pendingRef.current = new Map();
749
+ setPendingCount(0);
750
+ history.clear();
751
+ return;
752
+ }
753
+ setCommitting(true);
754
+ try {
755
+ const results = await applyEdits(pending.map((p) => p.edit));
756
+ const failures: string[] = [];
757
+ for (let i = 0; i < results.length; i++) {
758
+ const item = pending[i];
759
+ const r = results[i];
760
+ const bucket = pendingRef.current.get(item.key);
761
+ if (r.ok) {
762
+ if (bucket) {
763
+ item.onSuccess(bucket);
764
+ if (
765
+ bucket.styleOps.size === 0 &&
766
+ bucket.rangeStyleOps.size === 0 &&
767
+ bucket.textOps.size === 0 &&
768
+ bucket.attrOps.size === 0
769
+ ) {
770
+ pendingRef.current.delete(item.key);
771
+ }
772
+ }
773
+ } else {
774
+ failures.push(`line ${item.edit.line}: ${r.error ?? 'edit failed'}`);
775
+ }
776
+ }
777
+ refreshCount();
778
+ if (failures.length > 0) toast.error(`${t.inspector.saveFailed} ${failures.join('; ')}`);
779
+ } catch (err) {
780
+ const msg = err instanceof Error ? err.message : String(err);
781
+ toast.error(`${t.inspector.saveFailed} ${msg}`);
782
+ throw err;
783
+ } finally {
784
+ setCommitting(false);
785
+ history.clear();
786
+ }
787
+ }, [applyEdits, history, refreshCount, t]);
788
+
789
+ const cancelEdits = useCallback(() => {
790
+ if (pendingRef.current.size === 0) {
791
+ history.clear();
792
+ return;
793
+ }
794
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
795
+ for (const b of pendingRef.current.values()) {
796
+ const sharedEl = root?.querySelector<HTMLElement>(`[data-slide-loc="${b.line}:${b.column}"]`);
797
+ if (sharedEl) {
798
+ const style = sharedEl.style as unknown as Record<string, string>;
799
+ for (const [k, v] of b.origStyle) style[k] = v;
800
+ for (const [attr, value] of b.origAttrs) {
801
+ if (value === null) sharedEl.removeAttribute(attr);
802
+ else sharedEl.setAttribute(attr, value);
803
+ }
804
+ }
805
+ // Each text edit has its own anchor — locate by instance id.
806
+ for (const [instanceId, html] of b.origHtmls) {
807
+ const textEl =
808
+ root?.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`) ?? null;
809
+ if (textEl?.isConnected) textEl.innerHTML = html;
810
+ }
811
+ for (const [instanceId, orig] of b.origTexts) {
812
+ const textEl =
813
+ root?.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`) ?? null;
814
+ if (textEl?.isConnected) setEditableText(textEl, orig.value);
815
+ }
816
+ }
817
+ pendingRef.current = new Map();
818
+ setPendingCount(0);
819
+ history.clear();
820
+ }, [history]);
821
+
822
+ // Auto-flush on inspector close and on route unmount so toggling
823
+ // off or navigating away doesn't drop buffered edits. Failures are
824
+ // surfaced via toast inside `commitEdits`; the catch here only
825
+ // swallows the rethrown rejection.
826
+ const commitRef = useRef(commitEdits);
827
+ commitRef.current = commitEdits;
828
+ useEffect(() => {
829
+ if (!active) commitRef.current().catch(() => {});
830
+ }, [active]);
831
+ useEffect(() => {
832
+ return () => {
833
+ commitRef.current().catch(() => {});
834
+ };
835
+ }, []);
836
+
837
+ // Re-apply buffered ops onto any `[data-slide-loc]` element that gets
838
+ // (re)mounted in the slide canvas. Without this, navigating to a
839
+ // different page and back drops the optimistic styles, since the
840
+ // page's DOM nodes are torn down on unmount even though the buffer
841
+ // (keyed by source line:col) survives.
842
+ useEffect(() => {
843
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
844
+ if (!root) return;
845
+
846
+ const applyBuffered = (el: HTMLElement) => {
847
+ const loc = el.dataset.slideLoc;
848
+ if (!loc) return;
849
+ const bucket = pendingRef.current.get(loc);
850
+ if (!bucket) return;
851
+ const style = el.style as unknown as Record<string, string>;
852
+ for (const [key, op] of bucket.styleOps) {
853
+ const v = op.value ?? '';
854
+ if (style[key] !== v) style[key] = v;
855
+ }
856
+ // Text replays per-instance: only the originally clicked DOM node
857
+ // (stamped with its `data-slide-instance-id`) gets the buffered
858
+ // value, so siblings of a reused component aren't clobbered.
859
+ const instanceId = readInstanceId(el);
860
+ if (instanceId) {
861
+ const html = bucket.origHtmls.get(instanceId);
862
+ if (html !== undefined) {
863
+ replayDomTextRangeStyles(
864
+ el,
865
+ html,
866
+ Array.from(bucket.rangeStyleOps.values()).filter((op) => op.instanceId === instanceId),
867
+ );
868
+ }
869
+ const textOp = bucket.textOps.get(instanceId);
870
+ if (textOp && readEditableText(el) !== textOp.value) {
871
+ setEditableText(el, textOp.value);
872
+ }
873
+ }
874
+ for (const [attr, op] of bucket.attrOps) {
875
+ if (el.getAttribute(attr) !== op.previewUrl) el.setAttribute(attr, op.previewUrl);
876
+ }
877
+ };
878
+
879
+ let observer: MutationObserver | null = null;
880
+ const replayAll = () => {
881
+ if (pendingRef.current.size === 0) return;
882
+ observer?.disconnect();
883
+ root.querySelectorAll<HTMLElement>('[data-slide-loc]').forEach(applyBuffered);
884
+ observer?.observe(root, { childList: true, subtree: true });
885
+ };
886
+
887
+ replayAll();
888
+ observer = new MutationObserver(replayAll);
889
+ observer.observe(root, { childList: true, subtree: true });
890
+ return () => observer?.disconnect();
891
+ }, []);
892
+
893
+ useEffect(() => {
894
+ void pageIndex;
895
+ setSelected(null);
896
+ }, [pageIndex]);
897
+
898
+ // Never clear `selected` on a miss: the observer can fire between an
899
+ // "old removed" and "new added" mutation batch, and clearing then would
900
+ // drop a selection that's about to reattach on the next fire.
901
+ useEffect(() => {
902
+ if (!selected) return;
903
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
904
+ if (!root) return;
905
+
906
+ const revalidate = () => {
907
+ if (selected.anchor.isConnected) return;
908
+ const next = root.querySelector<HTMLElement>(
909
+ `[data-slide-loc="${selected.line}:${selected.column}"]`,
910
+ );
911
+ if (next && next !== selected.anchor) {
912
+ setSelected({ ...selected, anchor: next });
913
+ }
914
+ };
915
+
916
+ revalidate();
917
+ const observer = new MutationObserver(revalidate);
918
+ observer.observe(root, { childList: true, subtree: true });
919
+ return () => observer.disconnect();
920
+ }, [selected]);
921
+
922
+ const activate = useCallback(() => {
923
+ manualActiveRef.current = true;
924
+ setManualActive(true);
925
+ }, []);
926
+
927
+ const toggle = useCallback(() => {
928
+ setManualActive((a) => {
929
+ if (a && !heldActive) setSelected(null);
930
+ const next = !a;
931
+ manualActiveRef.current = next;
932
+ return next;
933
+ });
934
+ }, [heldActive]);
935
+
936
+ const cancel = useCallback(() => {
937
+ manualActiveRef.current = false;
938
+ setManualActive(false);
939
+ setHeldActive(false);
940
+ setSelected(null);
941
+ }, []);
942
+
943
+ const openReplace = useCallback((anchor: HTMLElement) => {
944
+ const loc = anchor.dataset.slideLoc;
945
+ if (!loc) return;
946
+ const [lineStr, columnStr] = loc.split(':');
947
+ const line = Number(lineStr);
948
+ const column = Number(columnStr);
949
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
950
+ setReplaceTarget({ line, column, anchor });
951
+ }, []);
952
+
953
+ useEffect(() => {
954
+ if (import.meta.env.PROD) return;
955
+ const onKey = (e: KeyboardEvent) => {
956
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
957
+ if (e.key !== 'i' && e.key !== 'I') return;
958
+ toggle();
959
+ };
960
+ window.addEventListener('keydown', onKey);
961
+ return () => window.removeEventListener('keydown', onKey);
962
+ }, [toggle]);
963
+
964
+ useEffect(() => {
965
+ if (import.meta.env.PROD) return;
966
+
967
+ const isApplePlatform = /Mac|iPhone|iPad|iPod/.test(
968
+ `${navigator.platform} ${navigator.userAgent}`,
969
+ );
970
+ const holdKey = isApplePlatform ? 'Meta' : 'Control';
971
+
972
+ const onKeyDown = (e: KeyboardEvent) => {
973
+ if (e.repeat) return;
974
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
975
+ if (e.key !== holdKey) return;
976
+ setHeldActive(true);
977
+ };
978
+
979
+ const releaseHeld = (e?: KeyboardEvent) => {
980
+ if (e && e.key !== holdKey) return;
981
+ setHeldActive(false);
982
+ if (!manualActiveRef.current) setSelected(null);
983
+ };
984
+
985
+ const onBlur = () => releaseHeld();
986
+
987
+ window.addEventListener('keydown', onKeyDown, true);
988
+ window.addEventListener('keyup', releaseHeld, true);
989
+ window.addEventListener('blur', onBlur, true);
990
+ return () => {
991
+ window.removeEventListener('keydown', onKeyDown, true);
992
+ window.removeEventListener('keyup', releaseHeld, true);
993
+ window.removeEventListener('blur', onBlur, true);
994
+ };
995
+ }, []);
996
+
997
+ const openCrop = useCallback((anchor: HTMLImageElement) => {
998
+ const loc = anchor.dataset.slideLoc;
999
+ if (!loc) return;
1000
+ const [lineStr, columnStr] = loc.split(':');
1001
+ const line = Number(lineStr);
1002
+ const column = Number(columnStr);
1003
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
1004
+ const cs = window.getComputedStyle(anchor);
1005
+ setCropTarget({
1006
+ line,
1007
+ column,
1008
+ anchor,
1009
+ src: anchor.currentSrc || anchor.src,
1010
+ targetWidth: anchor.offsetWidth || anchor.getBoundingClientRect().width,
1011
+ targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
1012
+ initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
1013
+ initialPosition: parseObjectPosition(cs.objectPosition),
1014
+ initialRect: parseObjectViewBox(cs.getPropertyValue('object-view-box')),
1015
+ });
1016
+ }, []);
1017
+
1018
+ const value = useMemo<InspectorCtx>(
1019
+ () => ({
1020
+ slideId,
1021
+ active,
1022
+ activate,
1023
+ toggle,
1024
+ cancel,
1025
+ comments,
1026
+ error,
1027
+ refetch,
1028
+ add,
1029
+ remove,
1030
+ selected,
1031
+ setSelected,
1032
+ applyEdit,
1033
+ applyEdits,
1034
+ bufferOps,
1035
+ pendingCount,
1036
+ commitEdits,
1037
+ cancelEdits,
1038
+ committing,
1039
+ openCrop,
1040
+ openReplace,
1041
+ }),
1042
+ [
1043
+ slideId,
1044
+ active,
1045
+ activate,
1046
+ toggle,
1047
+ cancel,
1048
+ comments,
1049
+ error,
1050
+ refetch,
1051
+ add,
1052
+ remove,
1053
+ selected,
1054
+ applyEdit,
1055
+ applyEdits,
1056
+ bufferOps,
1057
+ pendingCount,
1058
+ commitEdits,
1059
+ cancelEdits,
1060
+ committing,
1061
+ openCrop,
1062
+ openReplace,
1063
+ ],
1064
+ );
1065
+
1066
+ return (
1067
+ <Ctx.Provider value={value}>
1068
+ {children}
1069
+ {replaceTarget && (
1070
+ <AssetPickerDialog
1071
+ slideId={slideId}
1072
+ onClose={() => setReplaceTarget(null)}
1073
+ onPick={(asset, scope) => {
1074
+ const { line, column, anchor } = replaceTarget;
1075
+ const assetPath =
1076
+ scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
1077
+ const ops: EditOp[] = [
1078
+ {
1079
+ kind: 'set-attr-asset',
1080
+ attr: 'src',
1081
+ assetPath,
1082
+ previewUrl: asset.url,
1083
+ },
1084
+ ];
1085
+ if (anchor.tagName === 'IMG' && anchor.isConnected) {
1086
+ const cs = window.getComputedStyle(anchor);
1087
+ if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
1088
+ ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
1089
+ }
1090
+ const op = cs.objectPosition.trim();
1091
+ if (!op || op === '0% 0%' || op === 'auto') {
1092
+ ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
1093
+ }
1094
+ }
1095
+ bufferOps(line, column, anchor, ops);
1096
+ setReplaceTarget(null);
1097
+ }}
1098
+ />
1099
+ )}
1100
+ {cropTarget && (
1101
+ <ImageCropDialog
1102
+ src={cropTarget.src}
1103
+ targetWidth={cropTarget.targetWidth}
1104
+ targetHeight={cropTarget.targetHeight}
1105
+ initialFit={cropTarget.initialFit}
1106
+ initialPosition={cropTarget.initialPosition}
1107
+ initialRect={cropTarget.initialRect}
1108
+ onClose={() => setCropTarget(null)}
1109
+ onApply={(result) => {
1110
+ const { line, column, anchor } = cropTarget;
1111
+ if (anchor.isConnected) {
1112
+ const ops: EditOp[] = [
1113
+ { kind: 'set-style', key: 'objectFit', value: result.fit },
1114
+ { kind: 'set-style', key: 'objectPosition', value: '50% 50%' },
1115
+ ];
1116
+ if (result.fit === 'cover') {
1117
+ const { x, y, width, height } = result.rect;
1118
+ const top = round2(y);
1119
+ const left = round2(x);
1120
+ const right = round2(100 - x - width);
1121
+ const bottom = round2(100 - y - height);
1122
+ ops.push({
1123
+ kind: 'set-style',
1124
+ key: 'objectViewBox',
1125
+ value: `inset(${top}% ${right}% ${bottom}% ${left}%)`,
1126
+ });
1127
+ } else {
1128
+ ops.push({ kind: 'set-style', key: 'objectViewBox', value: null });
1129
+ }
1130
+ bufferOps(line, column, anchor, ops);
1131
+ }
1132
+ setCropTarget(null);
1133
+ }}
1134
+ />
1135
+ )}
1136
+ </Ctx.Provider>
1137
+ );
1138
+ }
1139
+
1140
+ function round2(n: number): number {
1141
+ return Math.round(n * 100) / 100;
1142
+ }
1143
+
1144
+ function parseObjectViewBox(value: string): ImageCropRect | null {
1145
+ const v = value?.trim();
1146
+ if (!v || v === 'none') return null;
1147
+ const m = v.match(/^inset\(([^)]+)\)$/);
1148
+ if (!m?.[1]) return null;
1149
+ const nums = m[1]
1150
+ .trim()
1151
+ .split(/\s+/)
1152
+ .map((p) => {
1153
+ const n = p.match(/^(-?\d+(?:\.\d+)?)%$/);
1154
+ return n?.[1] ? Number(n[1]) : null;
1155
+ });
1156
+ if (nums.some((n) => n === null)) return null;
1157
+ let top: number, right: number, bottom: number, left: number;
1158
+ if (nums.length === 1) {
1159
+ top = right = bottom = left = nums[0] as number;
1160
+ } else if (nums.length === 2) {
1161
+ top = bottom = nums[0] as number;
1162
+ right = left = nums[1] as number;
1163
+ } else if (nums.length === 3) {
1164
+ top = nums[0] as number;
1165
+ right = left = nums[1] as number;
1166
+ bottom = nums[2] as number;
1167
+ } else if (nums.length === 4) {
1168
+ top = nums[0] as number;
1169
+ right = nums[1] as number;
1170
+ bottom = nums[2] as number;
1171
+ left = nums[3] as number;
1172
+ } else {
1173
+ return null;
1174
+ }
1175
+ const x = left;
1176
+ const y = top;
1177
+ const width = 100 - left - right;
1178
+ const height = 100 - top - bottom;
1179
+ if (width <= 0 || height <= 0) return null;
1180
+ return { x, y, width, height };
1181
+ }
1182
+
1183
+ function parseObjectPosition(value: string): { x: number; y: number } {
1184
+ const parts = value.trim().split(/\s+/);
1185
+ const xRaw = parts[0] ?? '50%';
1186
+ const yRaw = parts[1] ?? xRaw;
1187
+ return { x: parsePercent(xRaw, 50), y: parsePercent(yRaw, 50) };
1188
+ }
1189
+
1190
+ function parsePercent(s: string, fallback: number): number {
1191
+ if (s === 'center') return 50;
1192
+ if (s === 'left' || s === 'top') return 0;
1193
+ if (s === 'right' || s === 'bottom') return 100;
1194
+ const m = s.match(/(-?\d+(?:\.\d+)?)%/);
1195
+ if (m?.[1]) return Number(m[1]);
1196
+ return fallback;
1197
+ }
1198
+
1199
+ export function InspectToggleButton() {
1200
+ const t = useLocale();
1201
+ const { active, toggle } = useInspector();
1202
+ if (import.meta.env.PROD) return null;
1203
+ return (
1204
+ <Button
1205
+ size="sm"
1206
+ variant={active ? 'default' : 'ghost'}
1207
+ onClick={toggle}
1208
+ data-inspector-ui
1209
+ title={t.inspector.inspect}
1210
+ >
1211
+ <Crosshair className="size-3.5" />
1212
+ <span className="hidden md:inline">{t.inspector.inspect}</span>
1213
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
1214
+ I
1215
+ </kbd>
1216
+ </Button>
1217
+ );
1218
+ }