anentrypoint-design 0.0.385 → 0.0.387
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/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The shared modal shell every file dialog funnels through: Backdrop (focus
|
|
2
|
+
// trap, document-level Escape, invoker focus restore, backdrop dismiss),
|
|
3
|
+
// Modal (head/body/actions slots + a stable aria-labelledby id), and the
|
|
4
|
+
// in-body error line.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
import { shortUid } from '../../uid.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
// Full focusable set for the modal Tab trap — omitting textarea/select/a[href]
|
|
11
|
+
// lets Tab escape behind the fixed backdrop (fully obscured at mobile sizes).
|
|
12
|
+
const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
13
|
+
|
|
14
|
+
function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
|
|
15
|
+
// webjsx invokes a ref callback with the element on mount and with null on
|
|
16
|
+
// unmount. We stash the per-element keydown teardown on the node itself so
|
|
17
|
+
// the null branch can run it — otherwise the document/element listener leaks
|
|
18
|
+
// once the modal is removed.
|
|
19
|
+
const backdropRef = (el) => {
|
|
20
|
+
if (!el) return; // unmount (ref(null)) handled by wrapper below
|
|
21
|
+
const modal = el.querySelector('.ds-modal');
|
|
22
|
+
if (!modal) return;
|
|
23
|
+
|
|
24
|
+
const handleKeydown = (e) => {
|
|
25
|
+
// Escape closes the modal — unless a mutation is in flight (the live
|
|
26
|
+
// busy state is read off the data-busy attribute, which re-renders;
|
|
27
|
+
// this handler's closure is bound once at mount).
|
|
28
|
+
if (e.key === 'Escape') {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
if (el.dataset.busy === '1') return;
|
|
31
|
+
if (onClose) onClose();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Focus trap: re-query focusables on each Tab press so that buttons
|
|
35
|
+
// disabled mid-flight (busy state) are excluded from the cycle and
|
|
36
|
+
// do not break tab navigation.
|
|
37
|
+
if (e.key === 'Tab') {
|
|
38
|
+
const focusables = modal.querySelectorAll(FOCUSABLE_SEL);
|
|
39
|
+
if (focusables.length === 0) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const firstFocusable = focusables[0];
|
|
44
|
+
const lastFocusable = focusables[focusables.length - 1];
|
|
45
|
+
if (e.shiftKey) {
|
|
46
|
+
if (document.activeElement === firstFocusable) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
lastFocusable.focus();
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
if (document.activeElement === lastFocusable) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
firstFocusable.focus();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Escape must close the modal no matter where focus sits (re-renders
|
|
60
|
+
// can bounce focus out of the dialog), so listen at document level
|
|
61
|
+
// for the modal's lifetime.
|
|
62
|
+
document.addEventListener('keydown', handleKeydown, true);
|
|
63
|
+
// Record the invoker BEFORE the modal steals focus, so close (confirm,
|
|
64
|
+
// cancel, Escape, backdrop click) restores keyboard/AT focus to where
|
|
65
|
+
// the user was (e.g. the FileGrid row button) instead of <body>.
|
|
66
|
+
// Re-mounts mid-lifetime (every app render re-runs this ref) keep the
|
|
67
|
+
// ORIGINAL invoker and never re-steal focus from the user.
|
|
68
|
+
const invoker = el.contains(document.activeElement) ? (Backdrop._invoker || document.activeElement) : document.activeElement;
|
|
69
|
+
if (!Backdrop._invoker) Backdrop._invoker = invoker;
|
|
70
|
+
el._dsModalTeardown = (removed) => {
|
|
71
|
+
document.removeEventListener('keydown', handleKeydown, true);
|
|
72
|
+
// Only restore focus when the modal is genuinely going away (not a
|
|
73
|
+
// re-render remount) and focus is not already somewhere useful.
|
|
74
|
+
if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
|
|
75
|
+
try { Backdrop._invoker.focus(); } catch { /* swallow: restoring focus on close is best-effort, teardown still completes */ }
|
|
76
|
+
}
|
|
77
|
+
if (removed) Backdrop._invoker = null;
|
|
78
|
+
};
|
|
79
|
+
// Auto-focus on open - only when focus is not already inside the modal
|
|
80
|
+
// (re-renders must not yank the caret around).
|
|
81
|
+
if (!el.contains(document.activeElement)) {
|
|
82
|
+
const preferred = modal.querySelector('[autofocus]') || modal.querySelector(FOCUSABLE_SEL);
|
|
83
|
+
if (preferred) preferred.focus();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return h('div', {
|
|
88
|
+
class: 'ds-modal-backdrop',
|
|
89
|
+
// Live busy flag read by the mount-bound Escape handler + backdrop click.
|
|
90
|
+
'data-busy': busy ? '1' : '0',
|
|
91
|
+
ref: (el) => {
|
|
92
|
+
if (el) {
|
|
93
|
+
// A remount in the same tick (render churn) is not a close:
|
|
94
|
+
// cancel the pending removal teardown before re-binding.
|
|
95
|
+
Backdrop._pendingRemoval = false;
|
|
96
|
+
backdropRef(el);
|
|
97
|
+
Backdrop._last = el;
|
|
98
|
+
} else if (Backdrop._last && Backdrop._last._dsModalTeardown) {
|
|
99
|
+
const t = Backdrop._last._dsModalTeardown;
|
|
100
|
+
Backdrop._last = null;
|
|
101
|
+
Backdrop._pendingRemoval = true;
|
|
102
|
+
t(false); // always unhook the document listener now
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
// Still gone next microtask -> genuine close: restore focus.
|
|
105
|
+
if (Backdrop._pendingRemoval) { t(true); Backdrop._pendingRemoval = false; }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
onclick: (e) => {
|
|
110
|
+
if (e.target !== e.currentTarget) return;
|
|
111
|
+
if (e.currentTarget.dataset.busy === '1') return; // no mid-flight close
|
|
112
|
+
if (onClose) onClose();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
h('div', {
|
|
116
|
+
class: 'ds-modal' + (kind ? ' ds-modal-' + kind : ''),
|
|
117
|
+
role: 'dialog', 'aria-modal': 'true',
|
|
118
|
+
...(labelledBy ? { 'aria-labelledby': labelledBy } : {})
|
|
119
|
+
}, ...(Array.isArray(children) ? children : [children]))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Shared modal shell: head + body + actions row. ConfirmDialog/PromptDialog/
|
|
124
|
+
// FileViewer all funnel through this so the ds-modal markup is authored once.
|
|
125
|
+
// `actions` is an array of vnodes (already using the Btn primitive). Any of the
|
|
126
|
+
// slots may be omitted.
|
|
127
|
+
export function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
|
|
128
|
+
// Give the head a stable id so the dialog can point aria-labelledby at it,
|
|
129
|
+
// exposing the title as the dialog's accessible name to screen readers.
|
|
130
|
+
// The id is minted once per Modal() call with a short random suffix so it
|
|
131
|
+
// stays constant across re-renders of the same dialog instance — an
|
|
132
|
+
// incrementing counter advances on every render, leaving the previous
|
|
133
|
+
// aria-labelledby reference pointing at a now-absent element.
|
|
134
|
+
const headId = head != null ? ('ds-modal-head-' + shortUid(6)) : null;
|
|
135
|
+
return Backdrop({
|
|
136
|
+
onClose,
|
|
137
|
+
kind,
|
|
138
|
+
busy,
|
|
139
|
+
labelledBy: headId,
|
|
140
|
+
children: [
|
|
141
|
+
head != null ? h('div', { id: headId, class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
|
|
142
|
+
body != null ? h('div', { class: bodyClass, ...bodyAttrs }, ...(Array.isArray(body) ? body : [body])) : null,
|
|
143
|
+
actions != null ? h('div', { class: 'ds-modal-actions' }, ...(Array.isArray(actions) ? actions : [actions])) : null,
|
|
144
|
+
].filter(Boolean)
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A role=alert error line rendered INSIDE the modal body (so a 409/403 from a
|
|
149
|
+
// mutation is visible at the point of action, inside the focus trap — not a
|
|
150
|
+
// sibling stuck in page flow behind the fixed backdrop).
|
|
151
|
+
export function modalError(error) {
|
|
152
|
+
return error ? h('p', { class: 'ds-modal-error', role: 'alert' }, String(error)) : null;
|
|
153
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The three preview body renderers a host drops into FileViewer /
|
|
2
|
+
// FilePreviewPane: media (image with fit/actual toggle, video, audio, or a
|
|
3
|
+
// typed fallback), code (gutter + Prism highlight, optional wrap toggle and
|
|
4
|
+
// source/preview mode switch), and plain text.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
import { Icon } from '../shell.js';
|
|
8
|
+
import { fileGlyph } from '../files.js';
|
|
9
|
+
import { highlightAllUnder } from '../../highlight.js';
|
|
10
|
+
const h = webjsx.createElement;
|
|
11
|
+
|
|
12
|
+
export function FilePreviewMedia({ src, type = 'other', name } = {}) {
|
|
13
|
+
if (type === 'image') {
|
|
14
|
+
// Fit-to-pane (default) vs actual-size (1:1) toggle + a checkerboard so
|
|
15
|
+
// transparency reads. The toggle flips a class on the img in-place and
|
|
16
|
+
// reports the natural pixel dimensions into its own caption on load.
|
|
17
|
+
const onToggle = (e) => {
|
|
18
|
+
const wrap = e.currentTarget.closest('.ds-preview-media-wrap');
|
|
19
|
+
const img = wrap && wrap.querySelector('.ds-preview-media');
|
|
20
|
+
if (!img) return;
|
|
21
|
+
const actual = img.classList.toggle('is-actual');
|
|
22
|
+
e.currentTarget.textContent = actual ? 'fit to pane' : 'actual size';
|
|
23
|
+
};
|
|
24
|
+
const onLoad = (e) => {
|
|
25
|
+
const img = e.currentTarget;
|
|
26
|
+
const cap = img.closest('.ds-preview-media-wrap');
|
|
27
|
+
const dim = cap && cap.querySelector('.ds-preview-media-dim');
|
|
28
|
+
if (dim && img.naturalWidth) dim.textContent = img.naturalWidth + ' x ' + img.naturalHeight + ' px';
|
|
29
|
+
};
|
|
30
|
+
return h('div', { class: 'ds-preview-media-wrap' },
|
|
31
|
+
h('img', { class: 'ds-preview-media ds-preview-media-alpha', src, alt: name || '', onload: onLoad }),
|
|
32
|
+
h('div', { class: 'ds-preview-media-controls' },
|
|
33
|
+
h('span', { class: 'ds-preview-media-dim', 'aria-live': 'polite' }, ''),
|
|
34
|
+
h('button', { type: 'button', class: 'chat-code-copy', onclick: onToggle }, 'actual size')));
|
|
35
|
+
}
|
|
36
|
+
if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
|
|
37
|
+
if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
|
|
38
|
+
return h('div', { class: 'ds-preview-fallback' },
|
|
39
|
+
h('span', { class: 'ds-preview-glyph', 'aria-hidden': 'true' }, Icon(fileGlyph(type))),
|
|
40
|
+
h('span', {}, 'no inline preview for ' + (type || 'this file'))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// FilePreviewCode — the code/source pane. Two additive, opt-in affordances
|
|
45
|
+
// ported from pi-web's FileViewer (behavior only, not its React/SSE plumbing):
|
|
46
|
+
// wrap : host-controlled wrap-lines toggle. Pass `wrap` (current
|
|
47
|
+
// state) + `onWrapToggle` to show the control; omitted host
|
|
48
|
+
// keeps the old always-'pre' behavior (no regression).
|
|
49
|
+
// previewHtml : when the host has already rendered markdown/HTML to a safe
|
|
50
|
+
// HTML string (e.g. via markdown-cache.js's
|
|
51
|
+
// renderMarkdownCached, or a sanitized srcDoc for raw HTML
|
|
52
|
+
// files) it passes it here + `previewLabel` (defaults
|
|
53
|
+
// 'preview') to get a source/preview mode switcher, mirroring
|
|
54
|
+
// pi-web's DisplayMode tabs. This component never renders
|
|
55
|
+
// unsanitized markdown itself — that stays the host's job
|
|
56
|
+
// (chat.js already owns the sanitize+render pipeline).
|
|
57
|
+
export function FilePreviewCode({ content = '', lang, filename, wrap, onWrapToggle, previewHtml, previewLabel = 'preview', mode, onModeChange } = {}) {
|
|
58
|
+
// A filename/lang header matching the chat CodeNode's .chat-code-head, plus
|
|
59
|
+
// the same copy control (chat A1/A2 ship this run, so preview matches for
|
|
60
|
+
// full cross-surface consistency).
|
|
61
|
+
const onCopy = (e) => {
|
|
62
|
+
const btn = e.currentTarget;
|
|
63
|
+
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
64
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
|
|
65
|
+
else { try { const t = document.createElement('textarea'); t.value = content; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch { /* swallow: legacy execCommand copy fallback unsupported, nothing more to try */ } }
|
|
66
|
+
};
|
|
67
|
+
const hasPreview = previewHtml != null && onModeChange;
|
|
68
|
+
const activeMode = hasPreview ? (mode || 'source') : 'source';
|
|
69
|
+
const modeSwitch = hasPreview ? h('div', { class: 'ds-preview-mode-switch', role: 'group', 'aria-label': 'file view mode' },
|
|
70
|
+
h('button', { type: 'button', class: 'ds-preview-mode-btn' + (activeMode === 'source' ? ' active' : ''),
|
|
71
|
+
'aria-pressed': activeMode === 'source' ? 'true' : 'false', onclick: () => onModeChange('source') }, 'source'),
|
|
72
|
+
h('button', { type: 'button', class: 'ds-preview-mode-btn' + (activeMode === 'preview' ? ' active' : ''),
|
|
73
|
+
'aria-pressed': activeMode === 'preview' ? 'true' : 'false', onclick: () => onModeChange('preview') }, previewLabel)
|
|
74
|
+
) : null;
|
|
75
|
+
const wrapCtl = (onWrapToggle && activeMode === 'source') ? h('button', {
|
|
76
|
+
type: 'button', class: 'chat-code-copy ds-preview-wrap-toggle' + (wrap ? ' active' : ''),
|
|
77
|
+
title: wrap ? 'disable word wrap' : 'enable word wrap',
|
|
78
|
+
'aria-label': wrap ? 'disable word wrap' : 'enable word wrap',
|
|
79
|
+
'aria-pressed': wrap ? 'true' : 'false',
|
|
80
|
+
onclick: () => onWrapToggle(!wrap),
|
|
81
|
+
}, 'wrap') : null;
|
|
82
|
+
return h('div', { class: 'ds-preview-code-wrap' },
|
|
83
|
+
h('div', { class: 'chat-code-head ds-preview-code-head' },
|
|
84
|
+
h('span', { class: 'lang' }, lang || 'text'),
|
|
85
|
+
filename ? h('span', { class: 'name' }, filename) : null,
|
|
86
|
+
h('span', { class: 'spread' }),
|
|
87
|
+
modeSwitch,
|
|
88
|
+
wrapCtl,
|
|
89
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
|
|
90
|
+
activeMode === 'preview'
|
|
91
|
+
// webjsx has no innerHTML prop — set it imperatively via ref, same
|
|
92
|
+
// pattern as chat-message-parts.js/community.js. `previewHtml` is
|
|
93
|
+
// the HOST's already-sanitized HTML (e.g. via markdown-cache.js's
|
|
94
|
+
// renderMarkdownCached, which owns its own sanitize step); this
|
|
95
|
+
// component never sanitizes or fetches on its own.
|
|
96
|
+
? h('div', { class: 'ds-preview-html', ref: (el) => { if (el) el.innerHTML = previewHtml; } })
|
|
97
|
+
: codeBody({ content, lang, wrap })
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The code body: a non-selectable line-number gutter + the highlighted code.
|
|
102
|
+
// A ref triggers Prism over the <code> after mount (the bundle only auto-runs
|
|
103
|
+
// Prism in the chat path), so the file preview is token-colored like Claude
|
|
104
|
+
// Code's file pane. lineNumbers defaults on for code, off for plaintext.
|
|
105
|
+
function codeBody({ content = '', lang, wrap = false } = {}) {
|
|
106
|
+
const wantGutter = !!lang;
|
|
107
|
+
const lineCount = content ? content.split('\n').length : 1;
|
|
108
|
+
const gutter = wantGutter
|
|
109
|
+
? h('div', { class: 'ds-preview-gutter', 'aria-hidden': 'true' },
|
|
110
|
+
Array.from({ length: lineCount }, (_, i) => String(i + 1)).join('\n'))
|
|
111
|
+
: null;
|
|
112
|
+
const highlightRef = (el) => {
|
|
113
|
+
if (!el) return;
|
|
114
|
+
try { highlightAllUnder(el); } catch { /* swallow: syntax highlighting is a progressive enhancement, plain code still renders */ }
|
|
115
|
+
};
|
|
116
|
+
// wrap: ported from pi-web's wrapLines toggle — pre-wrap + anywhere-break
|
|
117
|
+
// instead of the default horizontal-scroll 'pre', for long unbroken lines
|
|
118
|
+
// (minified JS, long log lines) that are easier to read wrapped.
|
|
119
|
+
return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : '') + (wrap ? ' is-wrapped' : ''), ref: highlightRef },
|
|
120
|
+
gutter,
|
|
121
|
+
h('code', { class: lang ? 'language-' + lang : '' }, content));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function FilePreviewText({ content = '', truncated } = {}) {
|
|
125
|
+
return h('pre', { class: 'ds-preview-text' },
|
|
126
|
+
h('code', {}, content),
|
|
127
|
+
truncated ? h('div', { class: 'ds-preview-truncated' }, '… (truncated)') : null
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The two preview containers around a body renderer: FileViewer (the modal
|
|
2
|
+
// overlay, focus-trapped, kept as the narrow-viewport fallback) and
|
|
3
|
+
// FilePreviewPane (the persistent non-modal side pane for a WorkspaceShell
|
|
4
|
+
// pane slot). Both share the head, the ArrowLeft/Right stepper, and the
|
|
5
|
+
// swipe-to-step gesture.
|
|
6
|
+
|
|
7
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
8
|
+
import { Icon } from '../shell.js';
|
|
9
|
+
import { fmtFileSize } from '../files.js';
|
|
10
|
+
import { Modal } from './modal-shell.js';
|
|
11
|
+
const h = webjsx.createElement;
|
|
12
|
+
|
|
13
|
+
// Shared preview-head children for both the modal FileViewer and the inline
|
|
14
|
+
// FilePreviewPane: name + meta + prev/next stepper + download + close. ASCII
|
|
15
|
+
// prev/next words (no glyph arrows). onPrev/onNext are omitted when there is no
|
|
16
|
+
// previewable neighbour in that direction.
|
|
17
|
+
function previewHead({ file, onClose, onAction, onPrev, onNext } = {}) {
|
|
18
|
+
const meta = [file.type, file.size != null ? fmtFileSize(file.size) : null, file.modified || null]
|
|
19
|
+
.filter(Boolean).join(' · ');
|
|
20
|
+
return [
|
|
21
|
+
h('span', { class: 'ds-preview-name', title: file.path || file.name || '' }, file.name || ''),
|
|
22
|
+
h('span', { class: 'ds-preview-meta' }, meta),
|
|
23
|
+
h('span', { class: 'ds-preview-actions' },
|
|
24
|
+
(onPrev || onNext) ? h('span', { class: 'ds-preview-step', role: 'group', 'aria-label': 'step files' },
|
|
25
|
+
h('button', { class: 'ds-file-act', title: 'previous file', 'aria-label': 'previous file', disabled: onPrev ? null : true, onclick: () => onPrev && onPrev() }, 'prev'),
|
|
26
|
+
h('button', { class: 'ds-file-act', title: 'next file', 'aria-label': 'next file', disabled: onNext ? null : true, onclick: () => onNext && onNext() }, 'next')) : null,
|
|
27
|
+
onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
|
|
28
|
+
onClose ? h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x')) : null
|
|
29
|
+
)
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ArrowLeft/Right step the preview when focus is inside it (both pane + modal).
|
|
34
|
+
function previewKeyNav(onPrev, onNext) {
|
|
35
|
+
return (e) => {
|
|
36
|
+
if (e.key === 'ArrowLeft' && onPrev) { e.preventDefault(); onPrev(); }
|
|
37
|
+
else if (e.key === 'ArrowRight' && onNext) { e.preventDefault(); onNext(); }
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Touch stepping: horizontal swipe on the preview body steps prev/next. Skips
|
|
42
|
+
// when the gesture starts inside a horizontally-scrollable child (code <pre>)
|
|
43
|
+
// so panning wide code never flips files.
|
|
44
|
+
function previewSwipe(onPrev, onNext) {
|
|
45
|
+
if (!onPrev && !onNext) return {};
|
|
46
|
+
let sx = null, sy = null;
|
|
47
|
+
return {
|
|
48
|
+
onpointerdown: (e) => {
|
|
49
|
+
const scroller = e.target.closest && e.target.closest('pre');
|
|
50
|
+
if (scroller && scroller.scrollWidth > scroller.clientWidth) { sx = null; return; }
|
|
51
|
+
sx = e.clientX; sy = e.clientY;
|
|
52
|
+
},
|
|
53
|
+
onpointerup: (e) => {
|
|
54
|
+
if (sx == null) return;
|
|
55
|
+
const dx = e.clientX - sx, dy = e.clientY - sy;
|
|
56
|
+
sx = null;
|
|
57
|
+
if (Math.abs(dx) < 48 || Math.abs(dy) > Math.abs(dx)) return;
|
|
58
|
+
if (dx < 0 && onNext) onNext();
|
|
59
|
+
else if (dx > 0 && onPrev) onPrev();
|
|
60
|
+
},
|
|
61
|
+
onpointercancel: () => { sx = null; },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function FileViewer({ file, body, onClose, onAction, onPrev, onNext } = {}) {
|
|
66
|
+
if (!file) return null;
|
|
67
|
+
const keyNav = previewKeyNav(onPrev, onNext);
|
|
68
|
+
return Modal({
|
|
69
|
+
onClose,
|
|
70
|
+
kind: 'preview',
|
|
71
|
+
headClass: 'ds-preview-head',
|
|
72
|
+
headAttrs: { 'data-file-type': file.type || 'other', onkeydown: keyNav },
|
|
73
|
+
head: previewHead({ file, onClose, onAction, onPrev, onNext }),
|
|
74
|
+
bodyClass: 'ds-preview-body',
|
|
75
|
+
bodyAttrs: { 'data-file-type': file.type || 'other', onkeydown: keyNav, ...previewSwipe(onPrev, onNext) },
|
|
76
|
+
body: Array.isArray(body) ? body : [body],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// FilePreviewPane — the SAME preview, but as a persistent, non-modal side pane
|
|
81
|
+
// for the WorkspaceShell's pane slot (the split-view, claude-Desktop file-pane
|
|
82
|
+
// feel). Distinct from the overlay FileViewer (kept as the <900px fallback).
|
|
83
|
+
// Not focus-trapped (it is not modal); ArrowLeft/Right step files when focused.
|
|
84
|
+
export function FilePreviewPane({ file, body, onClose, onAction, onPrev, onNext } = {}) {
|
|
85
|
+
if (!file) {
|
|
86
|
+
return h('div', { class: 'ds-preview-pane ds-preview-pane-empty', role: 'status' },
|
|
87
|
+
h('span', {}, 'Select a file to preview'));
|
|
88
|
+
}
|
|
89
|
+
return h('div', { class: 'ds-preview-pane', role: 'region', 'aria-label': 'file preview: ' + (file.name || ''),
|
|
90
|
+
tabindex: '0', onkeydown: previewKeyNav(onPrev, onNext) },
|
|
91
|
+
h('div', { class: 'ds-preview-head', 'data-file-type': file.type || 'other' },
|
|
92
|
+
...previewHead({ file, onClose, onAction, onPrev, onNext })),
|
|
93
|
+
h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
|
|
94
|
+
...(Array.isArray(body) ? body : [body]))
|
|
95
|
+
);
|
|
96
|
+
}
|