@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,212 @@
1
+ import { type SyntheticEvent, useEffect, useRef, useState } from 'react';
2
+ import ReactCrop, { type Crop, type PercentCrop } from 'react-image-crop';
3
+ import 'react-image-crop/dist/ReactCrop.css';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@/components/ui/dialog';
13
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
14
+ import { useLocale } from '@/lib/use-locale';
15
+
16
+ export type ImageCropRect = { x: number; y: number; width: number; height: number };
17
+
18
+ export type ImageCropResult = { fit: 'contain' } | { fit: 'cover'; rect: ImageCropRect };
19
+
20
+ export function ImageCropDialog({
21
+ src,
22
+ targetWidth,
23
+ targetHeight,
24
+ initialFit,
25
+ initialPosition,
26
+ initialRect,
27
+ onClose,
28
+ onApply,
29
+ }: {
30
+ src: string;
31
+ targetWidth: number;
32
+ targetHeight: number;
33
+ initialFit: 'cover' | 'contain';
34
+ initialPosition: { x: number; y: number };
35
+ initialRect: ImageCropRect | null;
36
+ onClose: () => void;
37
+ onApply: (result: ImageCropResult) => void;
38
+ }) {
39
+ const t = useLocale();
40
+ const [fit, setFit] = useState<'cover' | 'contain'>(initialFit);
41
+ const aspect = targetWidth > 0 && targetHeight > 0 ? targetWidth / targetHeight : 1;
42
+ const [crop, setCrop] = useState<Crop | undefined>(undefined);
43
+ const imgRef = useRef<HTMLImageElement>(null);
44
+
45
+ const onImageLoad = (e: SyntheticEvent<HTMLImageElement>) => {
46
+ const im = e.currentTarget;
47
+ setCrop(initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition));
48
+ };
49
+
50
+ useEffect(() => {
51
+ const im = imgRef.current;
52
+ if (!im?.complete || !im.naturalWidth || !im.naturalHeight) return;
53
+ setCrop((prev) => {
54
+ if (prev && prev.unit === '%') {
55
+ return clampToAspect(prev as PercentCrop, aspect, im.naturalWidth, im.naturalHeight);
56
+ }
57
+ return initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition);
58
+ });
59
+ }, [aspect, initialPosition, initialRect]);
60
+
61
+ const onApplyClick = () => {
62
+ if (fit === 'contain') {
63
+ onApply({ fit });
64
+ return;
65
+ }
66
+ const rect =
67
+ crop && crop.unit === '%'
68
+ ? roundRect(crop as PercentCrop)
69
+ : { x: 0, y: 0, width: 100, height: 100 };
70
+ onApply({ fit, rect });
71
+ };
72
+
73
+ return (
74
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
75
+ <DialogContent className="sm:max-w-2xl">
76
+ <DialogHeader>
77
+ <DialogTitle>{t.inspector.cropDialogTitle}</DialogTitle>
78
+ <DialogDescription>{t.inspector.cropDialogDescription}</DialogDescription>
79
+ </DialogHeader>
80
+ <div className="flex justify-center">
81
+ <ToggleGroup
82
+ type="single"
83
+ value={fit}
84
+ onValueChange={(v) => {
85
+ if (v === 'cover' || v === 'contain') setFit(v);
86
+ }}
87
+ variant="outline"
88
+ size="sm"
89
+ >
90
+ <ToggleGroupItem value="cover" className="text-xs">
91
+ {t.inspector.cropFitCover}
92
+ </ToggleGroupItem>
93
+ <ToggleGroupItem value="contain" className="text-xs">
94
+ {t.inspector.cropFitContain}
95
+ </ToggleGroupItem>
96
+ </ToggleGroup>
97
+ </div>
98
+ <div className="flex h-[420px] w-full items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
99
+ {fit === 'cover' ? (
100
+ <ReactCrop
101
+ crop={crop}
102
+ onChange={(_, percentCrop) => setCrop(percentCrop)}
103
+ aspect={aspect}
104
+ keepSelection
105
+ className="max-h-full"
106
+ >
107
+ <img
108
+ ref={imgRef}
109
+ src={src}
110
+ alt=""
111
+ style={{ maxHeight: 420, maxWidth: '100%' }}
112
+ onLoad={onImageLoad}
113
+ />
114
+ </ReactCrop>
115
+ ) : (
116
+ <img src={src} alt="" className="max-h-full max-w-full object-contain" />
117
+ )}
118
+ </div>
119
+ <DialogFooter>
120
+ <Button variant="outline" onClick={onClose}>
121
+ {t.common.cancel}
122
+ </Button>
123
+ <Button onClick={onApplyClick}>{t.inspector.cropApply}</Button>
124
+ </DialogFooter>
125
+ </DialogContent>
126
+ </Dialog>
127
+ );
128
+ }
129
+
130
+ function initialCrop(
131
+ naturalW: number,
132
+ naturalH: number,
133
+ aspect: number,
134
+ rect: ImageCropRect | null,
135
+ position: { x: number; y: number },
136
+ ): PercentCrop {
137
+ if (rect) {
138
+ return clampToAspect({ unit: '%', ...rect }, aspect, naturalW, naturalH);
139
+ }
140
+ return makeMaxSizeCrop(naturalW, naturalH, aspect, position);
141
+ }
142
+
143
+ function makeMaxSizeCrop(
144
+ naturalW: number,
145
+ naturalH: number,
146
+ aspect: number,
147
+ position: { x: number; y: number },
148
+ ): PercentCrop {
149
+ if (naturalW <= 0 || naturalH <= 0) {
150
+ return { unit: '%', x: 0, y: 0, width: 100, height: 100 };
151
+ }
152
+ const sourceAspect = naturalW / naturalH;
153
+ let width = 100;
154
+ let height = 100;
155
+ if (aspect >= sourceAspect) {
156
+ width = 100;
157
+ height = (sourceAspect / aspect) * 100;
158
+ } else {
159
+ height = 100;
160
+ width = (aspect / sourceAspect) * 100;
161
+ }
162
+ const slackX = 100 - width;
163
+ const slackY = 100 - height;
164
+ const x = clamp((position.x / 100) * slackX, 0, slackX);
165
+ const y = clamp((position.y / 100) * slackY, 0, slackY);
166
+ return { unit: '%', x, y, width, height };
167
+ }
168
+
169
+ function clampToAspect(
170
+ crop: PercentCrop,
171
+ aspect: number,
172
+ naturalW: number,
173
+ naturalH: number,
174
+ ): PercentCrop {
175
+ const cx = crop.x + crop.width / 2;
176
+ const cy = crop.y + crop.height / 2;
177
+ let width = crop.width;
178
+ let height = crop.height;
179
+ const targetPctRatio = naturalW > 0 && naturalH > 0 ? (aspect * naturalH) / naturalW : aspect;
180
+ const currentRatio = height > 0 ? width / height : targetPctRatio;
181
+ if (Math.abs(currentRatio - targetPctRatio) > 0.0001) {
182
+ if (currentRatio > targetPctRatio) {
183
+ height = width / targetPctRatio;
184
+ } else {
185
+ width = height * targetPctRatio;
186
+ }
187
+ }
188
+ width = clamp(width, 1, 100);
189
+ height = clamp(height, 1, 100);
190
+ let x = cx - width / 2;
191
+ let y = cy - height / 2;
192
+ x = clamp(x, 0, 100 - width);
193
+ y = clamp(y, 0, 100 - height);
194
+ return { unit: '%', x, y, width, height };
195
+ }
196
+
197
+ function roundRect(crop: PercentCrop): ImageCropRect {
198
+ return {
199
+ x: round2(crop.x),
200
+ y: round2(crop.y),
201
+ width: round2(crop.width),
202
+ height: round2(crop.height),
203
+ };
204
+ }
205
+
206
+ function clamp(v: number, lo: number, hi: number) {
207
+ return v < lo ? lo : v > hi ? hi : v;
208
+ }
209
+
210
+ function round2(n: number): number {
211
+ return Math.round(n * 100) / 100;
212
+ }
@@ -0,0 +1,387 @@
1
+ import { Crop, ImageIcon } from 'lucide-react';
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell';
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
5
+ import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
6
+ import { useLocale } from '@/lib/use-locale';
7
+ import { cn } from '@/lib/utils';
8
+ import { useInspector } from './inspector-provider';
9
+
10
+ type Highlight = { hit: SlideSourceHit };
11
+
12
+ type RelRect = { left: number; top: number; width: number; height: number };
13
+
14
+ const FRAME_FADE_MS = 150;
15
+ const FRAME_MORPH_MS = 180;
16
+ const LAYOUT_TRACK_MS = PANEL_TRANSITION_MS + FRAME_MORPH_MS;
17
+
18
+ export function InspectOverlay() {
19
+ const { active, activate, slideId, selected, setSelected, cancel, openCrop } = useInspector();
20
+ const overlayRef = useRef<HTMLDivElement>(null);
21
+ const [hover, setHover] = useState<Highlight | null>(null);
22
+
23
+ useEffect(() => {
24
+ const onKey = (e: KeyboardEvent) => {
25
+ if (!active) return;
26
+ if (e.key === 'Escape') {
27
+ e.preventDefault();
28
+ e.stopPropagation();
29
+ cancel();
30
+ }
31
+ };
32
+
33
+ const onMove = (e: PointerEvent) => {
34
+ if (!active) return;
35
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) {
36
+ return setHover(null);
37
+ }
38
+ const hit = findInspectorHitAtPoint(e.clientX, e.clientY, slideId);
39
+ if (!hit) return setHover(null);
40
+ setHover({ hit });
41
+ };
42
+
43
+ const onMouseDown = (e: MouseEvent) => {
44
+ if (!active && e.detail < 2) return;
45
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
46
+ const hit = findInspectorHitAtPoint(e.clientX, e.clientY, slideId);
47
+ if (!hit) return;
48
+ e.preventDefault();
49
+ };
50
+
51
+ const onClick = (e: MouseEvent) => {
52
+ if (!active) return;
53
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
54
+ const hit = findInspectorHitAtPoint(e.clientX, e.clientY, slideId);
55
+ if (!hit) return;
56
+ e.preventDefault();
57
+ e.stopPropagation();
58
+ clearTextSelection();
59
+ activate();
60
+ setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
61
+ setHover({ hit });
62
+ };
63
+
64
+ const onDblClick = (e: MouseEvent) => {
65
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
66
+ const hit = findInspectorHitAtPoint(e.clientX, e.clientY, slideId);
67
+ if (!hit) return;
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ clearTextSelection();
71
+ if (!active) activate();
72
+ setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
73
+ setHover({ hit });
74
+ if (active && hit.anchor instanceof HTMLImageElement) openCrop(hit.anchor);
75
+ };
76
+
77
+ window.addEventListener('pointermove', onMove, true);
78
+ window.addEventListener('mousedown', onMouseDown, true);
79
+ window.addEventListener('click', onClick, true);
80
+ window.addEventListener('dblclick', onDblClick, true);
81
+ window.addEventListener('keydown', onKey, true);
82
+ return () => {
83
+ window.removeEventListener('pointermove', onMove, true);
84
+ window.removeEventListener('mousedown', onMouseDown, true);
85
+ window.removeEventListener('click', onClick, true);
86
+ window.removeEventListener('dblclick', onDblClick, true);
87
+ window.removeEventListener('keydown', onKey, true);
88
+ };
89
+ }, [active, activate, slideId, setSelected, cancel, openCrop]);
90
+
91
+ useEffect(() => {
92
+ if (!active) setHover(null);
93
+ }, [active]);
94
+
95
+ const hoverAnchor = hover?.hit.anchor.isConnected ? hover.hit.anchor : null;
96
+ const selectedAnchor = selected?.anchor.isConnected ? selected.anchor : null;
97
+ const dedupedHover = hoverAnchor && hoverAnchor !== selectedAnchor ? hoverAnchor : null;
98
+
99
+ if (!active) return null;
100
+ return (
101
+ <div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
102
+ <Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" showImageActions />
103
+ <Frame anchor={dedupedHover} overlayRef={overlayRef} variant="hover" />
104
+ </div>
105
+ );
106
+ }
107
+
108
+ type FrameVariant = 'selected' | 'hover';
109
+
110
+ const FRAME_STYLES: Record<FrameVariant, React.CSSProperties> = {
111
+ selected: { outline: '2px solid #3b82f6', background: 'rgba(59,130,246,0.1)' },
112
+ hover: { outline: '1.5px dashed #3b82f6', background: 'rgba(59,130,246,0.05)' },
113
+ };
114
+
115
+ function Frame({
116
+ anchor,
117
+ overlayRef,
118
+ variant,
119
+ showImageActions = false,
120
+ }: {
121
+ anchor: HTMLElement | null;
122
+ overlayRef: React.RefObject<HTMLDivElement>;
123
+ variant: FrameVariant;
124
+ showImageActions?: boolean;
125
+ }) {
126
+ const [rect, setRect] = useState<RelRect | null>(null);
127
+ const [hasTarget, setHasTarget] = useState(false);
128
+
129
+ const measure = useCallback(() => {
130
+ const overlay = overlayRef.current;
131
+ if (!anchor?.isConnected || !overlay) {
132
+ setHasTarget(false);
133
+ return;
134
+ }
135
+
136
+ const targetRect = anchor.getBoundingClientRect();
137
+ const overlayRect = overlay.getBoundingClientRect();
138
+ const next = {
139
+ left: targetRect.left - overlayRect.left,
140
+ top: targetRect.top - overlayRect.top,
141
+ width: targetRect.width,
142
+ height: targetRect.height,
143
+ };
144
+
145
+ setHasTarget(true);
146
+ setRect((prev) => (sameRect(prev, next) ? prev : next));
147
+ }, [overlayRef, anchor]);
148
+
149
+ useLayoutEffect(() => {
150
+ measure();
151
+ }, [measure]);
152
+
153
+ useEffect(() => {
154
+ if (!anchor) {
155
+ setHasTarget(false);
156
+ return;
157
+ }
158
+
159
+ let scheduled = 0;
160
+ let tracking = 0;
161
+ const scheduleMeasure = () => {
162
+ cancelAnimationFrame(scheduled);
163
+ scheduled = requestAnimationFrame(measure);
164
+ };
165
+
166
+ const resizeObserver = new ResizeObserver(scheduleMeasure);
167
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
168
+ if (root) resizeObserver.observe(root);
169
+ if (overlayRef.current) resizeObserver.observe(overlayRef.current);
170
+ resizeObserver.observe(anchor);
171
+
172
+ const stopAt = performance.now() + LAYOUT_TRACK_MS;
173
+ const trackPanelTransition = () => {
174
+ measure();
175
+ if (performance.now() < stopAt) tracking = requestAnimationFrame(trackPanelTransition);
176
+ };
177
+ tracking = requestAnimationFrame(trackPanelTransition);
178
+
179
+ window.addEventListener('resize', scheduleMeasure, true);
180
+ window.addEventListener('scroll', scheduleMeasure, true);
181
+ return () => {
182
+ resizeObserver.disconnect();
183
+ cancelAnimationFrame(scheduled);
184
+ cancelAnimationFrame(tracking);
185
+ window.removeEventListener('resize', scheduleMeasure, true);
186
+ window.removeEventListener('scroll', scheduleMeasure, true);
187
+ };
188
+ }, [measure, overlayRef, anchor]);
189
+
190
+ const visible = !!(hasTarget && rect);
191
+
192
+ // First render after appearing: snap to the new rect (no transition).
193
+ // Subsequent rect changes in the same visible session: animate.
194
+ const [morph, setMorph] = useState(false);
195
+ useLayoutEffect(() => {
196
+ if (visible) {
197
+ setMorph(true);
198
+ return;
199
+ }
200
+ const t = setTimeout(() => setMorph(false), FRAME_FADE_MS);
201
+ return () => clearTimeout(t);
202
+ }, [visible]);
203
+
204
+ if (!rect) return null;
205
+ const transition = morph
206
+ ? `left ${FRAME_MORPH_MS}ms ease-out, top ${FRAME_MORPH_MS}ms ease-out, ` +
207
+ `width ${FRAME_MORPH_MS}ms ease-out, height ${FRAME_MORPH_MS}ms ease-out, ` +
208
+ `opacity ${FRAME_FADE_MS}ms ease-out`
209
+ : `opacity ${FRAME_FADE_MS}ms ease-out`;
210
+
211
+ const imageAnchor = anchor instanceof HTMLImageElement ? anchor : null;
212
+ const actionsVisible = showImageActions && visible && !!imageAnchor;
213
+
214
+ return (
215
+ <>
216
+ <div
217
+ className="absolute"
218
+ style={{
219
+ left: rect.left,
220
+ top: rect.top,
221
+ width: rect.width,
222
+ height: rect.height,
223
+ opacity: visible ? 1 : 0,
224
+ transition,
225
+ ...FRAME_STYLES[variant],
226
+ }}
227
+ />
228
+ {showImageActions && imageAnchor && (
229
+ <ImageActionPanel
230
+ anchor={imageAnchor}
231
+ rect={rect}
232
+ visible={actionsVisible}
233
+ transition={transition}
234
+ />
235
+ )}
236
+ </>
237
+ );
238
+ }
239
+
240
+ const FLOATING_PANEL_GAP = 8;
241
+
242
+ function ImageActionPanel({
243
+ anchor,
244
+ rect,
245
+ visible,
246
+ transition,
247
+ }: {
248
+ anchor: HTMLElement;
249
+ rect: RelRect;
250
+ visible: boolean;
251
+ transition: string;
252
+ }) {
253
+ const { openCrop, openReplace } = useInspector();
254
+ const t = useLocale();
255
+ return (
256
+ <TooltipProvider delayDuration={200}>
257
+ <div
258
+ className={cn(
259
+ 'absolute flex items-center gap-0.5 rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
260
+ visible ? 'pointer-events-auto' : 'pointer-events-none',
261
+ )}
262
+ style={{
263
+ left: rect.left + rect.width / 2,
264
+ top: rect.top + rect.height + FLOATING_PANEL_GAP,
265
+ transform: 'translateX(-50%)',
266
+ opacity: visible ? 1 : 0,
267
+ transition,
268
+ }}
269
+ >
270
+ <Tooltip>
271
+ <TooltipTrigger asChild>
272
+ <button
273
+ type="button"
274
+ aria-label={t.inspector.replace}
275
+ onClick={(e) => {
276
+ e.stopPropagation();
277
+ openReplace(anchor);
278
+ }}
279
+ className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
280
+ >
281
+ <ImageIcon className="size-3.5" />
282
+ </button>
283
+ </TooltipTrigger>
284
+ <TooltipContent side="bottom" data-inspector-ui>
285
+ {t.inspector.replace}
286
+ </TooltipContent>
287
+ </Tooltip>
288
+ <Tooltip>
289
+ <TooltipTrigger asChild>
290
+ <button
291
+ type="button"
292
+ aria-label={t.inspector.crop}
293
+ onClick={(e) => {
294
+ e.stopPropagation();
295
+ openCrop(anchor as HTMLImageElement);
296
+ }}
297
+ className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
298
+ >
299
+ <Crop className="size-3.5" />
300
+ </button>
301
+ </TooltipTrigger>
302
+ <TooltipContent side="bottom" data-inspector-ui>
303
+ {t.inspector.crop}
304
+ </TooltipContent>
305
+ </Tooltip>
306
+ </div>
307
+ </TooltipProvider>
308
+ );
309
+ }
310
+
311
+ function sameRect(a: RelRect | null, b: RelRect): boolean {
312
+ return (
313
+ !!a &&
314
+ Math.abs(a.left - b.left) < 0.5 &&
315
+ Math.abs(a.top - b.top) < 0.5 &&
316
+ Math.abs(a.width - b.width) < 0.5 &&
317
+ Math.abs(a.height - b.height) < 0.5
318
+ );
319
+ }
320
+
321
+ function pickElement(x: number, y: number): HTMLElement | null {
322
+ const stack = document.elementsFromPoint(x, y);
323
+ for (const el of stack) {
324
+ if (!(el instanceof HTMLElement)) continue;
325
+ if (el.closest('[data-inspector-ui]')) continue;
326
+ if (!el.closest('[data-inspector-root]')) continue;
327
+ return el;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ function findInspectorHitAtPoint(x: number, y: number, slideId: string): SlideSourceHit | null {
333
+ const el = pickInspectorTarget(pickElement(x, y));
334
+ if (!el) return null;
335
+ return findSlideSource(el, slideId, { hostOnly: true });
336
+ }
337
+
338
+ function clearTextSelection() {
339
+ const selection = window.getSelection();
340
+ if (selection && !selection.isCollapsed) selection.removeAllRanges();
341
+ }
342
+
343
+ const INLINE_TEXT_TAGS = new Set([
344
+ 'B',
345
+ 'CODE',
346
+ 'DEL',
347
+ 'EM',
348
+ 'I',
349
+ 'INS',
350
+ 'MARK',
351
+ 'S',
352
+ 'SMALL',
353
+ 'SPAN',
354
+ 'STRONG',
355
+ 'SUB',
356
+ 'SUP',
357
+ 'U',
358
+ ]);
359
+
360
+ function pickInspectorTarget(el: HTMLElement | null): HTMLElement | null {
361
+ if (!el) return null;
362
+ const root = el.closest('[data-inspector-root]');
363
+ const startedOnInlineText = INLINE_TEXT_TAGS.has(el.tagName);
364
+ for (let cur: HTMLElement | null = el; cur && root?.contains(cur); cur = cur.parentElement) {
365
+ if (startedOnInlineText && INLINE_TEXT_TAGS.has(cur.tagName)) continue;
366
+ if (isEditableTextContainer(cur)) return cur;
367
+ }
368
+ return el;
369
+ }
370
+
371
+ function isEditableTextContainer(el: HTMLElement): boolean {
372
+ if (!el.textContent?.trim()) return false;
373
+ return hasOnlyInlineTextChildren(el);
374
+ }
375
+
376
+ function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
377
+ for (const child of Array.from(el.childNodes)) {
378
+ if (child.nodeType === Node.TEXT_NODE) {
379
+ continue;
380
+ } else if (child instanceof HTMLElement) {
381
+ if (child.tagName === 'BR') continue;
382
+ if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
383
+ }
384
+ return false;
385
+ }
386
+ return true;
387
+ }