@open-slide/core 1.2.0 → 1.4.0
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/dist/{build-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
- package/dist/cli/bin.js +5 -5
- package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
- package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
- package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
- package/dist/en-7GU-DHbJ.js +361 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +229 -39
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +136 -342
- package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
- package/dist/sync-j9_QPovT.js +3 -0
- package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +2 -2
- package/package.json +9 -1
- package/skills/create-slide/SKILL.md +1 -1
- package/skills/create-theme/SKILL.md +60 -12
- package/skills/slide-authoring/SKILL.md +21 -2
- package/src/app/app.tsx +13 -1
- package/src/app/components/asset-view.tsx +37 -22
- package/src/app/components/image-placeholder.tsx +123 -1
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +370 -30
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/sidebar/folder-item.tsx +27 -5
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar.tsx +20 -0
- package/src/app/components/themes/theme-detail.tsx +300 -0
- package/src/app/components/themes/themes-gallery.tsx +146 -0
- package/src/app/components/thumbnail-rail.tsx +17 -5
- package/src/app/lib/assets.ts +55 -2
- package/src/app/lib/export-pdf.ts +6 -0
- package/src/app/lib/inspector/use-editor.ts +9 -1
- package/src/app/lib/sdk.ts +1 -0
- package/src/app/lib/slides.ts +17 -1
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +194 -0
- package/src/app/routes/home.tsx +89 -207
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +217 -54
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/virtual.d.ts +20 -0
- package/src/locale/en.ts +49 -7
- package/src/locale/ja.ts +50 -7
- package/src/locale/types.ts +44 -2
- package/src/locale/zh-cn.ts +49 -8
- package/src/locale/zh-tw.ts +49 -8
- package/dist/sync-B4eLo2H6.js +0 -3
- /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
- /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
|
@@ -31,7 +31,7 @@ export function InspectOverlay() {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const onMove = (e: PointerEvent) => {
|
|
34
|
-
const el = pickElement(e.clientX, e.clientY);
|
|
34
|
+
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
|
|
35
35
|
if (!el) return setHover(null);
|
|
36
36
|
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
37
37
|
if (!hit) return setHover(null);
|
|
@@ -40,7 +40,7 @@ export function InspectOverlay() {
|
|
|
40
40
|
|
|
41
41
|
const onClick = (e: MouseEvent) => {
|
|
42
42
|
if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
|
|
43
|
-
const el = pickElement(e.clientX, e.clientY);
|
|
43
|
+
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
|
|
44
44
|
if (!el) return;
|
|
45
45
|
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
46
46
|
if (!hit) return;
|
|
@@ -52,7 +52,7 @@ export function InspectOverlay() {
|
|
|
52
52
|
|
|
53
53
|
const onDblClick = (e: MouseEvent) => {
|
|
54
54
|
if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
|
|
55
|
-
const el = pickElement(e.clientX, e.clientY);
|
|
55
|
+
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
|
|
56
56
|
if (!el) return;
|
|
57
57
|
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
58
58
|
if (!hit) return;
|
|
@@ -221,3 +221,49 @@ function pickElement(x: number, y: number): HTMLElement | null {
|
|
|
221
221
|
}
|
|
222
222
|
return null;
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
const INLINE_TEXT_TAGS = new Set([
|
|
226
|
+
'B',
|
|
227
|
+
'CODE',
|
|
228
|
+
'DEL',
|
|
229
|
+
'EM',
|
|
230
|
+
'I',
|
|
231
|
+
'INS',
|
|
232
|
+
'MARK',
|
|
233
|
+
'S',
|
|
234
|
+
'SMALL',
|
|
235
|
+
'SPAN',
|
|
236
|
+
'STRONG',
|
|
237
|
+
'SUB',
|
|
238
|
+
'SUP',
|
|
239
|
+
'U',
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
function pickInspectorTarget(el: HTMLElement | null): HTMLElement | null {
|
|
243
|
+
if (!el) return null;
|
|
244
|
+
const root = el.closest('[data-inspector-root]');
|
|
245
|
+
const startedOnInlineText = INLINE_TEXT_TAGS.has(el.tagName);
|
|
246
|
+
for (let cur: HTMLElement | null = el; cur && root?.contains(cur); cur = cur.parentElement) {
|
|
247
|
+
if (startedOnInlineText && INLINE_TEXT_TAGS.has(cur.tagName)) continue;
|
|
248
|
+
if (isEditableTextContainer(cur)) return cur;
|
|
249
|
+
}
|
|
250
|
+
return el;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isEditableTextContainer(el: HTMLElement): boolean {
|
|
254
|
+
if (!el.textContent?.trim()) return false;
|
|
255
|
+
return hasOnlyInlineTextChildren(el);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
|
|
259
|
+
for (const child of Array.from(el.childNodes)) {
|
|
260
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
261
|
+
continue;
|
|
262
|
+
} else if (child instanceof HTMLElement) {
|
|
263
|
+
if (child.tagName === 'BR') continue;
|
|
264
|
+
if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
@@ -3,13 +3,17 @@ import {
|
|
|
3
3
|
AlignJustify,
|
|
4
4
|
AlignLeft,
|
|
5
5
|
AlignRight,
|
|
6
|
+
ArrowDownToLine,
|
|
6
7
|
Bold,
|
|
7
8
|
Crop,
|
|
8
9
|
ImageIcon,
|
|
9
10
|
Italic,
|
|
11
|
+
Loader2,
|
|
12
|
+
Upload,
|
|
10
13
|
X,
|
|
11
14
|
} from 'lucide-react';
|
|
12
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
16
|
+
import { toast } from 'sonner';
|
|
13
17
|
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
14
18
|
import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
|
|
15
19
|
import { Button } from '@/components/ui/button';
|
|
@@ -30,14 +34,16 @@ import {
|
|
|
30
34
|
} from '@/components/ui/select';
|
|
31
35
|
import { Separator } from '@/components/ui/separator';
|
|
32
36
|
import { Slider } from '@/components/ui/slider';
|
|
37
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
33
38
|
import { Textarea } from '@/components/ui/textarea';
|
|
34
39
|
import { Toggle } from '@/components/ui/toggle';
|
|
35
40
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
36
41
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
37
|
-
import { type AssetEntry, useAssets } from '@/lib/assets';
|
|
42
|
+
import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
|
|
38
43
|
import { findSlideSource } from '@/lib/inspector/fiber';
|
|
39
44
|
import type { EditOp } from '@/lib/inspector/use-editor';
|
|
40
|
-
import {
|
|
45
|
+
import { useAgentSocketConnected } from '@/lib/use-agent-socket';
|
|
46
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
41
47
|
import { cn } from '@/lib/utils';
|
|
42
48
|
import type { Locale } from '../../../locale/types';
|
|
43
49
|
import { type SelectedTarget, useInspector } from './inspector-provider';
|
|
@@ -56,13 +62,41 @@ type ElementSnapshot = {
|
|
|
56
62
|
placeholder: { hint: string; width?: number; height?: number } | null;
|
|
57
63
|
};
|
|
58
64
|
|
|
65
|
+
type ContentSelection = { start: number; end: number };
|
|
66
|
+
type StylePreview = Partial<
|
|
67
|
+
Pick<ElementSnapshot, 'fontSize' | 'fontWeight' | 'fontStyle' | 'color'>
|
|
68
|
+
>;
|
|
69
|
+
type RangeStylePreview = {
|
|
70
|
+
anchor: HTMLElement;
|
|
71
|
+
start: number;
|
|
72
|
+
end: number;
|
|
73
|
+
values: StylePreview;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function resolveSelectedTarget(target: SelectedTarget, slideId: string): SelectedTarget {
|
|
77
|
+
const hit = findSlideSource(target.anchor, slideId, { hostOnly: true });
|
|
78
|
+
if (!hit) return target;
|
|
79
|
+
if (hit.line === target.line && hit.column === target.column && hit.anchor === target.anchor) {
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
return { line: hit.line, column: hit.column, anchor: hit.anchor };
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
export function InspectorPanel() {
|
|
60
86
|
const { active, slideId, selected, setSelected, bufferOps, pendingCount, add, applyEdit } =
|
|
61
87
|
useInspector();
|
|
62
88
|
const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
|
|
89
|
+
const [contentSelection, setContentSelection] = useState<ContentSelection | null>(null);
|
|
90
|
+
const [rangeStylePreview, setRangeStylePreview] = useState<RangeStylePreview | null>(null);
|
|
63
91
|
const reloadCounter = useReloadCounter();
|
|
64
92
|
const t = useLocale();
|
|
65
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
void selected;
|
|
96
|
+
setContentSelection(null);
|
|
97
|
+
setRangeStylePreview(null);
|
|
98
|
+
}, [selected]);
|
|
99
|
+
|
|
66
100
|
useEffect(() => {
|
|
67
101
|
void reloadCounter;
|
|
68
102
|
void pendingCount;
|
|
@@ -110,10 +144,12 @@ export function InspectorPanel() {
|
|
|
110
144
|
const apply = useCallback(
|
|
111
145
|
(ops: EditOp[]) => {
|
|
112
146
|
if (!selected) return;
|
|
113
|
-
|
|
114
|
-
if (selected
|
|
147
|
+
const target = resolveSelectedTarget(selected, slideId);
|
|
148
|
+
if (target !== selected) setSelected(target);
|
|
149
|
+
bufferOps(target.line, target.column, target.anchor, ops);
|
|
150
|
+
if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
|
|
115
151
|
},
|
|
116
|
-
[selected, bufferOps],
|
|
152
|
+
[selected, setSelected, slideId, bufferOps],
|
|
117
153
|
);
|
|
118
154
|
|
|
119
155
|
// `pinned` keeps the last selection rendered through the close-out
|
|
@@ -135,6 +171,76 @@ export function InspectorPanel() {
|
|
|
135
171
|
|
|
136
172
|
if (!pinned) return null;
|
|
137
173
|
const { s: pinSelected, n: pinSnapshot } = pinned;
|
|
174
|
+
const contentRange =
|
|
175
|
+
pinSnapshot.text !== null && contentSelection && contentSelection.end > contentSelection.start
|
|
176
|
+
? contentSelection
|
|
177
|
+
: null;
|
|
178
|
+
const rangePreviewApplies =
|
|
179
|
+
contentRange &&
|
|
180
|
+
rangeStylePreview &&
|
|
181
|
+
rangeStylePreview.anchor === pinSelected.anchor &&
|
|
182
|
+
rangeStylePreview.start === contentRange.start &&
|
|
183
|
+
rangeStylePreview.end === contentRange.end;
|
|
184
|
+
const typographySnapshot = rangePreviewApplies
|
|
185
|
+
? withStylePreview(pinSnapshot, rangeStylePreview.values)
|
|
186
|
+
: pinSnapshot;
|
|
187
|
+
const applyTextStyle = (ops: EditOp[]) => {
|
|
188
|
+
const styleOps = ops.flatMap((op) => (op.kind === 'set-style' ? [op] : []));
|
|
189
|
+
const target = resolveSelectedTarget(pinSelected, slideId);
|
|
190
|
+
if (target !== pinSelected) setSelected(target);
|
|
191
|
+
if (
|
|
192
|
+
contentRange &&
|
|
193
|
+
pinSnapshot.text !== null &&
|
|
194
|
+
styleOps.length === 1 &&
|
|
195
|
+
styleOps.length === ops.length &&
|
|
196
|
+
styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
|
|
197
|
+
) {
|
|
198
|
+
bufferOps(
|
|
199
|
+
target.line,
|
|
200
|
+
target.column,
|
|
201
|
+
target.anchor,
|
|
202
|
+
styleOps.map((op) => ({
|
|
203
|
+
kind: 'set-text-range-style',
|
|
204
|
+
start: contentRange.start,
|
|
205
|
+
end: contentRange.end,
|
|
206
|
+
key: op.key,
|
|
207
|
+
value: op.value,
|
|
208
|
+
prevText: pinSnapshot.text ?? undefined,
|
|
209
|
+
})),
|
|
210
|
+
);
|
|
211
|
+
setRangeStylePreview((current) => ({
|
|
212
|
+
anchor: target.anchor,
|
|
213
|
+
start: contentRange.start,
|
|
214
|
+
end: contentRange.end,
|
|
215
|
+
values: {
|
|
216
|
+
...(current?.anchor === target.anchor &&
|
|
217
|
+
current.start === contentRange.start &&
|
|
218
|
+
current.end === contentRange.end
|
|
219
|
+
? current.values
|
|
220
|
+
: {}),
|
|
221
|
+
...stylePreviewFromOps(styleOps),
|
|
222
|
+
},
|
|
223
|
+
}));
|
|
224
|
+
if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
pinSnapshot.text !== null &&
|
|
229
|
+
styleOps.length > 0 &&
|
|
230
|
+
styleOps.length === ops.length &&
|
|
231
|
+
styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
|
|
232
|
+
) {
|
|
233
|
+
bufferOps(
|
|
234
|
+
target.line,
|
|
235
|
+
target.column,
|
|
236
|
+
target.anchor,
|
|
237
|
+
styleOps.map((op) => ({ ...op, prevText: pinSnapshot.text ?? undefined })),
|
|
238
|
+
);
|
|
239
|
+
if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
apply(ops);
|
|
243
|
+
};
|
|
138
244
|
|
|
139
245
|
return (
|
|
140
246
|
<PanelShell
|
|
@@ -169,16 +275,20 @@ export function InspectorPanel() {
|
|
|
169
275
|
>
|
|
170
276
|
{pinSnapshot.text !== null && (
|
|
171
277
|
<Section title={t.inspector.contentSection}>
|
|
172
|
-
<ContentField
|
|
278
|
+
<ContentField
|
|
279
|
+
snapshot={pinSnapshot}
|
|
280
|
+
apply={apply}
|
|
281
|
+
onSelectionChange={setContentSelection}
|
|
282
|
+
/>
|
|
173
283
|
</Section>
|
|
174
284
|
)}
|
|
175
285
|
|
|
176
286
|
<Separator />
|
|
177
287
|
|
|
178
288
|
<Section title={t.inspector.typographySection}>
|
|
179
|
-
<FontSizeField snapshot={
|
|
180
|
-
<FontWeightField snapshot={
|
|
181
|
-
<StyleToggles snapshot={
|
|
289
|
+
<FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
290
|
+
<FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
291
|
+
<StyleToggles snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
182
292
|
<LineHeightField snapshot={pinSnapshot} apply={apply} />
|
|
183
293
|
<LetterSpacingField snapshot={pinSnapshot} apply={apply} />
|
|
184
294
|
<TextAlignField snapshot={pinSnapshot} apply={apply} />
|
|
@@ -189,8 +299,8 @@ export function InspectorPanel() {
|
|
|
189
299
|
<Section title={t.inspector.colorSection}>
|
|
190
300
|
<ColorField
|
|
191
301
|
label={t.inspector.textColor}
|
|
192
|
-
value={
|
|
193
|
-
onChange={(v) =>
|
|
302
|
+
value={typographySnapshot.color}
|
|
303
|
+
onChange={(v) => applyTextStyle([{ kind: 'set-style', key: 'color', value: v }])}
|
|
194
304
|
clearable={false}
|
|
195
305
|
/>
|
|
196
306
|
<ColorField
|
|
@@ -249,12 +359,43 @@ const EDITING_FREEZE_CSS = `
|
|
|
249
359
|
}
|
|
250
360
|
`;
|
|
251
361
|
|
|
362
|
+
const INLINE_CONTENT_STYLE_KEYS = new Set([
|
|
363
|
+
'fontSize',
|
|
364
|
+
'fontWeight',
|
|
365
|
+
'fontStyle',
|
|
366
|
+
'fontFamily',
|
|
367
|
+
'color',
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
function stylePreviewFromOps(ops: Array<Extract<EditOp, { kind: 'set-style' }>>): StylePreview {
|
|
371
|
+
const preview: StylePreview = {};
|
|
372
|
+
for (const op of ops) {
|
|
373
|
+
if (op.key === 'fontSize' && op.value) {
|
|
374
|
+
const n = parseFloat(op.value);
|
|
375
|
+
if (Number.isFinite(n)) preview.fontSize = n;
|
|
376
|
+
} else if (op.key === 'fontWeight') {
|
|
377
|
+
preview.fontWeight = op.value ? Number(op.value) || 400 : 400;
|
|
378
|
+
} else if (op.key === 'fontStyle') {
|
|
379
|
+
preview.fontStyle = op.value === 'italic' ? 'italic' : 'normal';
|
|
380
|
+
} else if (op.key === 'color' && op.value) {
|
|
381
|
+
preview.color = op.value;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return preview;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function withStylePreview(snapshot: ElementSnapshot, preview: StylePreview): ElementSnapshot {
|
|
388
|
+
return { ...snapshot, ...preview };
|
|
389
|
+
}
|
|
390
|
+
|
|
252
391
|
function ContentField({
|
|
253
392
|
snapshot,
|
|
254
393
|
apply,
|
|
394
|
+
onSelectionChange,
|
|
255
395
|
}: {
|
|
256
396
|
snapshot: ElementSnapshot;
|
|
257
397
|
apply: (ops: EditOp[]) => void;
|
|
398
|
+
onSelectionChange?: (selection: ContentSelection | null) => void;
|
|
258
399
|
}) {
|
|
259
400
|
// Mirror the value locally and skip syncs during IME composition;
|
|
260
401
|
// a re-render mid-composition would otherwise clobber in-progress
|
|
@@ -267,6 +408,12 @@ function ContentField({
|
|
|
267
408
|
if (!composingRef.current) setLocal(snapshot.text ?? '');
|
|
268
409
|
}, [snapshot.text]);
|
|
269
410
|
|
|
411
|
+
const reportSelection = (el: HTMLTextAreaElement) => {
|
|
412
|
+
const start = el.selectionStart ?? 0;
|
|
413
|
+
const end = el.selectionEnd ?? start;
|
|
414
|
+
onSelectionChange?.(end > start ? { start, end } : null);
|
|
415
|
+
};
|
|
416
|
+
|
|
270
417
|
return (
|
|
271
418
|
<Textarea
|
|
272
419
|
value={local}
|
|
@@ -277,17 +424,23 @@ function ContentField({
|
|
|
277
424
|
composingRef.current = false;
|
|
278
425
|
const v = e.currentTarget.value;
|
|
279
426
|
setLocal(v);
|
|
427
|
+
reportSelection(e.currentTarget);
|
|
280
428
|
apply([{ kind: 'set-text', value: v }]);
|
|
281
429
|
}}
|
|
282
430
|
onChange={(e) => {
|
|
283
431
|
const v = e.target.value;
|
|
284
432
|
setLocal(v);
|
|
433
|
+
reportSelection(e.currentTarget);
|
|
285
434
|
if (!composingRef.current) {
|
|
286
435
|
apply([{ kind: 'set-text', value: v }]);
|
|
287
436
|
}
|
|
288
437
|
}}
|
|
438
|
+
onKeyUp={(e) => reportSelection(e.currentTarget)}
|
|
439
|
+
onMouseUp={(e) => reportSelection(e.currentTarget)}
|
|
440
|
+
onSelect={(e) => reportSelection(e.currentTarget)}
|
|
441
|
+
wrap="off"
|
|
289
442
|
rows={3}
|
|
290
|
-
className="min-h-16 resize-none text-xs"
|
|
443
|
+
className="field-sizing-fixed min-h-16 w-full resize-none overflow-x-auto whitespace-pre text-xs"
|
|
291
444
|
placeholder={t.inspector.elementTextPlaceholder}
|
|
292
445
|
/>
|
|
293
446
|
);
|
|
@@ -652,13 +805,15 @@ function ImageField({
|
|
|
652
805
|
<AssetPickerDialog
|
|
653
806
|
slideId={slideId}
|
|
654
807
|
onClose={() => setOpen(false)}
|
|
655
|
-
onPick={(asset) => {
|
|
808
|
+
onPick={(asset, scope) => {
|
|
656
809
|
setOpen(false);
|
|
810
|
+
const assetPath =
|
|
811
|
+
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
657
812
|
const ops: EditOp[] = [
|
|
658
813
|
{
|
|
659
814
|
kind: 'set-attr-asset',
|
|
660
815
|
attr: 'src',
|
|
661
|
-
assetPath
|
|
816
|
+
assetPath,
|
|
662
817
|
previewUrl: asset.url,
|
|
663
818
|
},
|
|
664
819
|
];
|
|
@@ -717,14 +872,16 @@ function PlaceholderField({
|
|
|
717
872
|
<AssetPickerDialog
|
|
718
873
|
slideId={slideId}
|
|
719
874
|
onClose={() => setOpen(false)}
|
|
720
|
-
onPick={async (asset) => {
|
|
875
|
+
onPick={async (asset, scope) => {
|
|
721
876
|
setOpen(false);
|
|
722
877
|
setSubmitting(true);
|
|
723
878
|
try {
|
|
879
|
+
const assetPath =
|
|
880
|
+
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
724
881
|
await applyEdit(line, column, [
|
|
725
882
|
{
|
|
726
883
|
kind: 'replace-placeholder-with-image',
|
|
727
|
-
assetPath
|
|
884
|
+
assetPath,
|
|
728
885
|
},
|
|
729
886
|
]);
|
|
730
887
|
} finally {
|
|
@@ -737,6 +894,9 @@ function PlaceholderField({
|
|
|
737
894
|
);
|
|
738
895
|
}
|
|
739
896
|
|
|
897
|
+
type PickerScope = 'slide' | 'global';
|
|
898
|
+
const GLOBAL_PICKER_SLIDE_ID = '@global';
|
|
899
|
+
|
|
740
900
|
function AssetPickerDialog({
|
|
741
901
|
slideId,
|
|
742
902
|
onClose,
|
|
@@ -744,13 +904,39 @@ function AssetPickerDialog({
|
|
|
744
904
|
}: {
|
|
745
905
|
slideId: string;
|
|
746
906
|
onClose: () => void;
|
|
747
|
-
onPick: (asset: AssetEntry) => void;
|
|
907
|
+
onPick: (asset: AssetEntry, scope: PickerScope) => void;
|
|
748
908
|
}) {
|
|
749
|
-
const
|
|
909
|
+
const [scope, setScope] = useState<PickerScope>('slide');
|
|
910
|
+
const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
|
|
911
|
+
const { assets, loading, refresh } = useAssets(effectiveSlideId);
|
|
750
912
|
const images = assets.filter((a) => a.mime.startsWith('image/'));
|
|
751
913
|
const t = useLocale();
|
|
752
|
-
const path = `slides/${slideId}/assets/`;
|
|
914
|
+
const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
|
|
753
915
|
const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
|
|
916
|
+
const [uploading, setUploading] = useState(false);
|
|
917
|
+
const [dragActive, setDragActive] = useState(false);
|
|
918
|
+
const dragDepth = useRef(0);
|
|
919
|
+
const inputId = useId();
|
|
920
|
+
|
|
921
|
+
const handleFile = useCallback(
|
|
922
|
+
async (file: File) => {
|
|
923
|
+
if (!file.type.startsWith('image/')) return;
|
|
924
|
+
setUploading(true);
|
|
925
|
+
try {
|
|
926
|
+
const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
|
|
927
|
+
if (!ok || !entry) {
|
|
928
|
+
toast.error(format(t.asset.toastUploadFailed, { status }));
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
await refresh().catch(() => {});
|
|
932
|
+
onPick(entry, scope);
|
|
933
|
+
} finally {
|
|
934
|
+
setUploading(false);
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
[effectiveSlideId, scope, refresh, onPick, t],
|
|
938
|
+
);
|
|
939
|
+
|
|
754
940
|
return (
|
|
755
941
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
756
942
|
<DialogContent className="sm:max-w-xl">
|
|
@@ -762,7 +948,66 @@ function AssetPickerDialog({
|
|
|
762
948
|
{descSuffix}
|
|
763
949
|
</DialogDescription>
|
|
764
950
|
</DialogHeader>
|
|
765
|
-
<
|
|
951
|
+
<Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
|
|
952
|
+
<TabsList>
|
|
953
|
+
<TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
|
|
954
|
+
<TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
|
|
955
|
+
</TabsList>
|
|
956
|
+
</Tabs>
|
|
957
|
+
<label
|
|
958
|
+
htmlFor={inputId}
|
|
959
|
+
className={cn(
|
|
960
|
+
'absolute right-12 top-3.5 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[5px] border border-border bg-card px-2 text-[12px] font-medium transition-colors',
|
|
961
|
+
'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
|
|
962
|
+
uploading && 'pointer-events-none opacity-60',
|
|
963
|
+
)}
|
|
964
|
+
>
|
|
965
|
+
{uploading ? (
|
|
966
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
967
|
+
) : (
|
|
968
|
+
<Upload className="size-3.5" />
|
|
969
|
+
)}
|
|
970
|
+
<span>{t.asset.upload}</span>
|
|
971
|
+
</label>
|
|
972
|
+
<input
|
|
973
|
+
id={inputId}
|
|
974
|
+
type="file"
|
|
975
|
+
accept="image/*"
|
|
976
|
+
className="sr-only"
|
|
977
|
+
disabled={uploading}
|
|
978
|
+
onChange={(e) => {
|
|
979
|
+
const file = e.target.files?.[0];
|
|
980
|
+
e.target.value = '';
|
|
981
|
+
if (file) handleFile(file).catch(() => {});
|
|
982
|
+
}}
|
|
983
|
+
/>
|
|
984
|
+
<section
|
|
985
|
+
aria-label={t.inspector.replaceImageDialogTitle}
|
|
986
|
+
className="relative max-h-[60vh] overflow-y-auto"
|
|
987
|
+
onDragEnter={(e) => {
|
|
988
|
+
if (uploading || !hasFiles(e)) return;
|
|
989
|
+
e.preventDefault();
|
|
990
|
+
dragDepth.current += 1;
|
|
991
|
+
setDragActive(true);
|
|
992
|
+
}}
|
|
993
|
+
onDragOver={(e) => {
|
|
994
|
+
if (uploading || !hasFiles(e)) return;
|
|
995
|
+
e.preventDefault();
|
|
996
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
997
|
+
}}
|
|
998
|
+
onDragLeave={() => {
|
|
999
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
1000
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
1001
|
+
}}
|
|
1002
|
+
onDrop={(e) => {
|
|
1003
|
+
if (uploading || !hasFiles(e)) return;
|
|
1004
|
+
e.preventDefault();
|
|
1005
|
+
dragDepth.current = 0;
|
|
1006
|
+
setDragActive(false);
|
|
1007
|
+
const file = e.dataTransfer.files?.[0];
|
|
1008
|
+
if (file) handleFile(file).catch(() => {});
|
|
1009
|
+
}}
|
|
1010
|
+
>
|
|
766
1011
|
{loading ? (
|
|
767
1012
|
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
|
768
1013
|
{t.inspector.pickerLoading}
|
|
@@ -777,7 +1022,7 @@ function AssetPickerDialog({
|
|
|
777
1022
|
<button
|
|
778
1023
|
key={asset.name}
|
|
779
1024
|
type="button"
|
|
780
|
-
onClick={() => onPick(asset)}
|
|
1025
|
+
onClick={() => onPick(asset, scope)}
|
|
781
1026
|
className={cn(
|
|
782
1027
|
'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
|
|
783
1028
|
'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
|
|
@@ -800,14 +1045,39 @@ function AssetPickerDialog({
|
|
|
800
1045
|
))}
|
|
801
1046
|
</div>
|
|
802
1047
|
)}
|
|
803
|
-
|
|
1048
|
+
{dragActive && (
|
|
1049
|
+
<div
|
|
1050
|
+
className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
|
|
1051
|
+
aria-hidden
|
|
1052
|
+
>
|
|
1053
|
+
<div className="absolute inset-0 bg-brand/5" />
|
|
1054
|
+
<div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
|
|
1055
|
+
<div className="absolute inset-x-0 bottom-4 flex justify-center">
|
|
1056
|
+
<div className="flex items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating">
|
|
1057
|
+
<ArrowDownToLine className="size-3.5 text-brand" />
|
|
1058
|
+
<span>{t.asset.dropToUpload}</span>
|
|
1059
|
+
</div>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
)}
|
|
1063
|
+
</section>
|
|
804
1064
|
</DialogContent>
|
|
805
1065
|
</Dialog>
|
|
806
1066
|
);
|
|
807
1067
|
}
|
|
808
1068
|
|
|
1069
|
+
function hasFiles(e: React.DragEvent): boolean {
|
|
1070
|
+
const types = e.dataTransfer?.types;
|
|
1071
|
+
if (!types) return false;
|
|
1072
|
+
for (let i = 0; i < types.length; i++) {
|
|
1073
|
+
if (types[i] === 'Files') return true;
|
|
1074
|
+
}
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
809
1078
|
function AgentWatchingBadge() {
|
|
810
1079
|
const t = useLocale();
|
|
1080
|
+
const connected = useAgentSocketConnected();
|
|
811
1081
|
return (
|
|
812
1082
|
<TooltipProvider delayDuration={200}>
|
|
813
1083
|
<Tooltip>
|
|
@@ -817,14 +1087,20 @@ function AgentWatchingBadge() {
|
|
|
817
1087
|
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"
|
|
818
1088
|
>
|
|
819
1089
|
<span aria-hidden className="relative flex size-1.5 items-center justify-center">
|
|
820
|
-
|
|
821
|
-
|
|
1090
|
+
{connected ? (
|
|
1091
|
+
<>
|
|
1092
|
+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
|
1093
|
+
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
|
|
1094
|
+
</>
|
|
1095
|
+
) : (
|
|
1096
|
+
<span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
|
|
1097
|
+
)}
|
|
822
1098
|
</span>
|
|
823
|
-
{t.inspector.agentWatching}
|
|
1099
|
+
{connected ? t.inspector.agentWatching : t.inspector.agentNotWatching}
|
|
824
1100
|
</button>
|
|
825
1101
|
</TooltipTrigger>
|
|
826
1102
|
<TooltipContent side="bottom" align="end" className="max-w-[260px] leading-relaxed">
|
|
827
|
-
{t.inspector.agentWatchingTooltip}
|
|
1103
|
+
{connected ? t.inspector.agentWatchingTooltip : t.inspector.agentNotWatchingTooltip}
|
|
828
1104
|
</TooltipContent>
|
|
829
1105
|
</Tooltip>
|
|
830
1106
|
</TooltipProvider>
|
|
@@ -886,7 +1162,7 @@ function CommentsSection({
|
|
|
886
1162
|
|
|
887
1163
|
function readSnapshot(el: HTMLElement): ElementSnapshot {
|
|
888
1164
|
const cs = getComputedStyle(el);
|
|
889
|
-
const text = isSimpleTextElement(el) ? (el
|
|
1165
|
+
const text = isSimpleTextElement(el) ? readEditableText(el) : null;
|
|
890
1166
|
const imageSrc =
|
|
891
1167
|
el.tagName === 'IMG'
|
|
892
1168
|
? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
|
|
@@ -918,8 +1194,72 @@ function readSnapshot(el: HTMLElement): ElementSnapshot {
|
|
|
918
1194
|
|
|
919
1195
|
function isSimpleTextElement(el: HTMLElement): boolean {
|
|
920
1196
|
if (el.childNodes.length === 0) return true;
|
|
921
|
-
|
|
922
|
-
|
|
1197
|
+
return hasOnlyInlineTextChildren(el);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const INLINE_TEXT_TAGS = new Set([
|
|
1201
|
+
'B',
|
|
1202
|
+
'CODE',
|
|
1203
|
+
'DEL',
|
|
1204
|
+
'EM',
|
|
1205
|
+
'I',
|
|
1206
|
+
'INS',
|
|
1207
|
+
'MARK',
|
|
1208
|
+
'S',
|
|
1209
|
+
'SMALL',
|
|
1210
|
+
'SPAN',
|
|
1211
|
+
'STRONG',
|
|
1212
|
+
'SUB',
|
|
1213
|
+
'SUP',
|
|
1214
|
+
'U',
|
|
1215
|
+
]);
|
|
1216
|
+
|
|
1217
|
+
function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
|
|
1218
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1219
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1220
|
+
continue;
|
|
1221
|
+
} else if (child instanceof HTMLElement) {
|
|
1222
|
+
if (child.tagName === 'BR') continue;
|
|
1223
|
+
if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
|
|
1224
|
+
}
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function readEditableText(el: HTMLElement): string {
|
|
1231
|
+
const parts: string[] = [];
|
|
1232
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1233
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1234
|
+
parts.push(renderedTextNodeValue(child as Text));
|
|
1235
|
+
} else if (child instanceof HTMLBRElement) {
|
|
1236
|
+
parts.push('\n');
|
|
1237
|
+
} else if (child instanceof HTMLElement) {
|
|
1238
|
+
parts.push(readEditableText(child));
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return normalizeRenderedText(parts);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function normalizeRenderedText(parts: string[]): string {
|
|
1245
|
+
return parts
|
|
1246
|
+
.map((part, index) => {
|
|
1247
|
+
if (part === '\n') return part;
|
|
1248
|
+
let next = part;
|
|
1249
|
+
if (parts[index - 1] === '\n') next = next.replace(/^\s+/, '');
|
|
1250
|
+
if (parts[index + 1] === '\n') next = next.replace(/\s+$/, '');
|
|
1251
|
+
return next;
|
|
1252
|
+
})
|
|
1253
|
+
.join('');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function renderedTextNodeValue(node: Text): string {
|
|
1257
|
+
const value = node.textContent ?? '';
|
|
1258
|
+
const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
|
|
1259
|
+
if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
|
|
1260
|
+
return value;
|
|
1261
|
+
}
|
|
1262
|
+
return value.replace(/\s+/g, ' ');
|
|
923
1263
|
}
|
|
924
1264
|
|
|
925
1265
|
function rgbToHex(value: string): string | null {
|