@adea-ai/ui 0.103.0 → 0.104.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.
@@ -17,6 +17,8 @@ import { computeLayoutFrames, type LayoutRect } from './geometry'
17
17
  import { paneDropIntent, type PaneDropIntent } from './drop'
18
18
  export type { PaneDropIntent } from './drop'
19
19
  const PANE_DRAG_TYPE = 'application/x-adea-pane-move'
20
+ const DRAG_PREVIEW_MAX_WIDTH = 384
21
+ const DRAG_PREVIEW_MAX_HEIGHT = 256
20
22
  import {
21
23
  listLeaves,
22
24
  MIN_SPLIT_RATIO,
@@ -57,6 +59,114 @@ function rectStyle(rect: LayoutRect): JSX.CSSProperties {
57
59
  height: `${rect.height * 100}%`,
58
60
  }
59
61
  }
62
+ function dragPreviewPlaceholder(source: HTMLElement, message: string) {
63
+ const placeholder = document.createElement('div')
64
+ placeholder.className = cn(
65
+ source.getAttribute('class'),
66
+ 'flex min-h-8 min-w-0 items-center justify-center bg-muted px-2 text-center text-xs text-muted-foreground'
67
+ )
68
+ placeholder.setAttribute('data-split-drag-placeholder', source.localName)
69
+ placeholder.textContent = message
70
+ return placeholder
71
+ }
72
+ function boundedSnapshotSize(width: number, height: number) {
73
+ const sourceWidth = Math.max(1, width)
74
+ const sourceHeight = Math.max(1, height)
75
+ const scale = Math.min(
76
+ 1,
77
+ DRAG_PREVIEW_MAX_WIDTH / sourceWidth,
78
+ DRAG_PREVIEW_MAX_HEIGHT / sourceHeight
79
+ )
80
+ return {
81
+ width: Math.max(1, Math.round(sourceWidth * scale)),
82
+ height: Math.max(1, Math.round(sourceHeight * scale)),
83
+ }
84
+ }
85
+ function cloneDragPreviewNode(source: Node): Node | undefined {
86
+ if (source.nodeType === Node.TEXT_NODE) return source.cloneNode(false)
87
+ if (!(source instanceof HTMLElement || source instanceof SVGElement))
88
+ return source.cloneNode(false)
89
+
90
+ if (['script', 'style', 'link', 'source', 'track', 'meta'].includes(source.localName))
91
+ return undefined
92
+ if (['iframe', 'video', 'audio', 'object', 'embed'].includes(source.localName))
93
+ return dragPreviewPlaceholder(
94
+ source as HTMLElement,
95
+ 'Embedded content unavailable while moving'
96
+ )
97
+ if (source instanceof HTMLImageElement) {
98
+ const snapshot = document.createElement('canvas')
99
+ const size = boundedSnapshotSize(
100
+ source.naturalWidth || source.width,
101
+ source.naturalHeight || source.height
102
+ )
103
+ snapshot.width = size.width
104
+ snapshot.height = size.height
105
+ snapshot.className = source.getAttribute('class') ?? ''
106
+ snapshot.setAttribute('data-split-drag-image', '')
107
+ try {
108
+ if (!source.complete || source.naturalWidth === 0)
109
+ throw new Error('image has no decoded pixels')
110
+ const context = snapshot.getContext('2d')
111
+ if (!context) throw new Error('canvas snapshot context is unavailable')
112
+ context.drawImage(source, 0, 0, snapshot.width, snapshot.height)
113
+ return snapshot
114
+ } catch {
115
+ return dragPreviewPlaceholder(source, 'Image preview unavailable')
116
+ }
117
+ }
118
+
119
+ // Copying a defined custom element can run its constructor; inserting it can
120
+ // also reconnect a stream or terminal session. Flatten its display tree.
121
+ if (source instanceof HTMLElement && source.localName.includes('-')) {
122
+ const children = document.createDocumentFragment()
123
+ for (const child of source.childNodes) {
124
+ const clone = cloneDragPreviewNode(child)
125
+ if (clone) children.append(clone)
126
+ }
127
+ return children.childNodes.length > 0
128
+ ? children
129
+ : dragPreviewPlaceholder(source, 'Live component preview unavailable while moving')
130
+ }
131
+
132
+ const clone = source.cloneNode(false) as HTMLElement | SVGElement
133
+ for (const attribute of [
134
+ 'id',
135
+ 'style',
136
+ 'draggable',
137
+ 'data-pane-drag-handle',
138
+ 'data-pane-id',
139
+ 'src',
140
+ 'srcset',
141
+ 'href',
142
+ 'xlink:href',
143
+ 'data',
144
+ 'poster',
145
+ ])
146
+ clone.removeAttribute(attribute)
147
+ for (const attribute of Array.from(clone.attributes))
148
+ if (/^on/i.test(attribute.name)) clone.removeAttribute(attribute.name)
149
+
150
+ if (source instanceof HTMLCanvasElement && clone instanceof HTMLCanvasElement) {
151
+ const size = boundedSnapshotSize(source.width, source.height)
152
+ clone.width = size.width
153
+ clone.height = size.height
154
+ try {
155
+ const context = clone.getContext('2d')
156
+ if (!context) throw new Error('canvas snapshot context is unavailable')
157
+ context.drawImage(source, 0, 0, clone.width, clone.height)
158
+ } catch {
159
+ return dragPreviewPlaceholder(source, 'Canvas preview unavailable while moving')
160
+ }
161
+ return clone
162
+ }
163
+
164
+ for (const child of source.childNodes) {
165
+ const childClone = cloneDragPreviewNode(child)
166
+ if (childClone) clone.append(childClone)
167
+ }
168
+ return clone
169
+ }
60
170
  /** Muxy frame geometry keeps leaf owners stable; Corvu owns constrained separator interactions. */
