@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- package/src/locale/zh-tw.ts +414 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlignCenter,
|
|
3
|
+
AlignJustify,
|
|
4
|
+
AlignLeft,
|
|
5
|
+
AlignRight,
|
|
6
|
+
Bold,
|
|
7
|
+
Crop,
|
|
8
|
+
Crosshair,
|
|
9
|
+
ImageIcon,
|
|
10
|
+
Italic,
|
|
11
|
+
X,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
15
|
+
import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Input } from '@/components/ui/input';
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
} from '@/components/ui/select';
|
|
25
|
+
import { Separator } from '@/components/ui/separator';
|
|
26
|
+
import { Slider } from '@/components/ui/slider';
|
|
27
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
28
|
+
import { Toggle } from '@/components/ui/toggle';
|
|
29
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
30
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
31
|
+
import { findSlideSource } from '@/lib/inspector/fiber';
|
|
32
|
+
import type { EditOp } from '@/lib/inspector/use-editor';
|
|
33
|
+
import { useAgentSocketConnected } from '@/lib/use-agent-socket';
|
|
34
|
+
import { useLocale } from '@/lib/use-locale';
|
|
35
|
+
import type { Locale } from '../../../locale/types';
|
|
36
|
+
import { AssetPickerDialog } from './asset-picker-dialog';
|
|
37
|
+
import { type SelectedTarget, useInspector } from './inspector-provider';
|
|
38
|
+
|
|
39
|
+
type ElementSnapshot = {
|
|
40
|
+
fontSize: number;
|
|
41
|
+
fontWeight: number;
|
|
42
|
+
fontStyle: 'normal' | 'italic';
|
|
43
|
+
color: string;
|
|
44
|
+
backgroundColor: string | null;
|
|
45
|
+
textAlign: 'left' | 'center' | 'right' | 'justify';
|
|
46
|
+
lineHeight: number | null;
|
|
47
|
+
letterSpacing: number;
|
|
48
|
+
text: string | null;
|
|
49
|
+
imageSrc: string | null;
|
|
50
|
+
placeholder: { hint: string; width?: number; height?: number } | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type ContentSelection = { start: number; end: number };
|
|
54
|
+
type StylePreview = Partial<
|
|
55
|
+
Pick<ElementSnapshot, 'fontSize' | 'fontWeight' | 'fontStyle' | 'color'>
|
|
56
|
+
>;
|
|
57
|
+
type RangeStylePreview = {
|
|
58
|
+
anchor: HTMLElement;
|
|
59
|
+
start: number;
|
|
60
|
+
end: number;
|
|
61
|
+
values: StylePreview;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function resolveSelectedTarget(target: SelectedTarget, slideId: string): SelectedTarget {
|
|
65
|
+
const hit = findSlideSource(target.anchor, slideId, { hostOnly: true });
|
|
66
|
+
if (!hit) return target;
|
|
67
|
+
if (hit.line === target.line && hit.column === target.column && hit.anchor === target.anchor) {
|
|
68
|
+
return target;
|
|
69
|
+
}
|
|
70
|
+
return { line: hit.line, column: hit.column, anchor: hit.anchor };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function InspectorPanel() {
|
|
74
|
+
const { active, slideId, selected, setSelected, bufferOps, pendingCount, add, applyEdit } =
|
|
75
|
+
useInspector();
|
|
76
|
+
const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
|
|
77
|
+
const [contentSelection, setContentSelection] = useState<ContentSelection | null>(null);
|
|
78
|
+
const [rangeStylePreview, setRangeStylePreview] = useState<RangeStylePreview | null>(null);
|
|
79
|
+
const reloadCounter = useReloadCounter();
|
|
80
|
+
const t = useLocale();
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
void selected;
|
|
84
|
+
setContentSelection(null);
|
|
85
|
+
setRangeStylePreview(null);
|
|
86
|
+
}, [selected]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
void reloadCounter;
|
|
90
|
+
void pendingCount;
|
|
91
|
+
if (!selected) {
|
|
92
|
+
setSnapshot(null);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let anchor = selected.anchor;
|
|
96
|
+
if (!anchor.isConnected) {
|
|
97
|
+
const next = findElementByLine(slideId, selected.line, selected.column);
|
|
98
|
+
if (next) {
|
|
99
|
+
anchor = next;
|
|
100
|
+
setSelected({ ...selected, anchor: next });
|
|
101
|
+
} else {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
setSnapshot(readSnapshot(anchor));
|
|
106
|
+
}, [selected, setSelected, slideId, reloadCounter, pendingCount]);
|
|
107
|
+
|
|
108
|
+
// Freeze slide animations while editing so commits don't replay motion.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!active) return;
|
|
111
|
+
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
112
|
+
if (!root) return;
|
|
113
|
+
const styleEl = document.createElement('style');
|
|
114
|
+
styleEl.textContent = EDITING_FREEZE_CSS;
|
|
115
|
+
document.head.appendChild(styleEl);
|
|
116
|
+
root.dataset.inspectorEditing = 'true';
|
|
117
|
+
return () => {
|
|
118
|
+
let cleaned = false;
|
|
119
|
+
const finish = () => {
|
|
120
|
+
if (cleaned) return;
|
|
121
|
+
cleaned = true;
|
|
122
|
+
styleEl.remove();
|
|
123
|
+
delete root.dataset.inspectorEditing;
|
|
124
|
+
import.meta.hot?.off('vite:afterUpdate', finish);
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
};
|
|
127
|
+
const timer = setTimeout(finish, 1500);
|
|
128
|
+
import.meta.hot?.on('vite:afterUpdate', finish);
|
|
129
|
+
};
|
|
130
|
+
}, [active]);
|
|
131
|
+
|
|
132
|
+
const apply = useCallback(
|
|
133
|
+
(ops: EditOp[]) => {
|
|
134
|
+
if (!selected) return;
|
|
135
|
+
const target = resolveSelectedTarget(selected, slideId);
|
|
136
|
+
if (target !== selected) setSelected(target);
|
|
137
|
+
bufferOps(target.line, target.column, target.anchor, ops);
|
|
138
|
+
if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
|
|
139
|
+
},
|
|
140
|
+
[selected, setSelected, slideId, bufferOps],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// `pinned` keeps the last selection rendered through the close-out
|
|
144
|
+
// animation so the panel's contents don't blank out before it collapses.
|
|
145
|
+
const targetOpen = active && !!selected && !!snapshot;
|
|
146
|
+
const [pinned, setPinned] = useState<{ s: SelectedTarget; n: ElementSnapshot } | null>(null);
|
|
147
|
+
const animVisible = useAnimatedOpen(targetOpen && !!pinned);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (selected && snapshot) setPinned({ s: selected, n: snapshot });
|
|
151
|
+
}, [selected, snapshot]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!targetOpen && pinned) {
|
|
155
|
+
const t = setTimeout(() => setPinned(null), PANEL_TRANSITION_MS);
|
|
156
|
+
return () => clearTimeout(t);
|
|
157
|
+
}
|
|
158
|
+
}, [targetOpen, pinned]);
|
|
159
|
+
|
|
160
|
+
if (!pinned) return null;
|
|
161
|
+
const { s: pinSelected, n: pinSnapshot } = pinned;
|
|
162
|
+
const contentRange =
|
|
163
|
+
pinSnapshot.text !== null && contentSelection && contentSelection.end > contentSelection.start
|
|
164
|
+
? contentSelection
|
|
165
|
+
: null;
|
|
166
|
+
const rangePreviewApplies =
|
|
167
|
+
contentRange &&
|
|
168
|
+
rangeStylePreview &&
|
|
169
|
+
rangeStylePreview.anchor === pinSelected.anchor &&
|
|
170
|
+
rangeStylePreview.start === contentRange.start &&
|
|
171
|
+
rangeStylePreview.end === contentRange.end;
|
|
172
|
+
const typographySnapshot = rangePreviewApplies
|
|
173
|
+
? withStylePreview(pinSnapshot, rangeStylePreview.values)
|
|
174
|
+
: pinSnapshot;
|
|
175
|
+
const applyTextStyle = (ops: EditOp[]) => {
|
|
176
|
+
const styleOps = ops.flatMap((op) => (op.kind === 'set-style' ? [op] : []));
|
|
177
|
+
const target = resolveSelectedTarget(pinSelected, slideId);
|
|
178
|
+
if (target !== pinSelected) setSelected(target);
|
|
179
|
+
if (
|
|
180
|
+
contentRange &&
|
|
181
|
+
pinSnapshot.text !== null &&
|
|
182
|
+
styleOps.length === 1 &&
|
|
183
|
+
styleOps.length === ops.length &&
|
|
184
|
+
styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
|
|
185
|
+
) {
|
|
186
|
+
bufferOps(
|
|
187
|
+
target.line,
|
|
188
|
+
target.column,
|
|
189
|
+
target.anchor,
|
|
190
|
+
styleOps.map((op) => ({
|
|
191
|
+
kind: 'set-text-range-style',
|
|
192
|
+
start: contentRange.start,
|
|
193
|
+
end: contentRange.end,
|
|
194
|
+
key: op.key,
|
|
195
|
+
value: op.value,
|
|
196
|
+
prevText: pinSnapshot.text ?? undefined,
|
|
197
|
+
})),
|
|
198
|
+
);
|
|
199
|
+
setRangeStylePreview((current) => ({
|
|
200
|
+
anchor: target.anchor,
|
|
201
|
+
start: contentRange.start,
|
|
202
|
+
end: contentRange.end,
|
|
203
|
+
values: {
|
|
204
|
+
...(current?.anchor === target.anchor &&
|
|
205
|
+
current.start === contentRange.start &&
|
|
206
|
+
current.end === contentRange.end
|
|
207
|
+
? current.values
|
|
208
|
+
: {}),
|
|
209
|
+
...stylePreviewFromOps(styleOps),
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
pinSnapshot.text !== null &&
|
|
217
|
+
styleOps.length > 0 &&
|
|
218
|
+
styleOps.length === ops.length &&
|
|
219
|
+
styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
|
|
220
|
+
) {
|
|
221
|
+
bufferOps(
|
|
222
|
+
target.line,
|
|
223
|
+
target.column,
|
|
224
|
+
target.anchor,
|
|
225
|
+
styleOps.map((op) => ({ ...op, prevText: pinSnapshot.text ?? undefined })),
|
|
226
|
+
);
|
|
227
|
+
if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
apply(ops);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<PanelShell
|
|
235
|
+
uiAttr="inspector"
|
|
236
|
+
animVisible={animVisible}
|
|
237
|
+
header={
|
|
238
|
+
<>
|
|
239
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
240
|
+
<Crosshair className="size-3.5 text-muted-foreground" />
|
|
241
|
+
<span className="font-heading text-[12px] font-semibold tracking-tight">
|
|
242
|
+
{t.inspector.inspect}
|
|
243
|
+
</span>
|
|
244
|
+
<span aria-hidden className="h-3 w-px bg-hairline" />
|
|
245
|
+
<span className="rounded-[3px] border border-hairline bg-card px-1.5 py-px font-mono text-[10.5px] text-foreground/85">
|
|
246
|
+
<{pinSelected.anchor.tagName.toLowerCase()}>
|
|
247
|
+
</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="flex items-center gap-1.5">
|
|
250
|
+
<AgentWatchingBadge />
|
|
251
|
+
<Button
|
|
252
|
+
variant="ghost"
|
|
253
|
+
size="icon-sm"
|
|
254
|
+
className="text-muted-foreground hover:text-foreground"
|
|
255
|
+
onClick={() => setSelected(null)}
|
|
256
|
+
aria-label={t.inspector.deselect}
|
|
257
|
+
>
|
|
258
|
+
<X className="size-3.5" />
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
</>
|
|
262
|
+
}
|
|
263
|
+
footer={<CommentsSection selected={pinSelected} onAdd={add} />}
|
|
264
|
+
>
|
|
265
|
+
{pinSnapshot.text !== null && (
|
|
266
|
+
<>
|
|
267
|
+
<Section title={t.inspector.contentSection}>
|
|
268
|
+
<ContentField
|
|
269
|
+
snapshot={pinSnapshot}
|
|
270
|
+
apply={apply}
|
|
271
|
+
onSelectionChange={setContentSelection}
|
|
272
|
+
/>
|
|
273
|
+
</Section>
|
|
274
|
+
<Separator />
|
|
275
|
+
</>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
<Section title={t.inspector.typographySection}>
|
|
279
|
+
<FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
280
|
+
<FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
281
|
+
<StyleToggles snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
282
|
+
<LineHeightField snapshot={pinSnapshot} apply={apply} />
|
|
283
|
+
<LetterSpacingField snapshot={pinSnapshot} apply={apply} />
|
|
284
|
+
<TextAlignField snapshot={pinSnapshot} apply={apply} />
|
|
285
|
+
</Section>
|
|
286
|
+
|
|
287
|
+
<Separator />
|
|
288
|
+
|
|
289
|
+
<Section title={t.inspector.colorSection}>
|
|
290
|
+
<ColorField
|
|
291
|
+
label={t.inspector.textColor}
|
|
292
|
+
value={typographySnapshot.color}
|
|
293
|
+
onChange={(v) => applyTextStyle([{ kind: 'set-style', key: 'color', value: v }])}
|
|
294
|
+
clearable={false}
|
|
295
|
+
/>
|
|
296
|
+
<ColorField
|
|
297
|
+
label={t.inspector.backgroundColor}
|
|
298
|
+
value={pinSnapshot.backgroundColor ?? '#ffffff'}
|
|
299
|
+
dim={!pinSnapshot.backgroundColor}
|
|
300
|
+
onChange={(v) => apply([{ kind: 'set-style', key: 'backgroundColor', value: v }])}
|
|
301
|
+
onClear={() => apply([{ kind: 'set-style', key: 'backgroundColor', value: null }])}
|
|
302
|
+
clearable
|
|
303
|
+
/>
|
|
304
|
+
</Section>
|
|
305
|
+
|
|
306
|
+
{pinSnapshot.imageSrc !== null && (
|
|
307
|
+
<>
|
|
308
|
+
<Separator />
|
|
309
|
+
<Section title={t.inspector.imageSection}>
|
|
310
|
+
<ImageField src={pinSnapshot.imageSrc} anchor={pinSelected.anchor} />
|
|
311
|
+
</Section>
|
|
312
|
+
</>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{pinSnapshot.placeholder && (
|
|
316
|
+
<>
|
|
317
|
+
<Separator />
|
|
318
|
+
<Section title={t.inspector.imagePlaceholderSection}>
|
|
319
|
+
<PlaceholderField
|
|
320
|
+
slideId={slideId}
|
|
321
|
+
hint={pinSnapshot.placeholder.hint}
|
|
322
|
+
line={pinSelected.line}
|
|
323
|
+
column={pinSelected.column}
|
|
324
|
+
applyEdit={applyEdit}
|
|
325
|
+
/>
|
|
326
|
+
</Section>
|
|
327
|
+
</>
|
|
328
|
+
)}
|
|
329
|
+
</PanelShell>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const EDITING_FREEZE_CSS = `
|
|
334
|
+
[data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *),
|
|
335
|
+
[data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::before,
|
|
336
|
+
[data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::after {
|
|
337
|
+
animation-duration: 1ms !important;
|
|
338
|
+
animation-delay: 0s !important;
|
|
339
|
+
animation-iteration-count: 1 !important;
|
|
340
|
+
animation-fill-mode: forwards !important;
|
|
341
|
+
transition: none !important;
|
|
342
|
+
view-transition-name: none !important;
|
|
343
|
+
cursor: pointer !important;
|
|
344
|
+
}
|
|
345
|
+
`;
|
|
346
|
+
|
|
347
|
+
const INLINE_CONTENT_STYLE_KEYS = new Set([
|
|
348
|
+
'fontSize',
|
|
349
|
+
'fontWeight',
|
|
350
|
+
'fontStyle',
|
|
351
|
+
'fontFamily',
|
|
352
|
+
'color',
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
function stylePreviewFromOps(ops: Array<Extract<EditOp, { kind: 'set-style' }>>): StylePreview {
|
|
356
|
+
const preview: StylePreview = {};
|
|
357
|
+
for (const op of ops) {
|
|
358
|
+
if (op.key === 'fontSize' && op.value) {
|
|
359
|
+
const n = parseFloat(op.value);
|
|
360
|
+
if (Number.isFinite(n)) preview.fontSize = n;
|
|
361
|
+
} else if (op.key === 'fontWeight') {
|
|
362
|
+
preview.fontWeight = op.value ? Number(op.value) || 400 : 400;
|
|
363
|
+
} else if (op.key === 'fontStyle') {
|
|
364
|
+
preview.fontStyle = op.value === 'italic' ? 'italic' : 'normal';
|
|
365
|
+
} else if (op.key === 'color' && op.value) {
|
|
366
|
+
preview.color = op.value;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return preview;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function withStylePreview(snapshot: ElementSnapshot, preview: StylePreview): ElementSnapshot {
|
|
373
|
+
return { ...snapshot, ...preview };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function ContentField({
|
|
377
|
+
snapshot,
|
|
378
|
+
apply,
|
|
379
|
+
onSelectionChange,
|
|
380
|
+
}: {
|
|
381
|
+
snapshot: ElementSnapshot;
|
|
382
|
+
apply: (ops: EditOp[]) => void;
|
|
383
|
+
onSelectionChange?: (selection: ContentSelection | null) => void;
|
|
384
|
+
}) {
|
|
385
|
+
// Mirror the value locally and skip syncs during IME composition;
|
|
386
|
+
// a re-render mid-composition would otherwise clobber in-progress
|
|
387
|
+
// candidates (Bopomofo/Pinyin only commit on candidate selection).
|
|
388
|
+
const [local, setLocal] = useState(snapshot.text ?? '');
|
|
389
|
+
const composingRef = useRef(false);
|
|
390
|
+
const t = useLocale();
|
|
391
|
+
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (!composingRef.current) setLocal(snapshot.text ?? '');
|
|
394
|
+
}, [snapshot.text]);
|
|
395
|
+
|
|
396
|
+
const reportSelection = (el: HTMLTextAreaElement) => {
|
|
397
|
+
const start = el.selectionStart ?? 0;
|
|
398
|
+
const end = el.selectionEnd ?? start;
|
|
399
|
+
onSelectionChange?.(end > start ? { start, end } : null);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<Textarea
|
|
404
|
+
value={local}
|
|
405
|
+
onCompositionStart={() => {
|
|
406
|
+
composingRef.current = true;
|
|
407
|
+
}}
|
|
408
|
+
onCompositionEnd={(e) => {
|
|
409
|
+
composingRef.current = false;
|
|
410
|
+
const v = e.currentTarget.value;
|
|
411
|
+
setLocal(v);
|
|
412
|
+
reportSelection(e.currentTarget);
|
|
413
|
+
apply([{ kind: 'set-text', value: v }]);
|
|
414
|
+
}}
|
|
415
|
+
onChange={(e) => {
|
|
416
|
+
const v = e.target.value;
|
|
417
|
+
setLocal(v);
|
|
418
|
+
reportSelection(e.currentTarget);
|
|
419
|
+
if (!composingRef.current) {
|
|
420
|
+
apply([{ kind: 'set-text', value: v }]);
|
|
421
|
+
}
|
|
422
|
+
}}
|
|
423
|
+
onKeyUp={(e) => reportSelection(e.currentTarget)}
|
|
424
|
+
onMouseUp={(e) => reportSelection(e.currentTarget)}
|
|
425
|
+
onSelect={(e) => reportSelection(e.currentTarget)}
|
|
426
|
+
wrap="off"
|
|
427
|
+
rows={3}
|
|
428
|
+
className="field-sizing-fixed min-h-16 w-full resize-none overflow-x-auto whitespace-pre text-xs"
|
|
429
|
+
placeholder={t.inspector.elementTextPlaceholder}
|
|
430
|
+
/>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function FontSizeField({
|
|
435
|
+
snapshot,
|
|
436
|
+
apply,
|
|
437
|
+
}: {
|
|
438
|
+
snapshot: ElementSnapshot;
|
|
439
|
+
apply: (ops: EditOp[]) => void;
|
|
440
|
+
}) {
|
|
441
|
+
const set = (px: number) => {
|
|
442
|
+
apply([{ kind: 'set-style', key: 'fontSize', value: `${Math.round(px)}px` }]);
|
|
443
|
+
};
|
|
444
|
+
const t = useLocale();
|
|
445
|
+
return (
|
|
446
|
+
<Field label={t.inspector.sizeLabel}>
|
|
447
|
+
<Slider
|
|
448
|
+
min={8}
|
|
449
|
+
max={200}
|
|
450
|
+
step={1}
|
|
451
|
+
value={[snapshot.fontSize]}
|
|
452
|
+
onValueChange={([v]) => set(v ?? snapshot.fontSize)}
|
|
453
|
+
className="flex-1"
|
|
454
|
+
/>
|
|
455
|
+
<NumberField
|
|
456
|
+
value={Math.round(snapshot.fontSize)}
|
|
457
|
+
onChange={set}
|
|
458
|
+
min={1}
|
|
459
|
+
max={400}
|
|
460
|
+
suffix="px"
|
|
461
|
+
/>
|
|
462
|
+
</Field>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function getWeightOptions(t: Locale): { value: string; label: string }[] {
|
|
467
|
+
return [
|
|
468
|
+
{ value: '300', label: t.inspector.weightLight },
|
|
469
|
+
{ value: '400', label: t.inspector.weightRegular },
|
|
470
|
+
{ value: '500', label: t.inspector.weightMedium },
|
|
471
|
+
{ value: '600', label: t.inspector.weightSemibold },
|
|
472
|
+
{ value: '700', label: t.inspector.weightBold },
|
|
473
|
+
{ value: '800', label: t.inspector.weightExtrabold },
|
|
474
|
+
];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function FontWeightField({
|
|
478
|
+
snapshot,
|
|
479
|
+
apply,
|
|
480
|
+
}: {
|
|
481
|
+
snapshot: ElementSnapshot;
|
|
482
|
+
apply: (ops: EditOp[]) => void;
|
|
483
|
+
}) {
|
|
484
|
+
const t = useLocale();
|
|
485
|
+
const weightOptions = getWeightOptions(t);
|
|
486
|
+
return (
|
|
487
|
+
<Field label={t.inspector.weightLabel}>
|
|
488
|
+
<Select
|
|
489
|
+
value={String(snapshot.fontWeight)}
|
|
490
|
+
onValueChange={(value) => {
|
|
491
|
+
const n = Number(value);
|
|
492
|
+
apply([
|
|
493
|
+
{
|
|
494
|
+
kind: 'set-style',
|
|
495
|
+
key: 'fontWeight',
|
|
496
|
+
value: n === 400 ? null : value,
|
|
497
|
+
},
|
|
498
|
+
]);
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
<SelectTrigger size="sm" className="h-8 flex-1 text-xs">
|
|
502
|
+
<SelectValue />
|
|
503
|
+
</SelectTrigger>
|
|
504
|
+
<SelectContent>
|
|
505
|
+
{weightOptions.map((opt) => (
|
|
506
|
+
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
507
|
+
{opt.label}
|
|
508
|
+
</SelectItem>
|
|
509
|
+
))}
|
|
510
|
+
</SelectContent>
|
|
511
|
+
</Select>
|
|
512
|
+
</Field>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function StyleToggles({
|
|
517
|
+
snapshot,
|
|
518
|
+
apply,
|
|
519
|
+
}: {
|
|
520
|
+
snapshot: ElementSnapshot;
|
|
521
|
+
apply: (ops: EditOp[]) => void;
|
|
522
|
+
}) {
|
|
523
|
+
const t = useLocale();
|
|
524
|
+
return (
|
|
525
|
+
<Field label={t.inspector.styleLabel}>
|
|
526
|
+
<Toggle
|
|
527
|
+
size="sm"
|
|
528
|
+
variant="outline"
|
|
529
|
+
pressed={snapshot.fontWeight >= 600}
|
|
530
|
+
onPressedChange={(v) =>
|
|
531
|
+
apply([{ kind: 'set-style', key: 'fontWeight', value: v ? '700' : null }])
|
|
532
|
+
}
|
|
533
|
+
aria-label={t.inspector.boldAria}
|
|
534
|
+
>
|
|
535
|
+
<Bold className="size-3.5" />
|
|
536
|
+
</Toggle>
|
|
537
|
+
<Toggle
|
|
538
|
+
size="sm"
|
|
539
|
+
variant="outline"
|
|
540
|
+
pressed={snapshot.fontStyle === 'italic'}
|
|
541
|
+
onPressedChange={(v) =>
|
|
542
|
+
apply([{ kind: 'set-style', key: 'fontStyle', value: v ? 'italic' : null }])
|
|
543
|
+
}
|
|
544
|
+
aria-label={t.inspector.italicAria}
|
|
545
|
+
>
|
|
546
|
+
<Italic className="size-3.5" />
|
|
547
|
+
</Toggle>
|
|
548
|
+
</Field>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function LineHeightField({
|
|
553
|
+
snapshot,
|
|
554
|
+
apply,
|
|
555
|
+
}: {
|
|
556
|
+
snapshot: ElementSnapshot;
|
|
557
|
+
apply: (ops: EditOp[]) => void;
|
|
558
|
+
}) {
|
|
559
|
+
const v = snapshot.lineHeight ?? 1.4;
|
|
560
|
+
const set = (n: number) => {
|
|
561
|
+
apply([{ kind: 'set-style', key: 'lineHeight', value: String(round2(n)) }]);
|
|
562
|
+
};
|
|
563
|
+
const t = useLocale();
|
|
564
|
+
return (
|
|
565
|
+
<Field label={t.inspector.lineHeightLabel}>
|
|
566
|
+
<Slider
|
|
567
|
+
min={0.8}
|
|
568
|
+
max={3}
|
|
569
|
+
step={0.05}
|
|
570
|
+
value={[v]}
|
|
571
|
+
onValueChange={([n]) => set(n ?? v)}
|
|
572
|
+
className="flex-1"
|
|
573
|
+
/>
|
|
574
|
+
<NumberField value={round2(v)} onChange={set} step={0.05} min={0.5} max={5} />
|
|
575
|
+
</Field>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function LetterSpacingField({
|
|
580
|
+
snapshot,
|
|
581
|
+
apply,
|
|
582
|
+
}: {
|
|
583
|
+
snapshot: ElementSnapshot;
|
|
584
|
+
apply: (ops: EditOp[]) => void;
|
|
585
|
+
}) {
|
|
586
|
+
const set = (n: number) => {
|
|
587
|
+
apply([
|
|
588
|
+
{
|
|
589
|
+
kind: 'set-style',
|
|
590
|
+
key: 'letterSpacing',
|
|
591
|
+
value: n === 0 ? null : `${round2(n)}px`,
|
|
592
|
+
},
|
|
593
|
+
]);
|
|
594
|
+
};
|
|
595
|
+
const t = useLocale();
|
|
596
|
+
return (
|
|
597
|
+
<Field label={t.inspector.trackingLabel}>
|
|
598
|
+
<Slider
|
|
599
|
+
min={-5}
|
|
600
|
+
max={20}
|
|
601
|
+
step={0.1}
|
|
602
|
+
value={[snapshot.letterSpacing]}
|
|
603
|
+
onValueChange={([n]) => set(n ?? snapshot.letterSpacing)}
|
|
604
|
+
className="flex-1"
|
|
605
|
+
/>
|
|
606
|
+
<NumberField
|
|
607
|
+
value={round2(snapshot.letterSpacing)}
|
|
608
|
+
onChange={set}
|
|
609
|
+
step={0.1}
|
|
610
|
+
min={-20}
|
|
611
|
+
max={50}
|
|
612
|
+
suffix="px"
|
|
613
|
+
/>
|
|
614
|
+
</Field>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const ALIGN_OPTIONS = [
|
|
619
|
+
{ v: 'left', icon: AlignLeft },
|
|
620
|
+
{ v: 'center', icon: AlignCenter },
|
|
621
|
+
{ v: 'right', icon: AlignRight },
|
|
622
|
+
{ v: 'justify', icon: AlignJustify },
|
|
623
|
+
] as const;
|
|
624
|
+
|
|
625
|
+
function TextAlignField({
|
|
626
|
+
snapshot,
|
|
627
|
+
apply,
|
|
628
|
+
}: {
|
|
629
|
+
snapshot: ElementSnapshot;
|
|
630
|
+
apply: (ops: EditOp[]) => void;
|
|
631
|
+
}) {
|
|
632
|
+
const t = useLocale();
|
|
633
|
+
return (
|
|
634
|
+
<Field label={t.inspector.alignLabel}>
|
|
635
|
+
<ToggleGroup
|
|
636
|
+
type="single"
|
|
637
|
+
size="sm"
|
|
638
|
+
variant="outline"
|
|
639
|
+
value={snapshot.textAlign}
|
|
640
|
+
onValueChange={(value) => {
|
|
641
|
+
if (!value) return;
|
|
642
|
+
apply([
|
|
643
|
+
{
|
|
644
|
+
kind: 'set-style',
|
|
645
|
+
key: 'textAlign',
|
|
646
|
+
value: value === 'left' ? null : value,
|
|
647
|
+
},
|
|
648
|
+
]);
|
|
649
|
+
}}
|
|
650
|
+
>
|
|
651
|
+
{ALIGN_OPTIONS.map(({ v, icon: Icon }) => (
|
|
652
|
+
<ToggleGroupItem key={v} value={v} aria-label={v} className="size-8">
|
|
653
|
+
<Icon className="size-3.5" />
|
|
654
|
+
</ToggleGroupItem>
|
|
655
|
+
))}
|
|
656
|
+
</ToggleGroup>
|
|
657
|
+
</Field>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function ColorField({
|
|
662
|
+
label,
|
|
663
|
+
value,
|
|
664
|
+
dim,
|
|
665
|
+
onChange,
|
|
666
|
+
onClear,
|
|
667
|
+
clearable,
|
|
668
|
+
}: {
|
|
669
|
+
label: string;
|
|
670
|
+
value: string;
|
|
671
|
+
dim?: boolean;
|
|
672
|
+
onChange: (v: string) => void;
|
|
673
|
+
onClear?: () => void;
|
|
674
|
+
clearable: boolean;
|
|
675
|
+
}) {
|
|
676
|
+
// Buffer the text input so intermediate hex like "#a" doesn't
|
|
677
|
+
// commit until it parses as a full color.
|
|
678
|
+
const [draft, setDraft] = useState(value);
|
|
679
|
+
const tColor = useLocale();
|
|
680
|
+
useEffect(() => setDraft(value), [value]);
|
|
681
|
+
|
|
682
|
+
const commitHex = (hex: string) => {
|
|
683
|
+
if (/^#[0-9a-fA-F]{6}$/.test(hex)) onChange(hex);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
<Field label={label}>
|
|
688
|
+
<label className="relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border bg-background shadow-xs">
|
|
689
|
+
<span
|
|
690
|
+
className="size-5 rounded-sm"
|
|
691
|
+
style={{
|
|
692
|
+
backgroundColor: dim ? 'transparent' : value,
|
|
693
|
+
backgroundImage: dim
|
|
694
|
+
? 'linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%), linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%)'
|
|
695
|
+
: undefined,
|
|
696
|
+
backgroundSize: dim ? '8px 8px' : undefined,
|
|
697
|
+
backgroundPosition: dim ? '0 0, 4px 4px' : undefined,
|
|
698
|
+
}}
|
|
699
|
+
/>
|
|
700
|
+
<input
|
|
701
|
+
type="color"
|
|
702
|
+
value={value}
|
|
703
|
+
onChange={(e) => {
|
|
704
|
+
setDraft(e.target.value);
|
|
705
|
+
onChange(e.target.value);
|
|
706
|
+
}}
|
|
707
|
+
className="absolute inset-0 cursor-pointer opacity-0"
|
|
708
|
+
/>
|
|
709
|
+
</label>
|
|
710
|
+
<Input
|
|
711
|
+
type="text"
|
|
712
|
+
value={draft}
|
|
713
|
+
onChange={(e) => {
|
|
714
|
+
setDraft(e.target.value);
|
|
715
|
+
commitHex(e.target.value);
|
|
716
|
+
}}
|
|
717
|
+
className="h-8 flex-1 font-mono text-[11px] uppercase"
|
|
718
|
+
spellCheck={false}
|
|
719
|
+
/>
|
|
720
|
+
{clearable && onClear && (
|
|
721
|
+
<Button
|
|
722
|
+
variant="ghost"
|
|
723
|
+
size="icon"
|
|
724
|
+
className="size-8 text-muted-foreground hover:text-foreground"
|
|
725
|
+
onClick={onClear}
|
|
726
|
+
aria-label={tColor.inspector.clearAria}
|
|
727
|
+
>
|
|
728
|
+
<X className="size-3.5" />
|
|
729
|
+
</Button>
|
|
730
|
+
)}
|
|
731
|
+
</Field>
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function ImageField({ src, anchor }: { src: string; anchor: HTMLElement }) {
|
|
736
|
+
const t = useLocale();
|
|
737
|
+
const { openCrop, openReplace } = useInspector();
|
|
738
|
+
const isImage = anchor.tagName === 'IMG';
|
|
739
|
+
return (
|
|
740
|
+
<div className="space-y-2">
|
|
741
|
+
<div className="flex items-center gap-3">
|
|
742
|
+
<div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:8px_8px]">
|
|
743
|
+
<img
|
|
744
|
+
src={src}
|
|
745
|
+
alt=""
|
|
746
|
+
className="size-full object-contain"
|
|
747
|
+
draggable={false}
|
|
748
|
+
onError={(e) => {
|
|
749
|
+
e.currentTarget.style.display = 'none';
|
|
750
|
+
}}
|
|
751
|
+
/>
|
|
752
|
+
</div>
|
|
753
|
+
<div className="flex flex-1 gap-2">
|
|
754
|
+
<Button
|
|
755
|
+
type="button"
|
|
756
|
+
variant="outline"
|
|
757
|
+
size="sm"
|
|
758
|
+
className="flex-1"
|
|
759
|
+
onClick={() => openReplace(anchor)}
|
|
760
|
+
>
|
|
761
|
+
<ImageIcon className="size-3.5" />
|
|
762
|
+
{t.inspector.replace}
|
|
763
|
+
</Button>
|
|
764
|
+
{isImage && (
|
|
765
|
+
<Button
|
|
766
|
+
type="button"
|
|
767
|
+
variant="outline"
|
|
768
|
+
size="sm"
|
|
769
|
+
className="flex-1"
|
|
770
|
+
onClick={() => openCrop(anchor as HTMLImageElement)}
|
|
771
|
+
>
|
|
772
|
+
<Crop className="size-3.5" />
|
|
773
|
+
{t.inspector.crop}
|
|
774
|
+
</Button>
|
|
775
|
+
)}
|
|
776
|
+
</div>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function PlaceholderField({
|
|
783
|
+
slideId,
|
|
784
|
+
hint,
|
|
785
|
+
line,
|
|
786
|
+
column,
|
|
787
|
+
applyEdit,
|
|
788
|
+
}: {
|
|
789
|
+
slideId: string;
|
|
790
|
+
hint: string;
|
|
791
|
+
line: number;
|
|
792
|
+
column: number;
|
|
793
|
+
applyEdit: (line: number, column: number, ops: EditOp[]) => Promise<void>;
|
|
794
|
+
}) {
|
|
795
|
+
const [open, setOpen] = useState(false);
|
|
796
|
+
const [submitting, setSubmitting] = useState(false);
|
|
797
|
+
const t = useLocale();
|
|
798
|
+
return (
|
|
799
|
+
<div className="space-y-2">
|
|
800
|
+
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
801
|
+
{t.inspector.placeholderHintLabel}{' '}
|
|
802
|
+
<span className="font-medium text-foreground">{hint}</span>
|
|
803
|
+
</p>
|
|
804
|
+
<Button
|
|
805
|
+
type="button"
|
|
806
|
+
variant="outline"
|
|
807
|
+
size="sm"
|
|
808
|
+
className="w-full"
|
|
809
|
+
disabled={submitting}
|
|
810
|
+
onClick={() => setOpen(true)}
|
|
811
|
+
>
|
|
812
|
+
<ImageIcon className="size-3.5" />
|
|
813
|
+
{t.inspector.replace}
|
|
814
|
+
</Button>
|
|
815
|
+
{open && (
|
|
816
|
+
<AssetPickerDialog
|
|
817
|
+
slideId={slideId}
|
|
818
|
+
onClose={() => setOpen(false)}
|
|
819
|
+
onPick={async (asset, scope) => {
|
|
820
|
+
setOpen(false);
|
|
821
|
+
setSubmitting(true);
|
|
822
|
+
try {
|
|
823
|
+
const assetPath =
|
|
824
|
+
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
825
|
+
await applyEdit(line, column, [
|
|
826
|
+
{
|
|
827
|
+
kind: 'replace-placeholder-with-image',
|
|
828
|
+
assetPath,
|
|
829
|
+
},
|
|
830
|
+
]);
|
|
831
|
+
} finally {
|
|
832
|
+
setSubmitting(false);
|
|
833
|
+
}
|
|
834
|
+
}}
|
|
835
|
+
/>
|
|
836
|
+
)}
|
|
837
|
+
</div>
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function AgentWatchingBadge() {
|
|
842
|
+
const t = useLocale();
|
|
843
|
+
const connected = useAgentSocketConnected();
|
|
844
|
+
return (
|
|
845
|
+
<TooltipProvider delayDuration={200}>
|
|
846
|
+
<Tooltip>
|
|
847
|
+
<TooltipTrigger asChild>
|
|
848
|
+
<button
|
|
849
|
+
type="button"
|
|
850
|
+
className="flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-px text-[10.5px] text-foreground/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
851
|
+
>
|
|
852
|
+
<span aria-hidden className="relative flex size-1.5 items-center justify-center">
|
|
853
|
+
{connected ? (
|
|
854
|
+
<>
|
|
855
|
+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
|
856
|
+
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
|
|
857
|
+
</>
|
|
858
|
+
) : (
|
|
859
|
+
<span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
|
|
860
|
+
)}
|
|
861
|
+
</span>
|
|
862
|
+
{connected ? t.inspector.agentWatching : t.inspector.agentNotWatching}
|
|
863
|
+
</button>
|
|
864
|
+
</TooltipTrigger>
|
|
865
|
+
<TooltipContent
|
|
866
|
+
side="bottom"
|
|
867
|
+
align="end"
|
|
868
|
+
className="w-max max-w-[min(520px,calc(100vw-2rem))] text-center leading-relaxed"
|
|
869
|
+
>
|
|
870
|
+
{connected ? t.inspector.agentWatchingTooltip : t.inspector.agentNotWatchingTooltip}
|
|
871
|
+
</TooltipContent>
|
|
872
|
+
</Tooltip>
|
|
873
|
+
</TooltipProvider>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function CommentsSection({
|
|
878
|
+
selected,
|
|
879
|
+
onAdd,
|
|
880
|
+
}: {
|
|
881
|
+
selected: { line: number; column: number };
|
|
882
|
+
onAdd: (line: number, column: number, text: string) => Promise<void>;
|
|
883
|
+
}) {
|
|
884
|
+
const [draft, setDraft] = useState('');
|
|
885
|
+
const [submitting, setSubmitting] = useState(false);
|
|
886
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
887
|
+
const t = useLocale();
|
|
888
|
+
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
const onKey = (e: KeyboardEvent) => {
|
|
891
|
+
if (e.key !== '/') return;
|
|
892
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
893
|
+
if (e.altKey || e.shiftKey) return;
|
|
894
|
+
const ta = wrapRef.current?.querySelector('textarea');
|
|
895
|
+
if (!ta) return;
|
|
896
|
+
e.preventDefault();
|
|
897
|
+
ta.focus({ preventScroll: true });
|
|
898
|
+
};
|
|
899
|
+
window.addEventListener('keydown', onKey);
|
|
900
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
901
|
+
}, []);
|
|
902
|
+
|
|
903
|
+
const submit = async () => {
|
|
904
|
+
const trimmed = draft.trim();
|
|
905
|
+
if (!trimmed) return;
|
|
906
|
+
setSubmitting(true);
|
|
907
|
+
try {
|
|
908
|
+
await onAdd(selected.line, selected.column, trimmed);
|
|
909
|
+
setDraft('');
|
|
910
|
+
} finally {
|
|
911
|
+
setSubmitting(false);
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
return (
|
|
916
|
+
<Section title={t.inspector.leaveComment}>
|
|
917
|
+
<div className="flex flex-col gap-2">
|
|
918
|
+
<div ref={wrapRef} className="comment-cue rounded-[6px]">
|
|
919
|
+
<Textarea
|
|
920
|
+
value={draft}
|
|
921
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
922
|
+
onKeyDown={(e) => {
|
|
923
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
924
|
+
e.preventDefault();
|
|
925
|
+
submit();
|
|
926
|
+
}
|
|
927
|
+
}}
|
|
928
|
+
placeholder={t.inspector.commentPlaceholder}
|
|
929
|
+
className="min-h-16 resize-none text-[12px]"
|
|
930
|
+
/>
|
|
931
|
+
</div>
|
|
932
|
+
<div className="flex items-center justify-between gap-2">
|
|
933
|
+
<span className="font-mono text-[10.5px] text-muted-foreground/70">
|
|
934
|
+
{t.inspector.commentShortcutHint}
|
|
935
|
+
</span>
|
|
936
|
+
<Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
|
|
937
|
+
{t.inspector.addComment}
|
|
938
|
+
</Button>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
</Section>
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function readSnapshot(el: HTMLElement): ElementSnapshot {
|
|
946
|
+
const cs = getComputedStyle(el);
|
|
947
|
+
const text = isSimpleTextElement(el) ? readEditableText(el) : null;
|
|
948
|
+
const imageSrc =
|
|
949
|
+
el.tagName === 'IMG'
|
|
950
|
+
? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
|
|
951
|
+
: null;
|
|
952
|
+
const ph = el.dataset.slidePlaceholder ?? null;
|
|
953
|
+
const placeholder =
|
|
954
|
+
ph !== null
|
|
955
|
+
? {
|
|
956
|
+
hint: ph,
|
|
957
|
+
width: el.dataset.placeholderW ? Number(el.dataset.placeholderW) : undefined,
|
|
958
|
+
height: el.dataset.placeholderH ? Number(el.dataset.placeholderH) : undefined,
|
|
959
|
+
}
|
|
960
|
+
: null;
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
fontSize: parseFloat(cs.fontSize) || 16,
|
|
964
|
+
fontWeight: parseInt(cs.fontWeight, 10) || 400,
|
|
965
|
+
fontStyle: cs.fontStyle === 'italic' ? 'italic' : 'normal',
|
|
966
|
+
color: rgbToHex(cs.color) ?? '#000000',
|
|
967
|
+
backgroundColor: isTransparent(cs.backgroundColor) ? null : rgbToHex(cs.backgroundColor),
|
|
968
|
+
textAlign: normalizeTextAlign(cs.textAlign),
|
|
969
|
+
lineHeight: parseLineHeight(cs.lineHeight, parseFloat(cs.fontSize) || 16),
|
|
970
|
+
letterSpacing: parseLetterSpacing(cs.letterSpacing),
|
|
971
|
+
text,
|
|
972
|
+
imageSrc,
|
|
973
|
+
placeholder,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function isSimpleTextElement(el: HTMLElement): boolean {
|
|
978
|
+
if (el.childNodes.length === 0) return true;
|
|
979
|
+
return hasOnlyInlineTextChildren(el);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const INLINE_TEXT_TAGS = new Set([
|
|
983
|
+
'B',
|
|
984
|
+
'CODE',
|
|
985
|
+
'DEL',
|
|
986
|
+
'EM',
|
|
987
|
+
'I',
|
|
988
|
+
'INS',
|
|
989
|
+
'MARK',
|
|
990
|
+
'S',
|
|
991
|
+
'SMALL',
|
|
992
|
+
'SPAN',
|
|
993
|
+
'STRONG',
|
|
994
|
+
'SUB',
|
|
995
|
+
'SUP',
|
|
996
|
+
'U',
|
|
997
|
+
]);
|
|
998
|
+
|
|
999
|
+
function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
|
|
1000
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1001
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1002
|
+
continue;
|
|
1003
|
+
} else if (child instanceof HTMLElement) {
|
|
1004
|
+
if (child.tagName === 'BR') continue;
|
|
1005
|
+
if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
|
|
1006
|
+
}
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function readEditableText(el: HTMLElement): string {
|
|
1013
|
+
const parts: string[] = [];
|
|
1014
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1015
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1016
|
+
parts.push(renderedTextNodeValue(child as Text));
|
|
1017
|
+
} else if (child instanceof HTMLBRElement) {
|
|
1018
|
+
parts.push('\n');
|
|
1019
|
+
} else if (child instanceof HTMLElement) {
|
|
1020
|
+
parts.push(readEditableText(child));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return normalizeRenderedText(parts);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function normalizeRenderedText(parts: string[]): string {
|
|
1027
|
+
return parts
|
|
1028
|
+
.map((part, index) => {
|
|
1029
|
+
if (part === '\n') return part;
|
|
1030
|
+
let next = part;
|
|
1031
|
+
if (parts[index - 1] === '\n') next = next.replace(/^\s+/, '');
|
|
1032
|
+
if (parts[index + 1] === '\n') next = next.replace(/\s+$/, '');
|
|
1033
|
+
return next;
|
|
1034
|
+
})
|
|
1035
|
+
.join('');
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function renderedTextNodeValue(node: Text): string {
|
|
1039
|
+
const value = node.textContent ?? '';
|
|
1040
|
+
const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
|
|
1041
|
+
if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
|
|
1042
|
+
return value;
|
|
1043
|
+
}
|
|
1044
|
+
return value.replace(/\s+/g, ' ');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function rgbToHex(value: string): string | null {
|
|
1048
|
+
const m = value.match(/^rgba?\(([^)]+)\)$/);
|
|
1049
|
+
if (!m) return null;
|
|
1050
|
+
const parts = m[1].split(',').map((s) => s.trim());
|
|
1051
|
+
if (parts.length < 3) return null;
|
|
1052
|
+
const r = clampByte(Number(parts[0]));
|
|
1053
|
+
const g = clampByte(Number(parts[1]));
|
|
1054
|
+
const b = clampByte(Number(parts[2]));
|
|
1055
|
+
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function clampByte(n: number): number {
|
|
1059
|
+
return Math.max(0, Math.min(255, Math.round(Number.isFinite(n) ? n : 0)));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function isTransparent(value: string): boolean {
|
|
1063
|
+
if (!value) return true;
|
|
1064
|
+
if (value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return true;
|
|
1065
|
+
const m = value.match(/^rgba\([^)]*,\s*0\)$/);
|
|
1066
|
+
return Boolean(m);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function normalizeTextAlign(v: string): ElementSnapshot['textAlign'] {
|
|
1070
|
+
if (v === 'center' || v === 'right' || v === 'justify') return v;
|
|
1071
|
+
return 'left';
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function parseLineHeight(value: string, fontSize: number): number | null {
|
|
1075
|
+
if (!value || value === 'normal') return null;
|
|
1076
|
+
const n = parseFloat(value);
|
|
1077
|
+
if (!Number.isFinite(n) || n === 0) return null;
|
|
1078
|
+
return round2(n / fontSize);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function parseLetterSpacing(value: string): number {
|
|
1082
|
+
if (!value || value === 'normal') return 0;
|
|
1083
|
+
const n = parseFloat(value);
|
|
1084
|
+
return Number.isFinite(n) ? round2(n) : 0;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function round2(n: number): number {
|
|
1088
|
+
return Math.round(n * 100) / 100;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function findElementByLine(slideId: string, line: number, column: number): HTMLElement | null {
|
|
1092
|
+
const root = document.querySelector('[data-inspector-root]');
|
|
1093
|
+
if (!root) return null;
|
|
1094
|
+
const tagged = root.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`);
|
|
1095
|
+
if (tagged) return tagged;
|
|
1096
|
+
const candidates = root.querySelectorAll<HTMLElement>('*');
|
|
1097
|
+
for (const el of candidates) {
|
|
1098
|
+
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
1099
|
+
if (hit && hit.line === line) return hit.anchor;
|
|
1100
|
+
}
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function useReloadCounter(): number {
|
|
1105
|
+
const [n, setN] = useState(0);
|
|
1106
|
+
useEffect(() => {
|
|
1107
|
+
if (!import.meta.hot) return;
|
|
1108
|
+
const handler = () => setN((x) => x + 1);
|
|
1109
|
+
import.meta.hot.on('vite:afterUpdate', handler);
|
|
1110
|
+
return () => {
|
|
1111
|
+
import.meta.hot?.off('vite:afterUpdate', handler);
|
|
1112
|
+
};
|
|
1113
|
+
}, []);
|
|
1114
|
+
return n;
|
|
1115
|
+
}
|