@open-slide/core 1.3.0 → 1.5.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-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
- package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
- package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
- package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +82 -13
- package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
- package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +19 -4
- package/src/app/app.tsx +2 -0
- package/src/app/components/asset-view.tsx +111 -18
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +267 -25
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/panel/panel-shell.tsx +5 -3
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/present/laser-pointer.tsx +3 -4
- package/src/app/components/present/progress-bar.tsx +4 -4
- package/src/app/components/sidebar/folder-item.tsx +14 -3
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/lib/assets.ts +21 -0
- 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 +2 -0
- package/src/app/lib/slides.ts +9 -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 +23 -2
- package/src/app/routes/home.tsx +101 -3
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +117 -39
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +28 -5
- package/src/locale/ja.ts +28 -5
- package/src/locale/types.ts +27 -1
- package/src/locale/zh-cn.ts +28 -6
- package/src/locale/zh-tw.ts +28 -6
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from '@/components/ui/select';
|
|
35
35
|
import { Separator } from '@/components/ui/separator';
|
|
36
36
|
import { Slider } from '@/components/ui/slider';
|
|
37
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
37
38
|
import { Textarea } from '@/components/ui/textarea';
|
|
38
39
|
import { Toggle } from '@/components/ui/toggle';
|
|
39
40
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
@@ -61,13 +62,41 @@ type ElementSnapshot = {
|
|
|
61
62
|
placeholder: { hint: string; width?: number; height?: number } | null;
|
|
62
63
|
};
|
|
63
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
|
+
|
|
64
85
|
export function InspectorPanel() {
|
|
65
86
|
const { active, slideId, selected, setSelected, bufferOps, pendingCount, add, applyEdit } =
|
|
66
87
|
useInspector();
|
|
67
88
|
const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
|
|
89
|
+
const [contentSelection, setContentSelection] = useState<ContentSelection | null>(null);
|
|
90
|
+
const [rangeStylePreview, setRangeStylePreview] = useState<RangeStylePreview | null>(null);
|
|
68
91
|
const reloadCounter = useReloadCounter();
|
|
69
92
|
const t = useLocale();
|
|
70
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
void selected;
|
|
96
|
+
setContentSelection(null);
|
|
97
|
+
setRangeStylePreview(null);
|
|
98
|
+
}, [selected]);
|
|
99
|
+
|
|
71
100
|
useEffect(() => {
|
|
72
101
|
void reloadCounter;
|
|
73
102
|
void pendingCount;
|
|
@@ -115,10 +144,12 @@ export function InspectorPanel() {
|
|
|
115
144
|
const apply = useCallback(
|
|
116
145
|
(ops: EditOp[]) => {
|
|
117
146
|
if (!selected) return;
|
|
118
|
-
|
|
119
|
-
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));
|
|
120
151
|
},
|
|
121
|
-
[selected, bufferOps],
|
|
152
|
+
[selected, setSelected, slideId, bufferOps],
|
|
122
153
|
);
|
|
123
154
|
|
|
124
155
|
// `pinned` keeps the last selection rendered through the close-out
|
|
@@ -140,6 +171,76 @@ export function InspectorPanel() {
|
|
|
140
171
|
|
|
141
172
|
if (!pinned) return null;
|
|
142
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
|
+
};
|
|
143
244
|
|
|
144
245
|
return (
|
|
145
246
|
<PanelShell
|
|
@@ -174,16 +275,20 @@ export function InspectorPanel() {
|
|
|
174
275
|
>
|
|
175
276
|
{pinSnapshot.text !== null && (
|
|
176
277
|
<Section title={t.inspector.contentSection}>
|
|
177
|
-
<ContentField
|
|
278
|
+
<ContentField
|
|
279
|
+
snapshot={pinSnapshot}
|
|
280
|
+
apply={apply}
|
|
281
|
+
onSelectionChange={setContentSelection}
|
|
282
|
+
/>
|
|
178
283
|
</Section>
|
|
179
284
|
)}
|
|
180
285
|
|
|
181
286
|
<Separator />
|
|
182
287
|
|
|
183
288
|
<Section title={t.inspector.typographySection}>
|
|
184
|
-
<FontSizeField snapshot={
|
|
185
|
-
<FontWeightField snapshot={
|
|
186
|
-
<StyleToggles snapshot={
|
|
289
|
+
<FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
290
|
+
<FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
291
|
+
<StyleToggles snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
187
292
|
<LineHeightField snapshot={pinSnapshot} apply={apply} />
|
|
188
293
|
<LetterSpacingField snapshot={pinSnapshot} apply={apply} />
|
|
189
294
|
<TextAlignField snapshot={pinSnapshot} apply={apply} />
|
|
@@ -194,8 +299,8 @@ export function InspectorPanel() {
|
|
|
194
299
|
<Section title={t.inspector.colorSection}>
|
|
195
300
|
<ColorField
|
|
196
301
|
label={t.inspector.textColor}
|
|
197
|
-
value={
|
|
198
|
-
onChange={(v) =>
|
|
302
|
+
value={typographySnapshot.color}
|
|
303
|
+
onChange={(v) => applyTextStyle([{ kind: 'set-style', key: 'color', value: v }])}
|
|
199
304
|
clearable={false}
|
|
200
305
|
/>
|
|
201
306
|
<ColorField
|
|
@@ -254,12 +359,43 @@ const EDITING_FREEZE_CSS = `
|
|
|
254
359
|
}
|
|
255
360
|
`;
|
|
256
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
|
+
|
|
257
391
|
function ContentField({
|
|
258
392
|
snapshot,
|
|
259
393
|
apply,
|
|
394
|
+
onSelectionChange,
|
|
260
395
|
}: {
|
|
261
396
|
snapshot: ElementSnapshot;
|
|
262
397
|
apply: (ops: EditOp[]) => void;
|
|
398
|
+
onSelectionChange?: (selection: ContentSelection | null) => void;
|
|
263
399
|
}) {
|
|
264
400
|
// Mirror the value locally and skip syncs during IME composition;
|
|
265
401
|
// a re-render mid-composition would otherwise clobber in-progress
|
|
@@ -272,6 +408,12 @@ function ContentField({
|
|
|
272
408
|
if (!composingRef.current) setLocal(snapshot.text ?? '');
|
|
273
409
|
}, [snapshot.text]);
|
|
274
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
|
+
|
|
275
417
|
return (
|
|
276
418
|
<Textarea
|
|
277
419
|
value={local}
|
|
@@ -282,17 +424,23 @@ function ContentField({
|
|
|
282
424
|
composingRef.current = false;
|
|
283
425
|
const v = e.currentTarget.value;
|
|
284
426
|
setLocal(v);
|
|
427
|
+
reportSelection(e.currentTarget);
|
|
285
428
|
apply([{ kind: 'set-text', value: v }]);
|
|
286
429
|
}}
|
|
287
430
|
onChange={(e) => {
|
|
288
431
|
const v = e.target.value;
|
|
289
432
|
setLocal(v);
|
|
433
|
+
reportSelection(e.currentTarget);
|
|
290
434
|
if (!composingRef.current) {
|
|
291
435
|
apply([{ kind: 'set-text', value: v }]);
|
|
292
436
|
}
|
|
293
437
|
}}
|
|
438
|
+
onKeyUp={(e) => reportSelection(e.currentTarget)}
|
|
439
|
+
onMouseUp={(e) => reportSelection(e.currentTarget)}
|
|
440
|
+
onSelect={(e) => reportSelection(e.currentTarget)}
|
|
441
|
+
wrap="off"
|
|
294
442
|
rows={3}
|
|
295
|
-
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"
|
|
296
444
|
placeholder={t.inspector.elementTextPlaceholder}
|
|
297
445
|
/>
|
|
298
446
|
);
|
|
@@ -657,13 +805,15 @@ function ImageField({
|
|
|
657
805
|
<AssetPickerDialog
|
|
658
806
|
slideId={slideId}
|
|
659
807
|
onClose={() => setOpen(false)}
|
|
660
|
-
onPick={(asset) => {
|
|
808
|
+
onPick={(asset, scope) => {
|
|
661
809
|
setOpen(false);
|
|
810
|
+
const assetPath =
|
|
811
|
+
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
662
812
|
const ops: EditOp[] = [
|
|
663
813
|
{
|
|
664
814
|
kind: 'set-attr-asset',
|
|
665
815
|
attr: 'src',
|
|
666
|
-
assetPath
|
|
816
|
+
assetPath,
|
|
667
817
|
previewUrl: asset.url,
|
|
668
818
|
},
|
|
669
819
|
];
|
|
@@ -722,14 +872,16 @@ function PlaceholderField({
|
|
|
722
872
|
<AssetPickerDialog
|
|
723
873
|
slideId={slideId}
|
|
724
874
|
onClose={() => setOpen(false)}
|
|
725
|
-
onPick={async (asset) => {
|
|
875
|
+
onPick={async (asset, scope) => {
|
|
726
876
|
setOpen(false);
|
|
727
877
|
setSubmitting(true);
|
|
728
878
|
try {
|
|
879
|
+
const assetPath =
|
|
880
|
+
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
729
881
|
await applyEdit(line, column, [
|
|
730
882
|
{
|
|
731
883
|
kind: 'replace-placeholder-with-image',
|
|
732
|
-
assetPath
|
|
884
|
+
assetPath,
|
|
733
885
|
},
|
|
734
886
|
]);
|
|
735
887
|
} finally {
|
|
@@ -742,6 +894,9 @@ function PlaceholderField({
|
|
|
742
894
|
);
|
|
743
895
|
}
|
|
744
896
|
|
|
897
|
+
type PickerScope = 'slide' | 'global';
|
|
898
|
+
const GLOBAL_PICKER_SLIDE_ID = '@global';
|
|
899
|
+
|
|
745
900
|
function AssetPickerDialog({
|
|
746
901
|
slideId,
|
|
747
902
|
onClose,
|
|
@@ -749,12 +904,14 @@ function AssetPickerDialog({
|
|
|
749
904
|
}: {
|
|
750
905
|
slideId: string;
|
|
751
906
|
onClose: () => void;
|
|
752
|
-
onPick: (asset: AssetEntry) => void;
|
|
907
|
+
onPick: (asset: AssetEntry, scope: PickerScope) => void;
|
|
753
908
|
}) {
|
|
754
|
-
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);
|
|
755
912
|
const images = assets.filter((a) => a.mime.startsWith('image/'));
|
|
756
913
|
const t = useLocale();
|
|
757
|
-
const path = `slides/${slideId}/assets/`;
|
|
914
|
+
const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
|
|
758
915
|
const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
|
|
759
916
|
const [uploading, setUploading] = useState(false);
|
|
760
917
|
const [dragActive, setDragActive] = useState(false);
|
|
@@ -766,18 +923,18 @@ function AssetPickerDialog({
|
|
|
766
923
|
if (!file.type.startsWith('image/')) return;
|
|
767
924
|
setUploading(true);
|
|
768
925
|
try {
|
|
769
|
-
const { ok, status, entry } = await uploadWithAutoRename(
|
|
926
|
+
const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
|
|
770
927
|
if (!ok || !entry) {
|
|
771
928
|
toast.error(format(t.asset.toastUploadFailed, { status }));
|
|
772
929
|
return;
|
|
773
930
|
}
|
|
774
931
|
await refresh().catch(() => {});
|
|
775
|
-
onPick(entry);
|
|
932
|
+
onPick(entry, scope);
|
|
776
933
|
} finally {
|
|
777
934
|
setUploading(false);
|
|
778
935
|
}
|
|
779
936
|
},
|
|
780
|
-
[
|
|
937
|
+
[effectiveSlideId, scope, refresh, onPick, t],
|
|
781
938
|
);
|
|
782
939
|
|
|
783
940
|
return (
|
|
@@ -791,6 +948,12 @@ function AssetPickerDialog({
|
|
|
791
948
|
{descSuffix}
|
|
792
949
|
</DialogDescription>
|
|
793
950
|
</DialogHeader>
|
|
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>
|
|
794
957
|
<label
|
|
795
958
|
htmlFor={inputId}
|
|
796
959
|
className={cn(
|
|
@@ -859,7 +1022,7 @@ function AssetPickerDialog({
|
|
|
859
1022
|
<button
|
|
860
1023
|
key={asset.name}
|
|
861
1024
|
type="button"
|
|
862
|
-
onClick={() => onPick(asset)}
|
|
1025
|
+
onClick={() => onPick(asset, scope)}
|
|
863
1026
|
className={cn(
|
|
864
1027
|
'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
|
|
865
1028
|
'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
|
|
@@ -953,8 +1116,23 @@ function CommentsSection({
|
|
|
953
1116
|
}) {
|
|
954
1117
|
const [draft, setDraft] = useState('');
|
|
955
1118
|
const [submitting, setSubmitting] = useState(false);
|
|
1119
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
956
1120
|
const t = useLocale();
|
|
957
1121
|
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1124
|
+
if (e.key !== '/') return;
|
|
1125
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
1126
|
+
if (e.altKey || e.shiftKey) return;
|
|
1127
|
+
const ta = wrapRef.current?.querySelector('textarea');
|
|
1128
|
+
if (!ta) return;
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
ta.focus({ preventScroll: true });
|
|
1131
|
+
};
|
|
1132
|
+
window.addEventListener('keydown', onKey);
|
|
1133
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1134
|
+
}, []);
|
|
1135
|
+
|
|
958
1136
|
const submit = async () => {
|
|
959
1137
|
const trimmed = draft.trim();
|
|
960
1138
|
if (!trimmed) return;
|
|
@@ -970,7 +1148,7 @@ function CommentsSection({
|
|
|
970
1148
|
return (
|
|
971
1149
|
<Section title={t.inspector.leaveComment}>
|
|
972
1150
|
<div className="flex flex-col gap-2">
|
|
973
|
-
<div className="comment-cue rounded-[6px]">
|
|
1151
|
+
<div ref={wrapRef} className="comment-cue rounded-[6px]">
|
|
974
1152
|
<Textarea
|
|
975
1153
|
value={draft}
|
|
976
1154
|
onChange={(e) => setDraft(e.target.value)}
|
|
@@ -999,7 +1177,7 @@ function CommentsSection({
|
|
|
999
1177
|
|
|
1000
1178
|
function readSnapshot(el: HTMLElement): ElementSnapshot {
|
|
1001
1179
|
const cs = getComputedStyle(el);
|
|
1002
|
-
const text = isSimpleTextElement(el) ? (el
|
|
1180
|
+
const text = isSimpleTextElement(el) ? readEditableText(el) : null;
|
|
1003
1181
|
const imageSrc =
|
|
1004
1182
|
el.tagName === 'IMG'
|
|
1005
1183
|
? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
|
|
@@ -1031,8 +1209,72 @@ function readSnapshot(el: HTMLElement): ElementSnapshot {
|
|
|
1031
1209
|
|
|
1032
1210
|
function isSimpleTextElement(el: HTMLElement): boolean {
|
|
1033
1211
|
if (el.childNodes.length === 0) return true;
|
|
1034
|
-
|
|
1035
|
-
|
|
1212
|
+
return hasOnlyInlineTextChildren(el);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const INLINE_TEXT_TAGS = new Set([
|
|
1216
|
+
'B',
|
|
1217
|
+
'CODE',
|
|
1218
|
+
'DEL',
|
|
1219
|
+
'EM',
|
|
1220
|
+
'I',
|
|
1221
|
+
'INS',
|
|
1222
|
+
'MARK',
|
|
1223
|
+
'S',
|
|
1224
|
+
'SMALL',
|
|
1225
|
+
'SPAN',
|
|
1226
|
+
'STRONG',
|
|
1227
|
+
'SUB',
|
|
1228
|
+
'SUP',
|
|
1229
|
+
'U',
|
|
1230
|
+
]);
|
|
1231
|
+
|
|
1232
|
+
function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
|
|
1233
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1234
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1235
|
+
continue;
|
|
1236
|
+
} else if (child instanceof HTMLElement) {
|
|
1237
|
+
if (child.tagName === 'BR') continue;
|
|
1238
|
+
if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
|
|
1239
|
+
}
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function readEditableText(el: HTMLElement): string {
|
|
1246
|
+
const parts: string[] = [];
|
|
1247
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1248
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1249
|
+
parts.push(renderedTextNodeValue(child as Text));
|
|
1250
|
+
} else if (child instanceof HTMLBRElement) {
|
|
1251
|
+
parts.push('\n');
|
|
1252
|
+
} else if (child instanceof HTMLElement) {
|
|
1253
|
+
parts.push(readEditableText(child));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return normalizeRenderedText(parts);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function normalizeRenderedText(parts: string[]): string {
|
|
1260
|
+
return parts
|
|
1261
|
+
.map((part, index) => {
|
|
1262
|
+
if (part === '\n') return part;
|
|
1263
|
+
let next = part;
|
|
1264
|
+
if (parts[index - 1] === '\n') next = next.replace(/^\s+/, '');
|
|
1265
|
+
if (parts[index + 1] === '\n') next = next.replace(/\s+$/, '');
|
|
1266
|
+
return next;
|
|
1267
|
+
})
|
|
1268
|
+
.join('');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function renderedTextNodeValue(node: Text): string {
|
|
1272
|
+
const value = node.textContent ?? '';
|
|
1273
|
+
const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
|
|
1274
|
+
if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
|
|
1275
|
+
return value;
|
|
1276
|
+
}
|
|
1277
|
+
return value.replace(/\s+/g, ' ');
|
|
1036
1278
|
}
|
|
1037
1279
|
|
|
1038
1280
|
function rgbToHex(value: string): string | null {
|