@getdom/studio 0.1.0 → 0.2.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/CHANGELOG.md +31 -0
- package/README.md +53 -1
- package/SKILL.md +21 -1
- package/package.json +6 -2
- package/public/llms.txt +13 -2
- package/src/pages/_layout/componentCatalog.generated.js +1185 -44
- package/src/pages/_layout/componentLoaders.js +41 -0
- package/src/pages/_layout/componentManager.js +38 -27
- package/src/pages/_layout/componentRuntimeRecords.js +3 -1
- package/src/pages/charts/_shared/ChartContainer.vue +67 -0
- package/src/pages/charts/_shared/ChartLegend.vue +22 -0
- package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
- package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
- package/src/pages/charts/_shared/XYChart.vue +140 -0
- package/src/pages/charts/_shared/XYRenderer.vue +90 -0
- package/src/pages/charts/_shared/chartProps.js +23 -0
- package/src/pages/charts/_shared/chartUtils.js +95 -0
- package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
- package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
- package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
- package/src/pages/charts/date-range-picker/dateRange.js +69 -0
- package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
- package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
- package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
- package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
- package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
- package/src/pages/components/_shared/media/useMediaInput.js +200 -0
- package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
- package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
- package/src/pages/components/button/DomButton.vue +10 -4
- package/src/pages/components/button/buttonStyles.js +5 -0
- package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
- package/src/pages/components/dialog/DomDialog.vue +338 -36
- package/src/pages/components/icon-button/DomIconButton.vue +8 -2
- package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
- package/src/pages/components/menu-stack/menuTree.js +35 -0
- package/src/pages/components/popover/DomPopover.vue +1 -53
- package/src/pages/components/popover/popover.css +50 -0
- package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
- package/src/pages/data/gantt/DomGantt.vue +788 -0
- package/src/pages/data/gantt/ganttTime.js +223 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
- package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
- package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
- package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
- package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
- package/src/pages/designer/_shared/cssEditorContext.js +19 -0
- package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
- package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
- package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
- package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
- package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
- package/src/pages/designer/background-editor/backgroundModel.js +102 -0
- package/src/pages/designer/border-editor/BorderControl.vue +72 -0
- package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
- package/src/pages/designer/border-editor/borderModel.js +21 -0
- package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
- package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
- package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
- package/src/pages/designer/frame/frameLayout.js +93 -0
- package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
- package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
- package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
- package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
- package/src/pages/designer/stage/DesignerBounds.vue +31 -0
- package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
- package/src/pages/designer/stage/SpacingGuides.vue +33 -0
- package/src/pages/designer/stage/resizeGeometry.js +15 -0
- package/src/pages/designer/stage/snapGeometry.js +69 -0
- package/src/pages/designer/stage/snapPipeline.js +17 -0
- package/src/pages/designer/stage/spacingGeometry.js +129 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
- package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
- package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
- package/src/pages/forms/toggle/DomToggle.vue +1 -1
- package/src/pages/lib/headless/base.js +10 -5
- package/src/pages/lib/headless/dialog.js +25 -6
- package/src/pages/lib/headless/drawer.js +49 -21
- package/src/pages/lib/vue/index.d.ts +1161 -14
- package/src/pages/lib/vue/index.js +43 -0
- package/src/pages/mobile/_shared/interactionMath.js +46 -0
- package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
- package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
- package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
- package/src/pages/mobile/shell/DomAppShell.vue +60 -9
- package/src/pages/mobile/stack/DomAppStack.vue +214 -55
- package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
- package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
- package/src/style.css +2 -445
- package/src/tailwind.css +351 -0
- package/src/theme.css +99 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue';
|
|
3
|
+
import { resolveSwipeRelease } from '../_shared/interactionMath.js';
|
|
4
|
+
|
|
5
|
+
defineOptions({
|
|
6
|
+
__doc: {
|
|
7
|
+
name: 'Swipe actions',
|
|
8
|
+
tag: '<DomSwipeActions>',
|
|
9
|
+
description: 'Reveal row actions with a horizontal swipe while preserving vertical scrolling. Includes an Actions button, keyboard access, coordinated open rows, and opt-in full-swipe shortcuts. Applications own mutations and undo.',
|
|
10
|
+
slots: [
|
|
11
|
+
{ name: '(default)', description: 'Row content, including an ordinary button or link.' },
|
|
12
|
+
{ name: 'action', payload: '{ action, side }', description: 'Optional visual content inside each action button.' },
|
|
13
|
+
],
|
|
14
|
+
events: [
|
|
15
|
+
{ name: 'action', payload: 'SwipeActionPayload', typeRef: 'SwipeActionPayload', description: 'An enabled action selected by button or full swipe. source is button or swipe.' },
|
|
16
|
+
{ name: 'open', payload: 'string', description: 'The revealed side: start, end, or menu.' },
|
|
17
|
+
{ name: 'close', description: 'All action controls have closed.' },
|
|
18
|
+
],
|
|
19
|
+
keyboard: [
|
|
20
|
+
{ keys: 'Actions button', action: 'Shows every action without requiring a gesture.' },
|
|
21
|
+
{ keys: 'Escape', action: 'Closes revealed actions and returns focus to Actions.' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const props = defineProps({
|
|
27
|
+
label: { type: String, default: 'Row', _edit: { description: 'Row name used in the accessible Actions button label.' } },
|
|
28
|
+
startActions: { type: Array, default: () => [], _edit: { typeRef: 'Array<SwipeAction>', description: 'Leading actions: { value, label, icon, variant, disabled, fullSwipe }. icon is an SVG path.' } },
|
|
29
|
+
endActions: { type: Array, default: () => [], _edit: { typeRef: 'Array<SwipeAction>', description: 'Trailing actions with the same shape as startActions.' } },
|
|
30
|
+
fullSwipe: { type: Boolean, default: false, _edit: { description: 'Allow the first action on each side to run after a full swipe only when that action also has fullSwipe: true.' } },
|
|
31
|
+
disabled: { type: Boolean, default: false, _edit: { description: 'Disable gestures and all action controls.' } },
|
|
32
|
+
group: { type: String, default: 'default', _edit: { description: 'Opening a row closes other rows in this group in the same document.' } },
|
|
33
|
+
});
|
|
34
|
+
const emit = defineEmits(['action', 'open', 'close']);
|
|
35
|
+
const root = ref(null);
|
|
36
|
+
const surface = ref(null);
|
|
37
|
+
const startRail = ref(null);
|
|
38
|
+
const endRail = ref(null);
|
|
39
|
+
const menuButton = ref(null);
|
|
40
|
+
const offset = ref(0);
|
|
41
|
+
const dragging = ref(false);
|
|
42
|
+
const menuOpen = ref(false);
|
|
43
|
+
const openSide = ref('');
|
|
44
|
+
const armed = ref(false);
|
|
45
|
+
const menuId = `dom-swipe-${useId()}`;
|
|
46
|
+
const actions = computed(() => [
|
|
47
|
+
...props.startActions.map((action) => ({ action, side: 'start' })),
|
|
48
|
+
...props.endActions.map((action) => ({ action, side: 'end' })),
|
|
49
|
+
]);
|
|
50
|
+
const activeSide = computed(() => offset.value === 0 ? '' : offset.value * direction() > 0 ? 'start' : 'end');
|
|
51
|
+
const activeActions = computed(() => activeSide.value === 'start' ? props.startActions : props.endActions);
|
|
52
|
+
let gesture = null;
|
|
53
|
+
let suppressClickUntil = 0;
|
|
54
|
+
let doc;
|
|
55
|
+
let observer;
|
|
56
|
+
|
|
57
|
+
/** Returns the action rail width, capped to leave part of the row visible. */
|
|
58
|
+
function railWidth(side) {
|
|
59
|
+
const rail = side === 'start' ? startRail.value : endRail.value;
|
|
60
|
+
return rail?.getBoundingClientRect().width || 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolves leading/trailing direction from the row's own document and CSS. */
|
|
64
|
+
function direction() {
|
|
65
|
+
return root.value?.ownerDocument.defaultView.getComputedStyle(root.value).direction === 'rtl' ? -1 : 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Releases pointer capture without interpreting cancellation as an action. */
|
|
69
|
+
function cancelGesture() {
|
|
70
|
+
const previous = gesture;
|
|
71
|
+
gesture = null;
|
|
72
|
+
dragging.value = false;
|
|
73
|
+
armed.value = false;
|
|
74
|
+
if (previous && !previous.touch && surface.value?.hasPointerCapture?.(previous.id)) surface.value.releasePointerCapture(previous.id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Closes both gesture and button presentations, optionally returning focus. */
|
|
78
|
+
function close(restoreFocus = false) {
|
|
79
|
+
const wasOpen = openSide.value || menuOpen.value;
|
|
80
|
+
cancelGesture();
|
|
81
|
+
offset.value = 0;
|
|
82
|
+
openSide.value = '';
|
|
83
|
+
menuOpen.value = false;
|
|
84
|
+
if (restoreFocus) menuButton.value?.focus({ preventScroll: true });
|
|
85
|
+
if (wasOpen) emit('close');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Announces the active row so sibling instances close before it opens. */
|
|
89
|
+
function announce(side) {
|
|
90
|
+
doc?.dispatchEvent(new doc.defaultView.CustomEvent('dom:swipe-open', { detail: { element: root.value, group: props.group } }));
|
|
91
|
+
emit('open', side);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reveals one rail through the public component method. */
|
|
95
|
+
function reveal(side) {
|
|
96
|
+
if (props.disabled || !['start', 'end'].includes(side) || !railWidth(side)) return;
|
|
97
|
+
cancelGesture();
|
|
98
|
+
menuOpen.value = false;
|
|
99
|
+
openSide.value = side;
|
|
100
|
+
offset.value = railWidth(side) * (side === 'start' ? 1 : -1) * direction();
|
|
101
|
+
announce(side);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Opens an explicit, focusable menu containing actions from both sides. */
|
|
105
|
+
async function toggleMenu() {
|
|
106
|
+
if (props.disabled) return;
|
|
107
|
+
if (menuOpen.value) return close(true);
|
|
108
|
+
close();
|
|
109
|
+
menuOpen.value = true;
|
|
110
|
+
announce('menu');
|
|
111
|
+
await nextTick();
|
|
112
|
+
root.value?.querySelector('[data-swipe-menu] button:not(:disabled)')?.focus();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Emits an enabled action after closing the controls; persistence stays with the app. */
|
|
116
|
+
function selectAction(action, side, source = 'button') {
|
|
117
|
+
if (props.disabled || action.disabled) return;
|
|
118
|
+
close(source === 'button');
|
|
119
|
+
emit('action', { action, side, source });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Starts observation without capturing the pointer until horizontal intent is clear. */
|
|
123
|
+
function onPointerDown(event) {
|
|
124
|
+
if (event.pointerType === 'touch' || props.disabled || event.button !== 0 || !event.isPrimary || gesture) return;
|
|
125
|
+
if (event.target.closest('input, textarea, select, [contenteditable], [data-swipe-toggle], [data-no-swipe]')) return;
|
|
126
|
+
gesture = { id: event.pointerId, touch: event.pointerType === 'direct-touch', x: event.clientX, y: event.clientY, start: offset.value, lastX: event.clientX, time: event.timeStamp, velocity: 0, initialSide: openSide.value, active: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Adapts a touch sample to the shared gesture coordinates. String ids keep native
|
|
131
|
+
* pointer cancellation separate from the touch stream that owns scrolling.
|
|
132
|
+
* @param {TouchEvent} event Native event whose scrolling default may be cancelled.
|
|
133
|
+
* @param {Touch} touch The single tracked contact.
|
|
134
|
+
* @returns {object} Gesture sample accepted by the shared handlers.
|
|
135
|
+
*/
|
|
136
|
+
function touchSample(event, touch) {
|
|
137
|
+
return {
|
|
138
|
+
pointerId: `touch-${touch.identifier}`, pointerType: 'direct-touch',
|
|
139
|
+
clientX: touch.clientX, clientY: touch.clientY, timeStamp: event.timeStamp,
|
|
140
|
+
target: event.target, button: 0, isPrimary: true,
|
|
141
|
+
/** Prevents native scrolling only after horizontal intent is established. */
|
|
142
|
+
preventDefault() { if (event.cancelable) event.preventDefault(); },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Observes one finger while leaving taps, vertical scrolling, and pinch zoom native. */
|
|
147
|
+
function onTouchStart(event) {
|
|
148
|
+
if (event.touches.length !== 1) {
|
|
149
|
+
if (gesture?.touch) onPointerCancel({ pointerId: gesture.id });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
onPointerDown(touchSample(event, event.touches[0]));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Locks scrolling for the rest of a horizontal gesture, including curved thumb
|
|
157
|
+
* paths. A non-passive listener must cancel touchmove before native scrolling
|
|
158
|
+
* starts; preventing pointermove or changing touch-action mid-gesture is too late.
|
|
159
|
+
*/
|
|
160
|
+
function onTouchMove(event) {
|
|
161
|
+
if (!gesture?.touch) return;
|
|
162
|
+
if (event.touches.length !== 1 || !event.cancelable) return onPointerCancel({ pointerId: gesture.id });
|
|
163
|
+
const touch = Array.from(event.touches).find((touch) => `touch-${touch.identifier}` === gesture.id);
|
|
164
|
+
if (touch) onPointerMove(touchSample(event, touch));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Settles only the tracked contact; interrupted or multi-touch gestures never commit. */
|
|
168
|
+
function onTouchEnd(event) {
|
|
169
|
+
if (!gesture?.touch) return;
|
|
170
|
+
const touch = Array.from(event.changedTouches).find((touch) => `touch-${touch.identifier}` === gesture.id);
|
|
171
|
+
if (!touch) return;
|
|
172
|
+
const sample = touchSample(event, touch);
|
|
173
|
+
if (event.type === 'touchcancel') onPointerCancel(sample);
|
|
174
|
+
else onPointerUp(sample);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tracks horizontal movement on the initially revealed side, clamping reversals at closed.
|
|
179
|
+
* The initial axis decision stays locked until release, even when a thumb arcs.
|
|
180
|
+
* @param {PointerEvent} event Current pointer position and timing.
|
|
181
|
+
* @returns {void}
|
|
182
|
+
*/
|
|
183
|
+
function onPointerMove(event) {
|
|
184
|
+
if (!gesture || event.pointerId !== gesture.id) return;
|
|
185
|
+
const dx = event.clientX - gesture.x;
|
|
186
|
+
const dy = event.clientY - gesture.y;
|
|
187
|
+
if (!gesture.active) {
|
|
188
|
+
if (Math.abs(dy) > 8 && Math.abs(dy) > Math.abs(dx)) return cancelGesture();
|
|
189
|
+
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy)) return;
|
|
190
|
+
gesture.active = true;
|
|
191
|
+
gesture.side = gesture.initialSide || (dx * direction() > 0 ? 'start' : 'end');
|
|
192
|
+
dragging.value = true;
|
|
193
|
+
menuOpen.value = false;
|
|
194
|
+
if (!gesture.touch) surface.value.setPointerCapture(event.pointerId);
|
|
195
|
+
announce(gesture.side);
|
|
196
|
+
}
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
const elapsed = Math.max(1, event.timeStamp - gesture.time);
|
|
199
|
+
const velocity = (event.clientX - gesture.lastX) / elapsed;
|
|
200
|
+
// Smooth sample jitter, but react immediately to a reversal or a paused finger.
|
|
201
|
+
gesture.velocity = elapsed > 100 || Math.sign(velocity) !== Math.sign(gesture.velocity)
|
|
202
|
+
? velocity : velocity * 0.65 + gesture.velocity * 0.35;
|
|
203
|
+
gesture.lastX = event.clientX;
|
|
204
|
+
gesture.time = event.timeStamp;
|
|
205
|
+
const next = gesture.start + dx;
|
|
206
|
+
const side = gesture.side;
|
|
207
|
+
const sign = (side === 'start' ? 1 : -1) * direction();
|
|
208
|
+
const width = railWidth(side);
|
|
209
|
+
const rowWidth = root.value.clientWidth;
|
|
210
|
+
const first = (side === 'start' ? props.startActions : props.endActions)[0];
|
|
211
|
+
const canCommit = props.fullSwipe && first?.fullSwipe && !first.disabled;
|
|
212
|
+
const limit = canCommit ? rowWidth : width + 24;
|
|
213
|
+
offset.value = width ? sign * Math.min(Math.max(0, next * sign), limit) : 0;
|
|
214
|
+
armed.value = resolveSwipeRelease({ distance: Math.abs(offset.value), actionWidth: width, rowWidth, fullSwipe: canCommit }) === 'commit';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Settles the gesture's original side or commits its explicitly enabled shortcut.
|
|
219
|
+
* @param {PointerEvent} event Released pointer and final event timing.
|
|
220
|
+
* @returns {void}
|
|
221
|
+
*/
|
|
222
|
+
function onPointerUp(event) {
|
|
223
|
+
if (!gesture || gesture.id !== event.pointerId) return;
|
|
224
|
+
const previous = gesture;
|
|
225
|
+
if (!previous.active) return cancelGesture();
|
|
226
|
+
suppressClickUntil = Date.now() + 400;
|
|
227
|
+
const side = previous.side;
|
|
228
|
+
const first = (side === 'start' ? props.startActions : props.endActions)[0];
|
|
229
|
+
const outcome = resolveSwipeRelease({
|
|
230
|
+
distance: Math.abs(offset.value),
|
|
231
|
+
velocity: event.timeStamp - previous.time > 100 ? 0 : previous.velocity * Math.sign(offset.value),
|
|
232
|
+
actionWidth: railWidth(side), rowWidth: root.value.clientWidth,
|
|
233
|
+
fullSwipe: props.fullSwipe && first?.fullSwipe && !first.disabled,
|
|
234
|
+
});
|
|
235
|
+
cancelGesture();
|
|
236
|
+
if (outcome === 'commit') selectAction(first, side, 'swipe');
|
|
237
|
+
else if (outcome === 'reveal') reveal(side);
|
|
238
|
+
else close();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Cancels an interrupted gesture without executing full-swipe actions. */
|
|
242
|
+
function onPointerCancel(event) {
|
|
243
|
+
if (!gesture || gesture.id !== event.pointerId) return;
|
|
244
|
+
const side = gesture.initialSide;
|
|
245
|
+
if (gesture.active) suppressClickUntil = Date.now() + 400;
|
|
246
|
+
cancelGesture();
|
|
247
|
+
if (side) reveal(side);
|
|
248
|
+
else close();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Prevents an accidental row activation after a drag or while a rail is open. */
|
|
252
|
+
function onSurfaceClick(event) {
|
|
253
|
+
if (event.target.closest('[data-swipe-toggle]')) return;
|
|
254
|
+
if (Date.now() < suppressClickUntil || openSide.value) {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
event.stopPropagation();
|
|
257
|
+
if (Date.now() >= suppressClickUntil) close();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Closes this row when another row in its group opens. */
|
|
262
|
+
function onSiblingOpen(event) {
|
|
263
|
+
if (event.detail?.group === props.group && event.detail.element !== root.value) close();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Dismisses controls when interacting outside this row. */
|
|
267
|
+
function onOutsidePointer(event) {
|
|
268
|
+
if (!root.value?.contains(event.target)) close();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Chooses semantic action colours for the rail and explicit menu. */
|
|
272
|
+
function actionClass(action) {
|
|
273
|
+
if (action.variant === 'danger') return 'bg-destructive text-destructive-fg';
|
|
274
|
+
if (action.variant === 'success') return 'bg-success text-success-fg';
|
|
275
|
+
return 'bg-primary text-primary-fg';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Installs listeners in the row's document, including adopted iframe previews. */
|
|
279
|
+
function mount() {
|
|
280
|
+
doc = root.value.ownerDocument;
|
|
281
|
+
surface.value.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
282
|
+
surface.value.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
283
|
+
surface.value.addEventListener('touchend', onTouchEnd);
|
|
284
|
+
surface.value.addEventListener('touchcancel', onTouchEnd);
|
|
285
|
+
doc.addEventListener('dom:swipe-open', onSiblingOpen);
|
|
286
|
+
doc.addEventListener('pointerdown', onOutsidePointer, true);
|
|
287
|
+
observer = new doc.defaultView.ResizeObserver(() => { if (openSide.value && !gesture) reveal(openSide.value); });
|
|
288
|
+
observer.observe(root.value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Releases document listeners, capture, and geometry observation. */
|
|
292
|
+
function unmount() {
|
|
293
|
+
cancelGesture();
|
|
294
|
+
surface.value?.removeEventListener('touchstart', onTouchStart);
|
|
295
|
+
surface.value?.removeEventListener('touchmove', onTouchMove);
|
|
296
|
+
surface.value?.removeEventListener('touchend', onTouchEnd);
|
|
297
|
+
surface.value?.removeEventListener('touchcancel', onTouchEnd);
|
|
298
|
+
doc?.removeEventListener('dom:swipe-open', onSiblingOpen);
|
|
299
|
+
doc?.removeEventListener('pointerdown', onOutsidePointer, true);
|
|
300
|
+
observer?.disconnect();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
watch(() => [props.disabled, props.startActions, props.endActions], () => close(), { deep: true });
|
|
304
|
+
onMounted(mount);
|
|
305
|
+
onBeforeUnmount(unmount);
|
|
306
|
+
defineExpose({ reveal, close });
|
|
307
|
+
</script>
|
|
308
|
+
|
|
309
|
+
<template>
|
|
310
|
+
<div ref="root" data-dom-swipe-actions class="relative min-w-0 overflow-hidden bg-canvas" @keydown.esc.stop.prevent="close(true)">
|
|
311
|
+
<div class="relative overflow-hidden" :class="offset && activeActions[0] ? actionClass(activeActions[0]) : 'bg-canvas'">
|
|
312
|
+
<div
|
|
313
|
+
v-for="side in ['start', 'end']" :key="side"
|
|
314
|
+
:ref="side === 'start' ? (el) => startRail = el : (el) => endRail = el"
|
|
315
|
+
class="absolute inset-y-0 flex max-w-[80%]" :class="[side === 'start' ? 'start-0' : 'end-0', activeSide !== side && 'invisible']"
|
|
316
|
+
:inert="disabled || activeSide !== side"
|
|
317
|
+
:aria-hidden="disabled || activeSide !== side"
|
|
318
|
+
>
|
|
319
|
+
<button v-for="(action, index) in side === 'start' ? startActions : endActions" :key="action.value ?? index" type="button"
|
|
320
|
+
class="flex min-h-11 min-w-0 flex-col items-center justify-center gap-1 px-5 text-xs font-semibold transition-[filter] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-current disabled:opacity-40"
|
|
321
|
+
:class="[actionClass(action), armed && 'brightness-110']" :disabled="disabled || action.disabled"
|
|
322
|
+
@click="selectAction(action, side)">
|
|
323
|
+
<slot name="action" :action="action" :side="side">
|
|
324
|
+
<svg v-if="action.icon" class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path :d="action.icon" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
325
|
+
<span>{{ action.label }}</span>
|
|
326
|
+
</slot>
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
<!-- Ignore bubbled capture loss when a touched child transfers implicit capture to this surface. -->
|
|
330
|
+
<div ref="surface" data-swipe-surface class="relative bg-canvas text-canvas-fg transition-transform duration-200 ease-out motion-reduce:transition-none [touch-action:pan-y_pinch-zoom]"
|
|
331
|
+
:class="[actions.length && !disabled && 'pe-11', dragging && 'select-none']"
|
|
332
|
+
:style="{ transform: `translateX(${offset}px)`, transition: dragging ? 'none' : undefined }"
|
|
333
|
+
@pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerCancel" @lostpointercapture.self="onPointerCancel" @click.capture="onSurfaceClick" @dragstart.prevent>
|
|
334
|
+
<slot />
|
|
335
|
+
<button v-if="actions.length && !disabled" ref="menuButton" data-swipe-toggle type="button" class="absolute end-0 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-lg text-muted-fg hover:bg-secondary focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring"
|
|
336
|
+
:aria-label="`Actions for ${label}`" :aria-expanded="menuOpen" :aria-controls="menuId" @click="toggleMenu">
|
|
337
|
+
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" /></svg>
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
<div v-if="menuOpen" :id="menuId" data-swipe-menu class="flex flex-wrap gap-2 border-t border-border bg-secondary/40 p-3" role="group" :aria-label="`Actions for ${label}`">
|
|
342
|
+
<button v-for="({ action, side }, index) in actions" :key="index" type="button" class="min-h-11 rounded-lg px-4 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-40" :class="actionClass(action)" :disabled="action.disabled" @click="selectAction(action, side)">{{ action.label }}</button>
|
|
343
|
+
<button type="button" class="min-h-11 rounded-lg px-4 text-sm text-muted-fg hover:bg-secondary focus-visible:outline-2 focus-visible:outline-ring" @click="close(true)">Cancel</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</template>
|
|
@@ -44,10 +44,10 @@ defineProps({
|
|
|
44
44
|
|
|
45
45
|
<template>
|
|
46
46
|
<header
|
|
47
|
-
class="
|
|
48
|
-
:class="[
|
|
47
|
+
class="shrink-0 bg-canvas/95 pl-[max(1rem,var(--dom-safe-area-left,env(safe-area-inset-left,0px)))] pr-[max(1rem,var(--dom-safe-area-right,env(safe-area-inset-right,0px)))] pt-[var(--dom-safe-area-top,env(safe-area-inset-top,0px))] text-canvas-fg"
|
|
48
|
+
:class="[border && 'border-b border-border', large ? 'pb-4' : 'pb-1']"
|
|
49
49
|
>
|
|
50
|
-
<div class="flex min-h-11 items-center gap-3">
|
|
50
|
+
<div class="flex min-h-11 items-center gap-3" :class="large && 'pt-3'">
|
|
51
51
|
<div v-if="$slots.leading" class="flex shrink-0 items-center">
|
|
52
52
|
<slot name="leading" />
|
|
53
53
|
</div>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
defineOptions({
|
|
5
|
+
__doc: {
|
|
6
|
+
nav: { section: 'ai' },
|
|
7
|
+
name: 'Audio visualizer',
|
|
8
|
+
tag: '<DomAudioVisualizer>',
|
|
9
|
+
description: 'A live waveform of input levels from a MediaStream. Shows real audio activity without recording, playing, or transmitting the stream.',
|
|
10
|
+
icon: 'M4 10v4M8 6v12M12 3v18M16 7v10M20 10v4',
|
|
11
|
+
events: [
|
|
12
|
+
{ name: 'level', payload: '(level: number)', description: 'Measured RMS amplitude from 0 to 1, emitted at most ten times per second. This is input activity, not speech detection.' },
|
|
13
|
+
{ name: 'error', payload: '({ name, message })', description: 'Web Audio could not analyse the stream. Capture remains owned by the caller.' },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
stream: { type: Object, default: null, _edit: { description: 'Application-owned MediaStream. The visualizer never stops its tracks.' } },
|
|
19
|
+
muted: { type: Boolean, default: false, _edit: { description: 'Show a flat baseline while the input is muted.' } },
|
|
20
|
+
bars: { type: Number, default: 25, _edit: { description: 'Number of rounded bars, clamped to 8–64.' } },
|
|
21
|
+
label: { type: String, default: 'Microphone input level', _edit: { description: 'Accessible name for the live input meter.' } },
|
|
22
|
+
});
|
|
23
|
+
const emit = defineEmits(['level', 'error']);
|
|
24
|
+
const levels = ref([]);
|
|
25
|
+
const level = ref(0);
|
|
26
|
+
const unavailable = ref(false);
|
|
27
|
+
let mounted = false;
|
|
28
|
+
let context;
|
|
29
|
+
let source;
|
|
30
|
+
let analyser;
|
|
31
|
+
let samples;
|
|
32
|
+
let frame = 0;
|
|
33
|
+
let lastSample = 0;
|
|
34
|
+
let lastEmit = 0;
|
|
35
|
+
let motionQuery;
|
|
36
|
+
let cleanupTracks = () => {};
|
|
37
|
+
/** Bounds SVG complexity even when a consumer supplies an invalid bar count. */
|
|
38
|
+
const count = computed(() => Number.isFinite(props.bars) ? Math.max(8, Math.min(64, Math.round(props.bars))) : 25);
|
|
39
|
+
/** Keeps the resting waveform visible while reserving height for real input. */
|
|
40
|
+
const heights = computed(() => Array.from({ length: count.value }, barHeight));
|
|
41
|
+
/** Maps sampled RMS to a readable display height without changing the source. */
|
|
42
|
+
function barHeight(_, index) { return 3 + Math.min(1, Math.sqrt(levels.value[index] || 0) * 2.5) * 37; }
|
|
43
|
+
/** Clears stale input activity whenever capture pauses or the source changes. */
|
|
44
|
+
function reset() { levels.value = Array(count.value).fill(0); level.value = 0; emit('level', 0); }
|
|
45
|
+
/** Closes only analysis resources; stream ownership stays with the caller. */
|
|
46
|
+
function cleanup() {
|
|
47
|
+
cancelAnimationFrame(frame);
|
|
48
|
+
cleanupTracks();
|
|
49
|
+
cleanupTracks = () => {};
|
|
50
|
+
source?.disconnect();
|
|
51
|
+
analyser?.disconnect();
|
|
52
|
+
if (context && context.state !== 'closed') context.close().catch(ignoreClosedContext);
|
|
53
|
+
context = source = analyser = samples = null;
|
|
54
|
+
reset();
|
|
55
|
+
}
|
|
56
|
+
/** Ignores a context that the browser has already closed during navigation. */
|
|
57
|
+
function ignoreClosedContext() {}
|
|
58
|
+
/** Emits a stable failure without interrupting an application-owned media stream. */
|
|
59
|
+
function failed(cause) {
|
|
60
|
+
unavailable.value = true;
|
|
61
|
+
emit('error', { name: cause?.name || 'Error', message: 'Audio visualisation is unavailable in this browser.' });
|
|
62
|
+
}
|
|
63
|
+
/** Builds a silent analysis graph for the new stream after browser mount. */
|
|
64
|
+
function connect() {
|
|
65
|
+
if (!mounted) return;
|
|
66
|
+
cleanup();
|
|
67
|
+
unavailable.value = false;
|
|
68
|
+
const tracks = props.stream?.getAudioTracks() || [];
|
|
69
|
+
if (!tracks.some(isLive)) return;
|
|
70
|
+
try {
|
|
71
|
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
72
|
+
context = new AudioContext();
|
|
73
|
+
analyser = context.createAnalyser();
|
|
74
|
+
analyser.fftSize = 1024;
|
|
75
|
+
samples = new Float32Array(analyser.fftSize);
|
|
76
|
+
source = context.createMediaStreamSource(props.stream);
|
|
77
|
+
source.connect(analyser);
|
|
78
|
+
// AnalyserNode works without a speaker destination: no microphone feedback.
|
|
79
|
+
for (const track of tracks) track.addEventListener('ended', connect);
|
|
80
|
+
/** Removes source lifecycle listeners when the analyser is replaced. */
|
|
81
|
+
cleanupTracks = () => { for (const track of tracks) track.removeEventListener('ended', connect); };
|
|
82
|
+
resume();
|
|
83
|
+
frame = requestAnimationFrame(sample);
|
|
84
|
+
} catch (cause) { cleanup(); failed(cause); }
|
|
85
|
+
}
|
|
86
|
+
/** Tests source liveness without relying on a browser-specific MediaStream class. */
|
|
87
|
+
function isLive(track) { return track.readyState === 'live'; }
|
|
88
|
+
/** Resumes Web Audio from the next explicit interaction if autoplay suspended it. */
|
|
89
|
+
function resume() {
|
|
90
|
+
const current = context;
|
|
91
|
+
if (current?.state !== 'suspended') return;
|
|
92
|
+
/** Ignores a suspended context replaced or disposed while resume was pending. */
|
|
93
|
+
current.resume().catch((cause) => { if (mounted && current === context) failed(cause); });
|
|
94
|
+
}
|
|
95
|
+
/** Samples actual input energy; reduced motion uses a slower, non-animated meter. */
|
|
96
|
+
function sample(time) {
|
|
97
|
+
if (!analyser) return;
|
|
98
|
+
frame = requestAnimationFrame(sample);
|
|
99
|
+
const cadence = motionQuery?.matches ? 250 : 40;
|
|
100
|
+
if (time - lastSample < cadence) return;
|
|
101
|
+
lastSample = time;
|
|
102
|
+
const active = !props.muted && !document.hidden && props.stream?.getAudioTracks().some(isEnabled);
|
|
103
|
+
let rms = 0;
|
|
104
|
+
if (active) {
|
|
105
|
+
analyser.getFloatTimeDomainData(samples);
|
|
106
|
+
let sum = 0;
|
|
107
|
+
for (const value of samples) sum += value * value;
|
|
108
|
+
rms = Math.min(1, Math.sqrt(sum / samples.length));
|
|
109
|
+
}
|
|
110
|
+
level.value = rms;
|
|
111
|
+
levels.value = active ? [...levels.value.slice(-(count.value - 1)), rms] : Array(count.value).fill(0);
|
|
112
|
+
if (time - lastEmit >= 100) { lastEmit = time; emit('level', rms); }
|
|
113
|
+
}
|
|
114
|
+
/** Excludes disabled, interrupted and ended sources from the activity display. */
|
|
115
|
+
function isEnabled(track) { return isLive(track) && track.enabled && !track.muted; }
|
|
116
|
+
/** Connects only in the browser so SSR never requests media or creates audio nodes. */
|
|
117
|
+
onMounted(() => {
|
|
118
|
+
mounted = true;
|
|
119
|
+
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
120
|
+
document.addEventListener('pointerdown', resume);
|
|
121
|
+
document.addEventListener('keydown', resume);
|
|
122
|
+
connect();
|
|
123
|
+
});
|
|
124
|
+
/** Rebuilds analysis when a parent replaces its stream. */
|
|
125
|
+
watch(() => props.stream, connect);
|
|
126
|
+
/** Removes old waveform history immediately when muted or resized. */
|
|
127
|
+
watch([() => props.muted, count], reset);
|
|
128
|
+
/** Releases the audio context and event/animation work without stopping input tracks. */
|
|
129
|
+
onBeforeUnmount(() => {
|
|
130
|
+
mounted = false;
|
|
131
|
+
cleanup();
|
|
132
|
+
document.removeEventListener('pointerdown', resume);
|
|
133
|
+
document.removeEventListener('keydown', resume);
|
|
134
|
+
});
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<template>
|
|
138
|
+
<div role="meter" :aria-label="unavailable ? 'Audio visualisation unavailable' : label" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="Math.round(level * 100)" :aria-valuetext="unavailable ? 'Unavailable' : muted ? 'Muted' : !stream ? 'Input inactive' : level > 0.005 ? 'Audio received' : 'Quiet'" class="h-12 min-w-0 text-current" :data-audio-level="level.toFixed(4)">
|
|
139
|
+
<svg class="h-full w-full" viewBox="0 0 200 48" preserveAspectRatio="none" aria-hidden="true">
|
|
140
|
+
<line v-for="(height, index) in heights" :key="index" :x1="4 + index * 192 / (count - 1)" :x2="4 + index * 192 / (count - 1)" :y1="24 - height / 2" :y2="24 + height / 2" stroke="currentColor" :stroke-width="Math.min(3.5, 110 / count)" stroke-linecap="round" />
|
|
141
|
+
</svg>
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|