@kywk/open-slide-core 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{build-CZ6Qtrng.js → build-46dO14h1.js} +1 -1
  3. package/dist/cli/bin.js +4 -4
  4. package/dist/{config-CpmGqpQi.d.ts → config-CDrYgaEj.d.ts} +6 -2
  5. package/dist/{config-B5LArqpp.js → config-KyYvWqq-.js} +2061 -355
  6. package/dist/{dev-CCgdakbZ.js → dev-C0n-o0-c.js} +2 -2
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +73 -32
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +1 -1
  11. package/dist/{preview-AwAqvG17.js → preview-CDWAGSfB.js} +1 -1
  12. package/dist/{types-CvAqFqsY.d.ts → types-DFK02XVv.d.ts} +110 -0
  13. package/dist/{update-C_RlnKu4.js → update-CqZqQRQk.js} +128 -7
  14. package/dist/vite/index.d.ts +1 -1
  15. package/dist/vite/index.js +1 -1
  16. package/dist/{zh-tw-B7_Oqv2q.js → zh-tw-B1pkKQsc.js} +535 -0
  17. package/package.json +15 -9
  18. package/src/agent/prompt.ts +141 -0
  19. package/src/agent/provider.ts +123 -0
  20. package/src/agent/types.ts +151 -0
  21. package/src/app/components/agent/agent-dock.tsx +35 -0
  22. package/src/app/components/agent/agent-markdown.tsx +45 -0
  23. package/src/app/components/agent/agent-panel.tsx +692 -0
  24. package/src/app/components/agent/agent-presence.tsx +74 -0
  25. package/src/app/components/agent/composer.tsx +120 -0
  26. package/src/app/components/agent/connect-view.tsx +184 -0
  27. package/src/app/components/agent/history-view.tsx +135 -0
  28. package/src/app/components/agent/provider-logo.tsx +34 -0
  29. package/src/app/components/agent/transcript.tsx +609 -0
  30. package/src/app/components/command/slide-command-menu.tsx +10 -0
  31. package/src/app/components/inline-svg.tsx +52 -21
  32. package/src/app/components/inspector/comment-layer.tsx +20 -1
  33. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  34. package/src/app/components/inspector/inspector-panel.tsx +1 -1
  35. package/src/app/components/mermaid.tsx +42 -11
  36. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  37. package/src/app/components/panel/panel-shell.tsx +2 -2
  38. package/src/app/components/panel/resize-handle.tsx +129 -0
  39. package/src/app/lib/agent/use-agent.ts +543 -0
  40. package/src/app/lib/use-auto-grow.ts +15 -0
  41. package/src/app/lib/utils.ts +4 -0
  42. package/src/app/routes/home.tsx +43 -7
  43. package/src/app/routes/slide.tsx +103 -104
  44. package/src/app/styles.css +170 -0
  45. package/src/editing/placeholder-id.ts +1 -0
  46. package/src/locale/en.ts +106 -0
  47. package/src/locale/es.ts +108 -0
  48. package/src/locale/ja.ts +99 -0
  49. package/src/locale/types.ts +109 -0
  50. package/src/locale/zh-cn.ts +96 -0
  51. package/src/locale/zh-tw.ts +96 -0
@@ -1,4 +1,11 @@
1
- import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
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
- useEffect(() => {
74
+ useLayoutEffect(() => {
36
75
  const el = containerRef.current;
37
76
  if (!el) return;
38
- el.innerHTML = svg;
39
- const svgEl = el.querySelector('svg');
40
- if (svgEl) {
41
- svgEl.style.width = '100%';
42
- svgEl.style.height = '100%';
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
- useEffect(() => {
147
+ useLayoutEffect(() => {
115
148
  const el = containerRef.current;
116
149
  if (!el) return;
117
- el.innerHTML = svg;
118
- const svgEl = el.querySelector('svg');
119
- if (svgEl) {
120
- svgEl.style.width = '100%';
121
- svgEl.style.height = '100%';
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={error ? 'error' : count === 0 ? 'empty' : 'count'}
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: string): SVGSVGElement {
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>(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 { svg } = renderPele(chart);
259
+ const element = getCachedDiagram(chart);
230
260
  if (!active || version !== renderVersion.current) return;
231
- setDiagram({ element: normalizeSvg(svg, nextRenderId()) });
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
- useEffect(() => {
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 = normalizeSvg(svg, nextRenderId());
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 = designOpen
19
- ? loaded && draft
20
- ? 'design'
21
- : null
22
- : active && panelOpen
23
- ? 'format'
24
- : null;
25
- const [lastMode, setLastMode] = useState<'format' | 'design'>('format');
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 ? PANEL_W : 0,
40
- transitionDuration: `${PANEL_TRANSITION_MS}ms`,
81
+ width: animVisible ? width : 0,
82
+ transitionDuration: resizing ? '0ms' : `${PANEL_TRANSITION_MS}ms`,
41
83
  }}
42
84
  >
43
- {(mode ?? lastMode) === 'design' ? (
44
- <DesignPanel onClose={onCloseDesign} />
45
- ) : (
46
- <InspectorPanel preferredTab={preferredTab} onTabChange={setPreferredTab} />
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 shrink-0 bg-sidebar" style={{ width: PANEL_W }}>
49
- <div style={{ width: PANEL_W }} className="flex h-full shrink-0 flex-col">
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
+ }