61
171
  export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L>) {
62
172
  let root: HTMLDivElement | undefined
@@ -66,11 +176,35 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
66
176
  const prefix = createUniqueId()
67
177
  let nextDomId = 0
68
178
  let nextDragId = 0
179
+ let dragPreview: HTMLElement | undefined
69
180
  const [drag, setDrag] = createSignal<{ id: string; token: string }>()
70
181
  const [drop, setDrop] = createSignal<{ id: string; intent: PaneDropIntent }>()
71
182
  const clearDrag = () => {
72
183
  setDrag(undefined)
73
184
  setDrop(undefined)
185
+ dragPreview?.remove()
186
+ dragPreview = undefined
187
+ }
188
+ const createDragPreview = (source: HTMLElement) => {
189
+ dragPreview?.remove()
190
+ const preview = cloneDragPreviewNode(source) as HTMLElement
191
+ preview.className =
192
+ 'pointer-events-none fixed -left-full top-0 z-(--z-dialog) flex h-64 max-h-screen w-96 max-w-screen min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-xl'
193
+ preview.removeAttribute('id')
194
+ preview.removeAttribute('style')
195
+ preview.removeAttribute('role')
196
+ preview.removeAttribute('aria-label')
197
+ preview.removeAttribute('data-pane-id')
198
+ preview.removeAttribute('data-focused')
199
+ preview.removeAttribute('data-drag-source')
200
+ preview.removeAttribute('data-drop-direction')
201
+ preview.removeAttribute('data-drop-placement')
202
+ preview.setAttribute('data-split-drag-preview', '')
203
+ preview.setAttribute('aria-hidden', 'true')
204
+ preview.inert = true
205
+ document.body.append(preview)
206
+ dragPreview = preview
207
+ return preview
74
208
  }
75
209
  const domId = (id: string) => {
76
210
  const existing = domIds.get(id)
@@ -236,7 +370,7 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
236
370
  props.onMove?.(current.id, id, next)
237
371
  }}
238
372
  style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
239
- class="absolute flex min-h-0 min-w-0 flex-col overflow-hidden border border-border transition-opacity data-[drag-source]:opacity-40 data-[focused]:border-primary bg-background text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
373
+ class="absolute flex min-h-0 min-w-0 flex-col overflow-hidden border border-border transition-opacity motion-reduce:transition-none data-[drag-source]:opacity-40 data-[focused]:border-primary bg-background text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
240
374
  onFocusIn={() => props.onFocus?.(id)}
241
375
  >
242
376
  <header class="flex min-w-0 shrink-0 items-center gap-2 bg-surface px-2">
@@ -245,6 +379,7 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
245
379
  draggable={Boolean(props.onMove)}
246
380
  class={cn('flex min-w-0 flex-1 items-center gap-2 truncate text-sm', {
247
381
  'cursor-grab': Boolean(props.onMove),
382
+ 'cursor-grabbing': drag()?.id === id,
248
383
  })}
249
384
  title={props.onMove ? `Drag ${props.labelForLeaf(leaf())} to move` : undefined}
250
385
  onDragStart={(event) => {
@@ -256,18 +391,23 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
256
391
  setDrag({ id, token })
257
392
  event.dataTransfer.setData(PANE_DRAG_TYPE, token)
258
393
  event.dataTransfer.effectAllowed = 'move'
259
- // The ghost is the pane itself, not the handle strip: the
260
- // browser snapshots the region before the drag state dims
261
- // this section, so the dragged pane stays readable while
262
- // its source visibly recedes.
394
+ // The native drag image carries this pane's actual content
395
+ // in a bounded floating-window frame; the source recedes
396
+ // independently as the browser follows the image pointer.
263
397
  const source = refs.get(id)
264
398
  const frame = source?.getBoundingClientRect()
265
- if (source && frame && typeof event.dataTransfer.setDragImage === 'function')
399
+ if (source && frame && typeof event.dataTransfer.setDragImage === 'function') {
400
+ const preview = createDragPreview(source)
401
+ const previewFrame = preview.getBoundingClientRect()
402
+ const scaleX = previewFrame.width / frame.width
403
+ const scaleY = previewFrame.height / frame.height
266
404
  event.dataTransfer.setDragImage(
267
- source,
268
- Math.max(0, Math.round(event.clientX - frame.left)),
269
- Math.max(0, Math.round(event.clientY - frame.top))
405
+ preview,
406
+ Math.max(0, Math.round((event.clientX - frame.left) * scaleX)),
407
+ Math.max(0, Math.round((event.clientY - frame.top) * scaleY))
270
408
  )
409
+ preview.setAttribute('data-native-drag-image', '')
410
+ }
271
411
  }}
272
412
  onDragEnd={clearDrag}
273
413
  >
@@ -182,6 +182,10 @@ function readSelection(storageKey: string): ThemeSelection {
182
182
  font: fontOptions.some((option) => option.id === parsed.font)
183
183
  ? (parsed.font as string)
184
184
  : defaultThemeSelection.font,
185
+ // Every axis has to be read back here, or it is lost twice: the effect below
186
+ // removes the `data-density` that `themeScript` set for the first paint, and
187
+ // the next `setSelection` rewrites the store without it.
188
+ density: resolvedDensity(parsed as ThemeSelection),
185
189
  }
186
190
  } catch {
187
191
  return defaultThemeSelection