@kywk/open-slide-core 2.3.1 → 2.3.3
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-CZ6Qtrng.js → build-46dO14h1.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-CpmGqpQi.d.ts → config-DJ8ZOe7C.d.ts} +6 -2
- package/dist/{config-B5LArqpp.js → config-KyYvWqq-.js} +2061 -355
- package/dist/{dev-CCgdakbZ.js → dev-C0n-o0-c.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +73 -32
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1
- package/dist/{preview-AwAqvG17.js → preview-CDWAGSfB.js} +1 -1
- package/dist/{types-CvAqFqsY.d.ts → types-tdaXSyah.d.ts} +111 -0
- package/dist/{update-C_RlnKu4.js → update-CqZqQRQk.js} +128 -7
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-B7_Oqv2q.js → zh-tw-DXcqQTrA.js} +540 -0
- package/package.json +10 -3
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/inline-svg.tsx +52 -21
- package/src/app/components/inspector/comment-layer.tsx +20 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspector-panel.tsx +1 -1
- package/src/app/components/mermaid.tsx +42 -11
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +105 -23
- package/src/app/components/present/curtain.tsx +52 -0
- package/src/app/components/present/last-slide-hint.tsx +36 -0
- package/src/app/components/slide-canvas.tsx +1 -1
- package/src/app/components/thumbnail-rail.tsx +1 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/home.tsx +51 -7
- package/src/app/routes/slide.tsx +172 -116
- package/src/app/styles.css +170 -0
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +107 -0
- package/src/locale/es.ts +109 -0
- package/src/locale/ja.ts +100 -0
- package/src/locale/types.ts +110 -0
- package/src/locale/zh-cn.ts +97 -0
- package/src/locale/zh-tw.ts +97 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import { createPortal } from 'react-dom';
|
|
3
10
|
|
|
4
11
|
export interface InlineSvgProps {
|
|
@@ -26,26 +33,52 @@ function isLightColor(color: string): boolean {
|
|
|
26
33
|
return luminance > 0.5;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
const MAX_SVG_CACHE_ENTRIES = 100;
|
|
37
|
+
const svgCache = new Map<string, SVGSVGElement>();
|
|
38
|
+
|
|
39
|
+
function getCachedSvg(svg: string, alt?: string): SVGSVGElement | null {
|
|
40
|
+
const cacheKey = alt ? `${alt}:::${svg}` : svg;
|
|
41
|
+
let template = svgCache.get(cacheKey);
|
|
42
|
+
if (template) {
|
|
43
|
+
svgCache.delete(cacheKey);
|
|
44
|
+
svgCache.set(cacheKey, template);
|
|
45
|
+
} else {
|
|
46
|
+
const el = document.createElement('template');
|
|
47
|
+
el.innerHTML = svg.trim();
|
|
48
|
+
const svgEl = el.content.querySelector('svg');
|
|
49
|
+
if (!svgEl) return null;
|
|
50
|
+
svgEl.style.width = '100%';
|
|
51
|
+
svgEl.style.height = '100%';
|
|
52
|
+
svgEl.style.display = 'block';
|
|
53
|
+
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
54
|
+
if (alt && !svgEl.getAttribute('aria-label')) {
|
|
55
|
+
svgEl.setAttribute('aria-label', alt);
|
|
56
|
+
svgEl.setAttribute('role', 'img');
|
|
57
|
+
}
|
|
58
|
+
template = svgEl;
|
|
59
|
+
if (svgCache.size >= MAX_SVG_CACHE_ENTRIES) {
|
|
60
|
+
const oldest = svgCache.keys().next().value;
|
|
61
|
+
if (oldest !== undefined) svgCache.delete(oldest);
|
|
62
|
+
}
|
|
63
|
+
svgCache.set(cacheKey, template);
|
|
64
|
+
}
|
|
65
|
+
return template.cloneNode(true) as SVGSVGElement;
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
export function InlineSvg({ svg, className, style, lightbox = true, alt }: InlineSvgProps) {
|
|
30
69
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
31
70
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
32
71
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
33
72
|
const [canvasBg, setCanvasBg] = useState('#ffffff');
|
|
34
73
|
|
|
35
|
-
|
|
74
|
+
useLayoutEffect(() => {
|
|
36
75
|
const el = containerRef.current;
|
|
37
76
|
if (!el) return;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
svgEl.style.display = 'block';
|
|
44
|
-
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
45
|
-
if (alt && !svgEl.getAttribute('aria-label')) {
|
|
46
|
-
svgEl.setAttribute('aria-label', alt);
|
|
47
|
-
svgEl.setAttribute('role', 'img');
|
|
48
|
-
}
|
|
77
|
+
const node = getCachedSvg(svg, alt);
|
|
78
|
+
if (node) {
|
|
79
|
+
el.replaceChildren(node);
|
|
80
|
+
} else {
|
|
81
|
+
el.replaceChildren();
|
|
49
82
|
}
|
|
50
83
|
}, [svg, alt]);
|
|
51
84
|
|
|
@@ -111,16 +144,14 @@ function SvgLightbox({
|
|
|
111
144
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
112
145
|
const light = isLightColor(canvasBg);
|
|
113
146
|
|
|
114
|
-
|
|
147
|
+
useLayoutEffect(() => {
|
|
115
148
|
const el = containerRef.current;
|
|
116
149
|
if (!el) return;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
svgEl.style.display = 'block';
|
|
123
|
-
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
150
|
+
const node = getCachedSvg(svg);
|
|
151
|
+
if (node) {
|
|
152
|
+
el.replaceChildren(node);
|
|
153
|
+
} else {
|
|
154
|
+
el.replaceChildren();
|
|
124
155
|
}
|
|
125
156
|
}, [svg]);
|
|
126
157
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
useState,
|
|
12
12
|
} from 'react';
|
|
13
13
|
import { toast } from 'sonner';
|
|
14
|
+
import { requestAgent, useAgentStatus } from '@/lib/agent/use-agent';
|
|
14
15
|
import { findSlideSource } from '@/lib/inspector/fiber';
|
|
15
16
|
import { useFreezeSlideMotion } from '@/lib/inspector/freeze-motion';
|
|
16
17
|
import { type Point, type Rect, rectContains } from '@/lib/inspector/geometry';
|
|
@@ -24,6 +25,7 @@ import { type Canvas, readCanvas, readFrame } from '@/lib/inspector/visual-dom';
|
|
|
24
25
|
import { IS_APPLE, isTypingTarget } from '@/lib/keys';
|
|
25
26
|
import { format, plural, useLocale } from '@/lib/use-locale';
|
|
26
27
|
import { cn } from '@/lib/utils';
|
|
28
|
+
import { applyCommentsPrompt } from '../../../agent/prompt';
|
|
27
29
|
import { type SelectedTarget, useInspector } from './inspector-provider';
|
|
28
30
|
|
|
29
31
|
type ScreenRect = { left: number; top: number; width: number; height: number };
|
|
@@ -119,6 +121,7 @@ export function CommentLayer() {
|
|
|
119
121
|
const { commentMode, exitCommentMode, slideId, pageIndex, comments, add, update, remove, error } =
|
|
120
122
|
useInspector();
|
|
121
123
|
const t = useLocale();
|
|
124
|
+
const agent = useAgentStatus();
|
|
122
125
|
useFreezeSlideMotion(commentMode ? 'comment' : null);
|
|
123
126
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
124
127
|
const gestureRef = useRef<Gesture | null>(null);
|
|
@@ -486,13 +489,29 @@ export function CommentLayer() {
|
|
|
486
489
|
)}
|
|
487
490
|
<div className="absolute inset-x-0 bottom-3 flex justify-center">
|
|
488
491
|
<IslandHint
|
|
489
|
-
contentKey={
|
|
492
|
+
contentKey={
|
|
493
|
+
error ? 'error' : count === 0 ? 'empty' : agent.connected ? 'count-agent' : 'count'
|
|
494
|
+
}
|
|
490
495
|
tone={error ? 'error' : 'default'}
|
|
491
496
|
>
|
|
492
497
|
{error ? (
|
|
493
498
|
error
|
|
494
499
|
) : count === 0 ? (
|
|
495
500
|
t.inspector.commentEmptyHint
|
|
501
|
+
) : agent.connected ? (
|
|
502
|
+
<>
|
|
503
|
+
<CommentCountLabel count={count} />
|
|
504
|
+
<span aria-hidden className="mx-1.5">
|
|
505
|
+
·
|
|
506
|
+
</span>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
className="pointer-events-auto rounded-[4px] px-1 py-0.5 font-medium text-brand outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
510
|
+
onClick={() => requestAgent({ text: applyCommentsPrompt(slideId) })}
|
|
511
|
+
>
|
|
512
|
+
{format(t.agent.applyCommentsWith, { provider: agent.providerName ?? '' })}
|
|
513
|
+
</button>
|
|
514
|
+
</>
|
|
496
515
|
) : (
|
|
497
516
|
<>
|
|
498
517
|
<CommentCountLabel count={count} />
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SyntheticEvent, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import ReactCrop, { type Crop, type PercentCrop } from 'react-image-crop';
|
|
3
|
-
import { round2 } from '@/lib/utils';
|
|
3
|
+
import { clamp, round2 } from '@/lib/utils';
|
|
4
4
|
import 'react-image-crop/dist/ReactCrop.css';
|
|
5
5
|
import { Button } from '@/components/ui/button';
|
|
6
6
|
import {
|
|
@@ -209,7 +209,3 @@ function roundRect(crop: PercentCrop): ImageCropRect {
|
|
|
209
209
|
height: round2(crop.height),
|
|
210
210
|
};
|
|
211
211
|
}
|
|
212
|
-
|
|
213
|
-
function clamp(v: number, lo: number, hi: number) {
|
|
214
|
-
return v < lo ? lo : v > hi ? hi : v;
|
|
215
|
-
}
|
|
@@ -272,7 +272,7 @@ export function InspectorPanel({
|
|
|
272
272
|
if (value === 'arrange') stopInlineEdit();
|
|
273
273
|
if (value === 'format' || value === 'arrange') onTabChange(value);
|
|
274
274
|
}}
|
|
275
|
-
className="h-full shrink-0 gap-0"
|
|
275
|
+
className="h-full w-full min-w-0 shrink-0 gap-0"
|
|
276
276
|
onPointerDownCapture={(event) => {
|
|
277
277
|
if (!inlineEdit || !(event.target instanceof Element)) return;
|
|
278
278
|
const button = event.target.closest('button');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { render as renderPele } from 'pele';
|
|
2
2
|
import type { CSSProperties, ReactNode } from 'react';
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
|
|
6
6
|
export interface MermaidConfigLegacy {
|
|
@@ -47,12 +47,12 @@ function isLightColor(color: string): boolean {
|
|
|
47
47
|
return luminance > 0.5;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function normalizeSvg(svg: string, id
|
|
50
|
+
function normalizeSvg(svg: string, id?: string): SVGSVGElement {
|
|
51
51
|
const template = document.createElement('template');
|
|
52
52
|
template.innerHTML = svg.trim();
|
|
53
53
|
const element = template.content.querySelector('svg');
|
|
54
54
|
if (!element) throw new Error('Pele did not return an SVG');
|
|
55
|
-
if (!element.id) {
|
|
55
|
+
if (id && !element.id) {
|
|
56
56
|
element.id = id;
|
|
57
57
|
}
|
|
58
58
|
const origWidth = element.getAttribute('width') ?? '800';
|
|
@@ -73,6 +73,28 @@ function normalizeSvg(svg: string, id: string): SVGSVGElement {
|
|
|
73
73
|
return element;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
const MAX_CACHE_ENTRIES = 100;
|
|
77
|
+
const diagramCache = new Map<string, SVGSVGElement>();
|
|
78
|
+
|
|
79
|
+
function getCachedDiagram(chart: string): SVGSVGElement {
|
|
80
|
+
let template = diagramCache.get(chart);
|
|
81
|
+
if (template) {
|
|
82
|
+
diagramCache.delete(chart);
|
|
83
|
+
diagramCache.set(chart, template);
|
|
84
|
+
} else {
|
|
85
|
+
const { svg } = renderPele(chart);
|
|
86
|
+
template = normalizeSvg(svg);
|
|
87
|
+
if (diagramCache.size >= MAX_CACHE_ENTRIES) {
|
|
88
|
+
const oldest = diagramCache.keys().next().value;
|
|
89
|
+
if (oldest !== undefined) diagramCache.delete(oldest);
|
|
90
|
+
}
|
|
91
|
+
diagramCache.set(chart, template);
|
|
92
|
+
}
|
|
93
|
+
const clone = template.cloneNode(true) as SVGSVGElement;
|
|
94
|
+
clone.id = nextRenderId();
|
|
95
|
+
return clone;
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
const peleDefaults: CSSProperties = {
|
|
77
99
|
'--pele-bg': 'var(--osd-bg, #ffffff)',
|
|
78
100
|
'--pele-fg': 'var(--osd-text, #111827)',
|
|
@@ -213,7 +235,17 @@ export function Mermaid({
|
|
|
213
235
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
214
236
|
const diagramRef = useRef<HTMLDivElement>(null);
|
|
215
237
|
const renderVersion = useRef(0);
|
|
216
|
-
const [diagram, setDiagram] = useState<RenderedDiagram | null>(
|
|
238
|
+
const [diagram, setDiagram] = useState<RenderedDiagram | null>(() => {
|
|
239
|
+
if (typeof document === 'undefined') return null;
|
|
240
|
+
try {
|
|
241
|
+
if (diagramCache.has(chart)) {
|
|
242
|
+
return { element: getCachedDiagram(chart) };
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// ignore
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
});
|
|
217
249
|
const [error, setError] = useState<unknown>(null);
|
|
218
250
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
219
251
|
const [canvasBg, setCanvasBg] = useState('#ffffff');
|
|
@@ -222,16 +254,16 @@ export function Mermaid({
|
|
|
222
254
|
useEffect(() => {
|
|
223
255
|
let active = true;
|
|
224
256
|
const version = ++renderVersion.current;
|
|
225
|
-
setDiagram(null);
|
|
226
|
-
setError(null);
|
|
227
257
|
|
|
228
258
|
try {
|
|
229
|
-
const
|
|
259
|
+
const element = getCachedDiagram(chart);
|
|
230
260
|
if (!active || version !== renderVersion.current) return;
|
|
231
|
-
|
|
261
|
+
setError(null);
|
|
262
|
+
setDiagram({ element });
|
|
232
263
|
} catch (cause: unknown) {
|
|
233
264
|
if (!active || version !== renderVersion.current) return;
|
|
234
265
|
wrapperRef.current?.replaceChildren();
|
|
266
|
+
setDiagram(null);
|
|
235
267
|
setError(cause);
|
|
236
268
|
}
|
|
237
269
|
|
|
@@ -240,7 +272,7 @@ export function Mermaid({
|
|
|
240
272
|
};
|
|
241
273
|
}, [chart]);
|
|
242
274
|
|
|
243
|
-
|
|
275
|
+
useLayoutEffect(() => {
|
|
244
276
|
if (!diagram || !diagramRef.current) return;
|
|
245
277
|
const container = diagramRef.current;
|
|
246
278
|
container.replaceChildren(diagram.element);
|
|
@@ -334,9 +366,8 @@ function MermaidLightbox({
|
|
|
334
366
|
|
|
335
367
|
useEffect(() => {
|
|
336
368
|
try {
|
|
337
|
-
const { svg } = renderPele(chart);
|
|
338
369
|
if (!containerRef.current) return;
|
|
339
|
-
const el =
|
|
370
|
+
const el = getCachedDiagram(chart);
|
|
340
371
|
containerRef.current.replaceChildren(el);
|
|
341
372
|
} catch {
|
|
342
373
|
// ignore
|
|
@@ -1,49 +1,118 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { type ReactNode, useState } from 'react';
|
|
2
|
+
import { AGENT_DOCK_W } from '@/components/agent/agent-dock';
|
|
2
3
|
import { InspectorPanel } from '@/components/inspector/inspector-panel';
|
|
3
4
|
import { useInspector } from '@/components/inspector/inspector-provider';
|
|
4
5
|
import { useDesignPanelState } from '@/components/style-panel/design-provider';
|
|
5
6
|
import { DesignPanel } from '@/components/style-panel/style-panel';
|
|
7
|
+
import { useLocale } from '@/lib/use-locale';
|
|
6
8
|
import { PANEL_TRANSITION_MS, PANEL_W, usePanelMount } from './panel-shell';
|
|
9
|
+
import { ResizeHandle, useStoredWidth } from './resize-handle';
|
|
7
10
|
|
|
11
|
+
type Mode = 'format' | 'design' | 'agent';
|
|
12
|
+
|
|
13
|
+
const AGENT_MIN_W = 300;
|
|
14
|
+
const EDITOR_MAX_W = 480;
|
|
15
|
+
const AGENT_MAX_W = 720;
|
|
16
|
+
|
|
17
|
+
// The agent shares this container with Format and Design so switching between
|
|
18
|
+
// them only retargets one width transition instead of collapsing one column
|
|
19
|
+
// while another grows.
|
|
8
20
|
export function EditorSidebar({
|
|
9
21
|
designOpen,
|
|
10
22
|
onCloseDesign,
|
|
23
|
+
agentOpen = false,
|
|
24
|
+
agent,
|
|
11
25
|
}: {
|
|
12
26
|
designOpen: boolean;
|
|
13
27
|
onCloseDesign: () => void;
|
|
28
|
+
agentOpen?: boolean;
|
|
29
|
+
agent?: ReactNode;
|
|
14
30
|
}) {
|
|
31
|
+
const t = useLocale();
|
|
15
32
|
const { active, panelOpen } = useInspector();
|
|
33
|
+
const [editorWidth, setEditorWidth] = useStoredWidth(
|
|
34
|
+
'open-slide:editor-panel-width',
|
|
35
|
+
PANEL_W,
|
|
36
|
+
PANEL_W,
|
|
37
|
+
EDITOR_MAX_W,
|
|
38
|
+
);
|
|
39
|
+
const [agentWidth, setAgentWidth] = useStoredWidth(
|
|
40
|
+
'open-slide:agent-panel-width',
|
|
41
|
+
AGENT_DOCK_W,
|
|
42
|
+
AGENT_MIN_W,
|
|
43
|
+
AGENT_MAX_W,
|
|
44
|
+
);
|
|
45
|
+
const [resizing, setResizing] = useState(false);
|
|
16
46
|
const { loaded, draft } = useDesignPanelState();
|
|
17
47
|
const [preferredTab, setPreferredTab] = useState<'format' | 'arrange'>('format');
|
|
18
|
-
const mode =
|
|
19
|
-
|
|
20
|
-
? '
|
|
21
|
-
:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
48
|
+
const mode: Mode | null =
|
|
49
|
+
agentOpen && agent
|
|
50
|
+
? 'agent'
|
|
51
|
+
: designOpen
|
|
52
|
+
? loaded && draft
|
|
53
|
+
? 'design'
|
|
54
|
+
: null
|
|
55
|
+
: active && panelOpen
|
|
56
|
+
? 'format'
|
|
57
|
+
: null;
|
|
58
|
+
const [lastMode, setLastMode] = useState<Mode>('format');
|
|
26
59
|
if (mode && mode !== lastMode) setLastMode(mode);
|
|
60
|
+
const shown = mode ?? lastMode;
|
|
61
|
+
const isAgent = shown === 'agent';
|
|
62
|
+
const [lastEditor, setLastEditor] = useState<'format' | 'design'>('format');
|
|
63
|
+
if (!isAgent && shown !== lastEditor) setLastEditor(shown);
|
|
64
|
+
const [agentMounted, setAgentMounted] = useState(false);
|
|
65
|
+
if (isAgent && !agentMounted) setAgentMounted(true);
|
|
27
66
|
const open = mode !== null;
|
|
28
67
|
const { mounted, animVisible } = usePanelMount(open);
|
|
29
68
|
|
|
30
69
|
if (!mounted) return null;
|
|
31
70
|
|
|
71
|
+
const width = isAgent ? agentWidth : editorWidth;
|
|
72
|
+
|
|
32
73
|
return (
|
|
33
74
|
<div
|
|
34
75
|
data-editor-sidebar
|
|
76
|
+
data-agent-dock={isAgent ? '' : undefined}
|
|
35
77
|
inert={!open}
|
|
36
78
|
aria-hidden={!open}
|
|
37
|
-
className="flex h-full max-h-1/2 shrink-0 justify-end overflow-hidden bg-sidebar transition-[width] ease-swift motion-reduce:transition-none md:max-h-none"
|
|
79
|
+
className="relative flex h-full max-h-1/2 shrink-0 justify-end overflow-hidden bg-sidebar transition-[width] ease-swift motion-reduce:transition-none md:max-h-none"
|
|
38
80
|
style={{
|
|
39
|
-
width: animVisible ?
|
|
40
|
-
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
|
|
81
|
+
width: animVisible ? width : 0,
|
|
82
|
+
transitionDuration: resizing ? '0ms' : `${PANEL_TRANSITION_MS}ms`,
|
|
41
83
|
}}
|
|
42
84
|
>
|
|
43
|
-
{
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
85
|
+
{open && (
|
|
86
|
+
<ResizeHandle
|
|
87
|
+
key={isAgent ? 'agent' : 'editor'}
|
|
88
|
+
width={width}
|
|
89
|
+
min={isAgent ? AGENT_MIN_W : PANEL_W}
|
|
90
|
+
max={isAgent ? AGENT_MAX_W : EDITOR_MAX_W}
|
|
91
|
+
defaultWidth={isAgent ? AGENT_DOCK_W : PANEL_W}
|
|
92
|
+
edge="left"
|
|
93
|
+
label={t.slide.resizeSidePanel}
|
|
94
|
+
onResize={isAgent ? setAgentWidth : setEditorWidth}
|
|
95
|
+
onResizingChange={setResizing}
|
|
96
|
+
className="left-0 hidden md:block"
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
99
|
+
{agentMounted && (
|
|
100
|
+
<div
|
|
101
|
+
className="sidebar-swap h-full shrink-0"
|
|
102
|
+
style={{ width: agentWidth }}
|
|
103
|
+
hidden={!isAgent}
|
|
104
|
+
>
|
|
105
|
+
{agent}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
{!isAgent && (
|
|
109
|
+
<div className="sidebar-swap flex h-full shrink-0" style={{ width: editorWidth }}>
|
|
110
|
+
{lastEditor === 'design' ? (
|
|
111
|
+
<DesignPanel onClose={onCloseDesign} />
|
|
112
|
+
) : (
|
|
113
|
+
<InspectorPanel preferredTab={preferredTab} onTabChange={setPreferredTab} />
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
47
116
|
)}
|
|
48
117
|
</div>
|
|
49
118
|
);
|
|
@@ -45,8 +45,8 @@ type PanelShellProps = {
|
|
|
45
45
|
export function PanelShell({ uiAttr, header, banner, footer, children }: PanelShellProps) {
|
|
46
46
|
const dataAttrs = uiAttr === 'inspector' ? { 'data-inspector-ui': '' } : { 'data-design-ui': '' };
|
|
47
47
|
return (
|
|
48
|
-
<aside {...dataAttrs} className="flex h-full
|
|
49
|
-
<div
|
|
48
|
+
<aside {...dataAttrs} className="flex h-full w-full min-w-0 bg-sidebar">
|
|
49
|
+
<div className="flex h-full w-full min-w-0 flex-col">
|
|
50
50
|
<header className="flex h-9 shrink-0 items-center justify-between gap-2 border-b border-hairline px-3">
|
|
51
51
|
{header}
|
|
52
52
|
</header>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { clamp, cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
export function useStoredWidth(key: string, fallback: number, min: number, max: number) {
|
|
5
|
+
const [width, setWidth] = useState<number>(() => {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = Number(window.localStorage.getItem(key) ?? Number.NaN);
|
|
8
|
+
return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback;
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
try {
|
|
15
|
+
window.localStorage.setItem(key, String(width));
|
|
16
|
+
} catch {}
|
|
17
|
+
}, [key, width]);
|
|
18
|
+
return [width, setWidth] as const;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A vertical splitter on one edge of a panel. `edge` is the side of the panel
|
|
22
|
+
// it sits on, so dragging away from the panel always makes it wider.
|
|
23
|
+
export function ResizeHandle({
|
|
24
|
+
width,
|
|
25
|
+
min,
|
|
26
|
+
max,
|
|
27
|
+
defaultWidth,
|
|
28
|
+
edge,
|
|
29
|
+
label,
|
|
30
|
+
onResize,
|
|
31
|
+
onResizingChange,
|
|
32
|
+
className,
|
|
33
|
+
}: {
|
|
34
|
+
width: number;
|
|
35
|
+
min: number;
|
|
36
|
+
max: number;
|
|
37
|
+
defaultWidth: number;
|
|
38
|
+
edge: 'left' | 'right';
|
|
39
|
+
label: string;
|
|
40
|
+
onResize: (width: number) => void;
|
|
41
|
+
onResizingChange?: (resizing: boolean) => void;
|
|
42
|
+
className?: string;
|
|
43
|
+
}) {
|
|
44
|
+
const [resizing, setResizing] = useState(false);
|
|
45
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
46
|
+
const direction = edge === 'right' ? 1 : -1;
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
onResizingChange?.(resizing);
|
|
50
|
+
if (!resizing) return;
|
|
51
|
+
const prev = {
|
|
52
|
+
cursor: document.body.style.cursor,
|
|
53
|
+
userSelect: document.body.style.userSelect,
|
|
54
|
+
};
|
|
55
|
+
document.body.style.cursor = 'col-resize';
|
|
56
|
+
document.body.style.userSelect = 'none';
|
|
57
|
+
return () => {
|
|
58
|
+
document.body.style.cursor = prev.cursor;
|
|
59
|
+
document.body.style.userSelect = prev.userSelect;
|
|
60
|
+
};
|
|
61
|
+
}, [resizing, onResizingChange]);
|
|
62
|
+
|
|
63
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
66
|
+
dragRef.current = { startX: e.clientX, startWidth: width };
|
|
67
|
+
setResizing(true);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
71
|
+
if (!dragRef.current) return;
|
|
72
|
+
const delta = (e.clientX - dragRef.current.startX) * direction;
|
|
73
|
+
onResize(clamp(dragRef.current.startWidth + delta, min, max));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
77
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
78
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
79
|
+
}
|
|
80
|
+
dragRef.current = null;
|
|
81
|
+
setResizing(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
85
|
+
const step = (e.shiftKey ? 32 : 8) * direction;
|
|
86
|
+
let next: number | null = null;
|
|
87
|
+
if (e.key === 'ArrowLeft') next = width - step;
|
|
88
|
+
else if (e.key === 'ArrowRight') next = width + step;
|
|
89
|
+
else if (e.key === 'Home') next = defaultWidth;
|
|
90
|
+
if (next === null) return;
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
onResize(clamp(next, min, max));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
// biome-ignore lint/a11y/useSemanticElements: focusable resize handle (splitter pattern), not a static <hr>
|
|
98
|
+
<div
|
|
99
|
+
role="separator"
|
|
100
|
+
aria-orientation="vertical"
|
|
101
|
+
aria-label={label}
|
|
102
|
+
aria-valuenow={width}
|
|
103
|
+
aria-valuemin={min}
|
|
104
|
+
aria-valuemax={max}
|
|
105
|
+
tabIndex={0}
|
|
106
|
+
onPointerDown={onPointerDown}
|
|
107
|
+
onPointerMove={onPointerMove}
|
|
108
|
+
onPointerUp={onPointerUp}
|
|
109
|
+
onPointerCancel={onPointerUp}
|
|
110
|
+
onKeyDown={onKeyDown}
|
|
111
|
+
onDoubleClick={() => onResize(defaultWidth)}
|
|
112
|
+
className={cn(
|
|
113
|
+
'group/resize absolute inset-y-0 z-20 w-1.5 cursor-col-resize touch-none outline-none',
|
|
114
|
+
'focus-visible:bg-brand/20',
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<span
|
|
119
|
+
aria-hidden
|
|
120
|
+
className={cn(
|
|
121
|
+
'pointer-events-none absolute inset-y-0 w-px bg-brand opacity-0 transition-opacity duration-150',
|
|
122
|
+
edge === 'right' ? 'left-1/2 -translate-x-1/2' : 'left-0',
|
|
123
|
+
'group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100',
|
|
124
|
+
resizing && 'opacity-100',
|
|
125
|
+
)}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|