@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,95 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, inject, onMounted, ref, useId, watch } from 'vue';
|
|
3
|
+
import DomColorInput from '../../forms/color-input/DomColorInput.vue';
|
|
4
|
+
import { cssColorToHex } from '../../lib/css/color.js';
|
|
5
|
+
import { cssEditorContextKey, resolveEditorColor } from '../_shared/cssEditorContext.js';
|
|
6
|
+
|
|
7
|
+
defineOptions({ __doc: { name: 'Color swatch', description: 'Beta CSS colour swatch with an editable expression and native picker.' } });
|
|
8
|
+
|
|
9
|
+
const props = defineProps({
|
|
10
|
+
modelValue: { type: String, default: '#000000' },
|
|
11
|
+
label: { type: String, default: 'Colour' },
|
|
12
|
+
});
|
|
13
|
+
const emit = defineEmits(['update:modelValue']);
|
|
14
|
+
const context = inject(cssEditorContextKey, null);
|
|
15
|
+
const id = useId();
|
|
16
|
+
const mounted = ref(false);
|
|
17
|
+
const resolvedColor = computed(() => resolveEditorColor(props.modelValue, context?.colorVariables.value));
|
|
18
|
+
const nativeValue = computed(() => {
|
|
19
|
+
// Browser-dependent colour functions can only be resolved after hydration.
|
|
20
|
+
if (!mounted.value && !/^#|^oklch\(|^rgba?\(/i.test(resolvedColor.value)) return '#000000';
|
|
21
|
+
return cssColorToHex(resolvedColor.value);
|
|
22
|
+
});
|
|
23
|
+
const draft = ref(props.modelValue);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keeps the editable expression aligned when the selected skin or palette changes.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} value Newly selected CSS colour value.
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
function syncDraft(value) {
|
|
32
|
+
draft.value = value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Commits a valid CSS colour expression or restores the previous value.
|
|
37
|
+
*
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
function commitDraft() {
|
|
41
|
+
const value = draft.value.trim();
|
|
42
|
+
const resolved = resolveEditorColor(value, context?.colorVariables.value);
|
|
43
|
+
if (value && value.length <= 160 && !/[;{}@]|\burl\s*\(/i.test(value) && CSS.supports('color', resolved)) {
|
|
44
|
+
emit('update:modelValue', value);
|
|
45
|
+
} else {
|
|
46
|
+
draft.value = props.modelValue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Enables browser colour resolution after the initial server/client render. */
|
|
51
|
+
function enableBrowserColors() {
|
|
52
|
+
mounted.value = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
watch(() => props.modelValue, syncDraft);
|
|
56
|
+
onMounted(enableBrowserColors);
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div v-if="!context?.framed.value" class="min-w-0 space-y-1.5">
|
|
61
|
+
<label :for="id" class="block text-[10px] font-medium uppercase tracking-wide text-muted-fg">{{ label }}</label>
|
|
62
|
+
<div
|
|
63
|
+
class="flex h-9 min-w-0 items-center gap-2 rounded-md border border-input bg-canvas px-2 focus-within:ring-1 focus-within:ring-ring"
|
|
64
|
+
>
|
|
65
|
+
<label
|
|
66
|
+
class="relative size-5 shrink-0 overflow-hidden rounded-sm border border-border"
|
|
67
|
+
:style="{ backgroundColor: resolvedColor }"
|
|
68
|
+
>
|
|
69
|
+
<input
|
|
70
|
+
type="color"
|
|
71
|
+
:value="nativeValue"
|
|
72
|
+
:aria-label="`${label} colour`"
|
|
73
|
+
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
74
|
+
@input="emit('update:modelValue', $event.target.value)"
|
|
75
|
+
/>
|
|
76
|
+
</label>
|
|
77
|
+
<input
|
|
78
|
+
:id="id"
|
|
79
|
+
v-model="draft"
|
|
80
|
+
:aria-label="`${label} CSS colour`"
|
|
81
|
+
:title="modelValue"
|
|
82
|
+
spellcheck="false"
|
|
83
|
+
class="min-w-0 w-full bg-transparent font-mono text-[11px] outline-none"
|
|
84
|
+
@blur="commitDraft"
|
|
85
|
+
@keydown.enter.prevent="commitDraft"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<DomColorInput
|
|
90
|
+
v-else
|
|
91
|
+
:model-value="modelValue"
|
|
92
|
+
:label="label"
|
|
93
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
94
|
+
/>
|
|
95
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, useAttrs } from 'vue';
|
|
3
|
+
import { createFrameLayout, frameLayoutStyle } from './frameLayout.js';
|
|
4
|
+
|
|
5
|
+
defineOptions({ inheritAttrs: false, __doc: { name: 'Designer frame', description: 'An ordinary container with independent flow, flex, grid or absolute child layout.', slots: [{ name: 'default', description: 'Frame content. Applications own child placement.' }] } });
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
as: { type: String, default: 'div' },
|
|
8
|
+
layout: { type: Object, default: () => createFrameLayout() },
|
|
9
|
+
});
|
|
10
|
+
const attrs = useAttrs();
|
|
11
|
+
const style = computed(() => frameLayoutStyle(props.layout));
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<component :is="as" v-bind="attrs" :style="[style, attrs.style]"><slot /></component>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import DomNativeSelect from '../../forms/native-select/DomNativeSelect.vue';
|
|
3
|
+
import DomNumberInput from '../../forms/number-input/DomNumberInput.vue';
|
|
4
|
+
import DomTextInput from '../../forms/text-input/DomTextInput.vue';
|
|
5
|
+
import { createFrameLayout } from './frameLayout.js';
|
|
6
|
+
|
|
7
|
+
defineOptions({ __doc: { name: 'Frame layout editor', description: 'Controlled layout settings for flow, flex, grid and absolute frames.' } });
|
|
8
|
+
const props = defineProps({ modelValue: { type: Object, default: () => createFrameLayout() } });
|
|
9
|
+
const emit = defineEmits(['update:modelValue']);
|
|
10
|
+
const modes = [{ label: 'Flow', value: 'flow' }, { label: 'Flex', value: 'flex' }, { label: 'Grid', value: 'grid' }, { label: 'Absolute', value: 'absolute' }];
|
|
11
|
+
const directions = ['row', 'column', 'row-reverse', 'column-reverse'];
|
|
12
|
+
const justification = ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'];
|
|
13
|
+
const alignment = ['start', 'center', 'end', 'stretch', 'baseline'];
|
|
14
|
+
/** Emits a fresh record; the host captures geometry before committing a mode change. */
|
|
15
|
+
function update(key, value) {
|
|
16
|
+
emit('update:modelValue', createFrameLayout({ ...props.modelValue, [key]: value }));
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="space-y-4">
|
|
22
|
+
<DomNativeSelect label="Child layout" :model-value="modelValue.mode" :options="modes" @update:model-value="update('mode', $event)" />
|
|
23
|
+
<DomNumberInput label="Padding" :model-value="modelValue.padding" :min="0" @update:model-value="update('padding', $event)" />
|
|
24
|
+
<template v-if="modelValue.mode === 'flex' || modelValue.mode === 'grid'">
|
|
25
|
+
<DomNumberInput label="Gap" :model-value="modelValue.gap" :min="0" @update:model-value="update('gap', $event)" />
|
|
26
|
+
<template v-if="modelValue.mode === 'flex'">
|
|
27
|
+
<DomNativeSelect label="Direction" :model-value="modelValue.direction" :options="directions" @update:model-value="update('direction', $event)" />
|
|
28
|
+
<DomNativeSelect label="Wrap" :model-value="modelValue.wrap" :options="['nowrap', 'wrap']" @update:model-value="update('wrap', $event)" />
|
|
29
|
+
</template>
|
|
30
|
+
<template v-else>
|
|
31
|
+
<DomTextInput label="Columns" :model-value="modelValue.columns" @update:model-value="update('columns', $event)" />
|
|
32
|
+
<DomTextInput label="Rows" :model-value="modelValue.rows" @update:model-value="update('rows', $event)" />
|
|
33
|
+
<DomNativeSelect label="Auto flow" :model-value="modelValue.autoFlow" :options="['row', 'column', 'row dense', 'column dense']" @update:model-value="update('autoFlow', $event)" />
|
|
34
|
+
<DomNativeSelect label="Justify items" :model-value="modelValue.justifyItems" :options="['start', 'center', 'end', 'stretch']" @update:model-value="update('justifyItems', $event)" />
|
|
35
|
+
</template>
|
|
36
|
+
<DomNativeSelect label="Justify content" :model-value="modelValue.justify" :options="justification" @update:model-value="update('justify', $event)" />
|
|
37
|
+
<DomNativeSelect label="Align items" :model-value="modelValue.align" :options="alignment" @update:model-value="update('align', $event)" />
|
|
38
|
+
</template>
|
|
39
|
+
<p class="text-xs leading-5 text-muted-fg">{{ modelValue.mode === 'absolute' ? 'Children move freely inside this frame. Alignment and spacing use this frame and its siblings.' : modelValue.mode === 'flow' ? 'Children follow normal document flow. Their margins and widths control spacing.' : 'The browser arranges children. Use gap and alignment here; reorder items in the inspector.' }}</p>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export const frameModes = ['flow', 'flex', 'grid', 'absolute'];
|
|
2
|
+
|
|
3
|
+
/** Creates independent layout settings; positioning in a parent is deliberately separate. */
|
|
4
|
+
export function createFrameLayout(value = {}) {
|
|
5
|
+
return {
|
|
6
|
+
mode: frameModes.includes(value.mode) ? value.mode : 'flow',
|
|
7
|
+
direction: ['row', 'column', 'row-reverse', 'column-reverse'].includes(value.direction) ? value.direction : 'row',
|
|
8
|
+
wrap: value.wrap === 'wrap' ? 'wrap' : 'nowrap',
|
|
9
|
+
gap: Number.isFinite(Number(value.gap)) ? Math.max(0, Number(value.gap)) : 0,
|
|
10
|
+
padding: Number.isFinite(Number(value.padding)) ? Math.max(0, Number(value.padding)) : 0,
|
|
11
|
+
justify: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'].includes(value.justify) ? value.justify : 'start',
|
|
12
|
+
align: ['start', 'center', 'end', 'stretch', 'baseline'].includes(value.align) ? value.align : 'stretch',
|
|
13
|
+
justifyItems: ['start', 'center', 'end', 'stretch'].includes(value.justifyItems) ? value.justifyItems : 'stretch',
|
|
14
|
+
autoFlow: ['row', 'column', 'row dense', 'column dense'].includes(value.autoFlow) ? value.autoFlow : 'row',
|
|
15
|
+
columns: String(value.columns || 'repeat(3, minmax(0, 1fr))'),
|
|
16
|
+
rows: String(value.rows || 'auto'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Projects child layout into CSS without choosing the frame's placement in its parent. */
|
|
21
|
+
export function frameLayoutStyle(value = {}) {
|
|
22
|
+
const layout = createFrameLayout(value);
|
|
23
|
+
return {
|
|
24
|
+
position: 'relative',
|
|
25
|
+
boxSizing: 'border-box',
|
|
26
|
+
display: layout.mode === 'flex' || layout.mode === 'grid' ? layout.mode : 'block',
|
|
27
|
+
padding: `${layout.padding}px`,
|
|
28
|
+
gap: `${layout.gap}px`,
|
|
29
|
+
flexDirection: layout.direction,
|
|
30
|
+
flexWrap: layout.wrap,
|
|
31
|
+
justifyContent: layout.justify,
|
|
32
|
+
alignItems: layout.align,
|
|
33
|
+
justifyItems: layout.justifyItems,
|
|
34
|
+
gridAutoFlow: layout.autoFlow,
|
|
35
|
+
gridTemplateColumns: layout.mode === 'grid' ? layout.columns : 'none',
|
|
36
|
+
gridTemplateRows: layout.mode === 'grid' ? layout.rows : 'none',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reads the browser's resolved layout, including responsive Tailwind and inline styles. */
|
|
41
|
+
export function readFrameLayout(element) {
|
|
42
|
+
const css = element.ownerDocument.defaultView.getComputedStyle(element);
|
|
43
|
+
const children = [...element.children].filter((child) => child.hasAttribute('data-designer-id'));
|
|
44
|
+
const absolute = children.length && children.every((child) => element.ownerDocument.defaultView.getComputedStyle(child).position === 'absolute');
|
|
45
|
+
return createFrameLayout({
|
|
46
|
+
mode: css.display.includes('flex') ? 'flex' : css.display.includes('grid') ? 'grid' : absolute ? 'absolute' : 'flow',
|
|
47
|
+
direction: css.flexDirection, wrap: css.flexWrap,
|
|
48
|
+
gap: parseFloat(css.gap), padding: parseFloat(css.paddingTop),
|
|
49
|
+
justify: css.justifyContent.replace('flex-', ''), align: css.alignItems.replace('flex-', ''),
|
|
50
|
+
justifyItems: css.justifyItems, autoFlow: css.gridAutoFlow,
|
|
51
|
+
columns: css.gridTemplateColumns === 'none' ? undefined : css.gridTemplateColumns,
|
|
52
|
+
rows: css.gridTemplateRows === 'none' ? undefined : css.gridTemplateRows,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Measures a child in its containing frame's CSS pixels, accounting for borders and scrolling. */
|
|
57
|
+
export function measureFrameChild(element, frame) {
|
|
58
|
+
const rect = element.getBoundingClientRect();
|
|
59
|
+
const parent = frame.getBoundingClientRect();
|
|
60
|
+
const scale = parent.width / frame.offsetWidth || 1;
|
|
61
|
+
return {
|
|
62
|
+
left: (rect.left - parent.left) / scale - frame.clientLeft + frame.scrollLeft,
|
|
63
|
+
top: (rect.top - parent.top) / scale - frame.clientTop + frame.scrollTop,
|
|
64
|
+
width: rect.width / scale,
|
|
65
|
+
height: rect.height / scale,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Aligns a child against its frame's padded inner bounds. */
|
|
70
|
+
export function alignFrameChild(rect, bounds, alignment, padding = 0) {
|
|
71
|
+
const next = { ...rect };
|
|
72
|
+
if (alignment === 'left') next.left = padding;
|
|
73
|
+
if (alignment === 'center') next.left = (bounds.width - rect.width) / 2;
|
|
74
|
+
if (alignment === 'right') next.left = bounds.width - padding - rect.width;
|
|
75
|
+
if (alignment === 'top') next.top = padding;
|
|
76
|
+
if (alignment === 'middle') next.top = (bounds.height - rect.height) / 2;
|
|
77
|
+
if (alignment === 'bottom') next.top = bounds.height - padding - rect.height;
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Places children in their existing visual order with an explicit horizontal or vertical gap. */
|
|
82
|
+
export function spaceFrameChildren(rects, axis, gap) {
|
|
83
|
+
const key = axis === 'x' ? 'left' : 'top';
|
|
84
|
+
const size = axis === 'x' ? 'width' : 'height';
|
|
85
|
+
const ordered = rects.map((rect, index) => ({ ...rect, index })).sort((a, b) => a[key] - b[key]);
|
|
86
|
+
let cursor = ordered[0]?.[key] || 0;
|
|
87
|
+
const result = rects.map((rect) => ({ ...rect }));
|
|
88
|
+
for (const rect of ordered) {
|
|
89
|
+
result[rect.index][key] = cursor;
|
|
90
|
+
cursor += rect[size] + Math.max(0, Number(gap) || 0);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import DomTreeView from '../../components/tree-view/DomTreeView.vue';
|
|
3
|
+
|
|
4
|
+
defineOptions({ __doc: { name: 'Designer layers', description: 'Beta layer tree shared with Studio. Accepts the full DomTreeView props, slots and events.' } });
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<DomTreeView label="Layers" :chrome="false">
|
|
9
|
+
<template v-for="(_, name) in $slots" #[name]="scope"><slot :name="name" v-bind="scope" /></template>
|
|
10
|
+
</DomTreeView>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, watch } from 'vue';
|
|
3
|
+
import ShadowLayers from './ShadowLayers.vue';
|
|
4
|
+
import { shadowListToCss } from '../shadow-editor/shadowModel.js';
|
|
5
|
+
|
|
6
|
+
defineOptions({ __doc: { name: 'Shadow editor', description: 'Beta controlled CSS box-shadow layers: drop, inner and ring.' } });
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
modelValue: { type: Array, default: () => [] },
|
|
9
|
+
title: { type: String, default: 'Box shadows' },
|
|
10
|
+
});
|
|
11
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
12
|
+
const draft = ref([]);
|
|
13
|
+
const open = ref(true);
|
|
14
|
+
|
|
15
|
+
/** Copies caller-owned layers so editing never mutates a prop or its children. */
|
|
16
|
+
function syncLayers(value) {
|
|
17
|
+
if (JSON.stringify(value) !== JSON.stringify(draft.value)) draft.value = value.map((layer) => ({ ...layer }));
|
|
18
|
+
}
|
|
19
|
+
/** Publishes independent layer records and their ready-to-use CSS value. */
|
|
20
|
+
function publishLayers(value) {
|
|
21
|
+
if (JSON.stringify(value) === JSON.stringify(props.modelValue)) return;
|
|
22
|
+
const layers = value.map((layer) => ({ ...layer }));
|
|
23
|
+
emit('update:modelValue', layers);
|
|
24
|
+
emit('change', { layers, css: shadowListToCss(layers) });
|
|
25
|
+
}
|
|
26
|
+
watch(() => props.modelValue, syncLayers, { deep: true, immediate: true });
|
|
27
|
+
watch(draft, publishLayers, { deep: true });
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<ShadowLayers v-model="open" :shadows="draft" :title="title" />
|
|
32
|
+
</template>
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
import CssColorInput from '../color-swatch/DomColorSwatch.vue';
|
|
4
|
+
import {
|
|
5
|
+
DomNativeSelect,
|
|
6
|
+
DomPopover,
|
|
7
|
+
DomRangeInput,
|
|
8
|
+
} from '../../lib/vue';
|
|
9
|
+
import ControlIconButton from '../_shared/ControlIconButton.vue';
|
|
10
|
+
import StyleControlSection from '../_shared/StyleControlSection.vue';
|
|
11
|
+
import {
|
|
12
|
+
addShadowLayer,
|
|
13
|
+
duplicateShadowLayer,
|
|
14
|
+
enabledShadows,
|
|
15
|
+
removeShadowLayer,
|
|
16
|
+
shadowKindOptions,
|
|
17
|
+
shadowTitle,
|
|
18
|
+
} from '../shadow-editor/shadowModel.js';
|
|
19
|
+
|
|
20
|
+
const props = defineProps({
|
|
21
|
+
shadows: {
|
|
22
|
+
type: Array,
|
|
23
|
+
required: true,
|
|
24
|
+
},
|
|
25
|
+
modelValue: {
|
|
26
|
+
type: Boolean,
|
|
27
|
+
default: true,
|
|
28
|
+
},
|
|
29
|
+
title: {
|
|
30
|
+
type: String,
|
|
31
|
+
default: 'Box shadows',
|
|
32
|
+
},
|
|
33
|
+
variables: {
|
|
34
|
+
type: Object,
|
|
35
|
+
default: () => ({}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const section = ref(null);
|
|
40
|
+
const emit = defineEmits(['update:modelValue']);
|
|
41
|
+
|
|
42
|
+
/** Adds a default layer to the editor-owned draft. */
|
|
43
|
+
function addShadow() {
|
|
44
|
+
emit('update:modelValue', true);
|
|
45
|
+
addShadowLayer(props.shadows);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Duplicates a draft layer with a fresh identity. */
|
|
49
|
+
function duplicateShadow(index) {
|
|
50
|
+
duplicateShadowLayer(props.shadows, index);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Removes a layer from the editor-owned draft. */
|
|
54
|
+
function removeShadow(index) {
|
|
55
|
+
removeShadowLayer(props.shadows, index);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Closes other layer popovers when opening the active layer. */
|
|
59
|
+
function closeOtherLayerPopovers(activeId) {
|
|
60
|
+
if (typeof document === 'undefined') return;
|
|
61
|
+
globalThis.requestAnimationFrame?.(() => {
|
|
62
|
+
section.value?.$el?.querySelectorAll('dom-popover[data-layer-popover-id]').forEach((popover) => {
|
|
63
|
+
if (popover.dataset.layerPopoverId === activeId) return;
|
|
64
|
+
closeLayerPopoverElement(popover);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Closes open layer controls before switching the active layer. */
|
|
70
|
+
function closeAllLayerPopovers() {
|
|
71
|
+
if (typeof document === 'undefined') return;
|
|
72
|
+
section.value?.$el?.querySelectorAll('dom-popover[data-layer-popover-id]').forEach(closeLayerPopoverElement);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Closes a native popover and synchronizes its trigger state. */
|
|
76
|
+
function closeLayerPopoverElement(popover) {
|
|
77
|
+
popover.open = false;
|
|
78
|
+
popover.removeAttribute('open');
|
|
79
|
+
popover.querySelector('.dom-popover-panel')?.hidePopover?.();
|
|
80
|
+
popover.querySelector('[aria-expanded="true"]')?.setAttribute('aria-expanded', 'false');
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<StyleControlSection
|
|
86
|
+
ref="section"
|
|
87
|
+
:model-value="modelValue"
|
|
88
|
+
:title="title"
|
|
89
|
+
:meta="`${enabledShadows(shadows).length} of ${shadows.length} visible`"
|
|
90
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
91
|
+
>
|
|
92
|
+
<template #actions>
|
|
93
|
+
<ControlIconButton icon="plus" label="Add shadow layer" @click.stop="addShadow" />
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<div class="divide-y divide-border">
|
|
97
|
+
<p v-if="variables.shadow" class="px-3 py-2 font-mono text-[11px] text-muted-fg">{{ variables.shadow }}</p>
|
|
98
|
+
<article
|
|
99
|
+
v-for="(shadow, index) in shadows"
|
|
100
|
+
:key="shadow.id"
|
|
101
|
+
class="px-3 py-2"
|
|
102
|
+
:class="!shadow.enabled && 'opacity-60'"
|
|
103
|
+
>
|
|
104
|
+
<div class="grid grid-cols-[1fr_auto] items-center gap-2">
|
|
105
|
+
<DomPopover
|
|
106
|
+
:data-layer-popover-id="shadow.id"
|
|
107
|
+
position="start-bottom"
|
|
108
|
+
width="w-[22rem] max-w-[calc(100vw-2rem)]"
|
|
109
|
+
padding="p-0"
|
|
110
|
+
:collision-padding="12"
|
|
111
|
+
@open="closeOtherLayerPopovers(shadow.id)"
|
|
112
|
+
>
|
|
113
|
+
<template #trigger>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="w-full min-w-0 rounded-md text-left transition hover:bg-secondary/60"
|
|
117
|
+
@pointerdown.capture="closeAllLayerPopovers"
|
|
118
|
+
>
|
|
119
|
+
<span class="flex min-w-0 items-center gap-2">
|
|
120
|
+
<span class="font-mono text-[11px] text-muted-fg">{{ index + 1 }}</span>
|
|
121
|
+
<span class="truncate text-sm font-medium">{{ shadowTitle(shadow) }}</span>
|
|
122
|
+
</span>
|
|
123
|
+
</button>
|
|
124
|
+
</template>
|
|
125
|
+
<div>
|
|
126
|
+
<div class="border-b border-border px-3 py-2">
|
|
127
|
+
<p class="truncate text-sm font-semibold">{{ shadowTitle(shadow) }}</p>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="grid gap-3 p-3 sm:grid-cols-2">
|
|
130
|
+
<DomNativeSelect
|
|
131
|
+
v-model="shadow.kind"
|
|
132
|
+
label="Type"
|
|
133
|
+
:options="shadowKindOptions"
|
|
134
|
+
placeholder=""
|
|
135
|
+
/>
|
|
136
|
+
<CssColorInput v-model="shadow.color" label="Colour" />
|
|
137
|
+
<DomRangeInput v-model="shadow.alpha" label="Alpha" :min="0" :max="100" suffix="%" />
|
|
138
|
+
<DomRangeInput
|
|
139
|
+
v-model="shadow.x"
|
|
140
|
+
label="Horizontal"
|
|
141
|
+
:min="-80"
|
|
142
|
+
:max="80"
|
|
143
|
+
suffix="px"
|
|
144
|
+
:disabled="shadow.kind === 'ring'"
|
|
145
|
+
/>
|
|
146
|
+
<DomRangeInput
|
|
147
|
+
v-model="shadow.y"
|
|
148
|
+
label="Vertical"
|
|
149
|
+
:min="-80"
|
|
150
|
+
:max="80"
|
|
151
|
+
suffix="px"
|
|
152
|
+
:disabled="shadow.kind === 'ring'"
|
|
153
|
+
/>
|
|
154
|
+
<DomRangeInput
|
|
155
|
+
v-model="shadow.blur"
|
|
156
|
+
label="Blur"
|
|
157
|
+
:min="0"
|
|
158
|
+
:max="120"
|
|
159
|
+
suffix="px"
|
|
160
|
+
:disabled="shadow.kind === 'ring'"
|
|
161
|
+
/>
|
|
162
|
+
<DomRangeInput
|
|
163
|
+
v-model="shadow.spread"
|
|
164
|
+
label="Spread"
|
|
165
|
+
:min="-80"
|
|
166
|
+
:max="80"
|
|
167
|
+
suffix="px"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</DomPopover>
|
|
172
|
+
<div class="flex items-center gap-1">
|
|
173
|
+
<ControlIconButton icon="duplicate" label="Duplicate shadow" @click="duplicateShadow(index)" />
|
|
174
|
+
<ControlIconButton
|
|
175
|
+
:icon="shadow.enabled ? 'eye' : 'eye-off'"
|
|
176
|
+
:label="shadow.enabled ? 'Hide shadow' : 'Show shadow'"
|
|
177
|
+
@click="shadow.enabled = !shadow.enabled"
|
|
178
|
+
/>
|
|
179
|
+
<ControlIconButton icon="remove" label="Remove shadow" @click="removeShadow(index)" />
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</article>
|
|
183
|
+
</div>
|
|
184
|
+
</StyleControlSection>
|
|
185
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cssColorWithAlpha } from '../../lib/css/color.js';
|
|
2
|
+
|
|
3
|
+
export const shadowKindOptions = [
|
|
4
|
+
{ label: 'Drop shadow', value: 'drop' },
|
|
5
|
+
{ label: 'Inner shadow', value: 'inset' },
|
|
6
|
+
{ label: 'Ring', value: 'ring' },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
/** Creates a fresh editable shadow layer. Supply deterministic IDs after construction during SSR. */
|
|
10
|
+
export function createShadow(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
enabled: true,
|
|
13
|
+
open: true,
|
|
14
|
+
kind: 'drop',
|
|
15
|
+
color: '#000000',
|
|
16
|
+
alpha: 20,
|
|
17
|
+
x: 0,
|
|
18
|
+
y: 8,
|
|
19
|
+
blur: 18,
|
|
20
|
+
spread: -6,
|
|
21
|
+
...overrides,
|
|
22
|
+
id: createId(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Returns enabled layers in CSS stacking order. */
|
|
27
|
+
export function enabledShadows(shadowList) {
|
|
28
|
+
return shadowList.filter((shadow) => shadow.enabled);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Serializes a drop shadow, inner shadow or ring to CSS. */
|
|
32
|
+
export function shadowToCss(shadow) {
|
|
33
|
+
const color = cssColorWithAlpha(shadow.color, shadow.alpha);
|
|
34
|
+
if (shadow.kind === 'ring') return `0 0 0 ${shadow.spread}px ${color}`;
|
|
35
|
+
return `${shadow.kind === 'inset' ? 'inset ' : ''}${shadow.x}px ${shadow.y}px ${shadow.blur}px ${shadow.spread}px ${color}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Serializes visible layers as a CSS box-shadow value. */
|
|
39
|
+
export function shadowListToCss(shadowList) {
|
|
40
|
+
const visibleShadows = enabledShadows(shadowList);
|
|
41
|
+
if (!visibleShadows.length) return 'none';
|
|
42
|
+
return visibleShadows.map((shadow) => shadowToCss(shadow)).join(', ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Appends a new shadow and closes existing layer disclosures. */
|
|
46
|
+
export function addShadowLayer(shadowList) {
|
|
47
|
+
shadowList.forEach((shadow) => {
|
|
48
|
+
shadow.open = false;
|
|
49
|
+
});
|
|
50
|
+
shadowList.push(createShadow());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inserts an independent duplicate with a fresh identity. */
|
|
54
|
+
export function duplicateShadowLayer(shadowList, index) {
|
|
55
|
+
shadowList.splice(index + 1, 0, createShadow({ ...shadowList[index], open: true }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Removes a layer at the specified index. */
|
|
59
|
+
export function removeShadowLayer(shadowList, index) {
|
|
60
|
+
shadowList.splice(index, 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns the human-readable shadow kind. */
|
|
64
|
+
export function shadowTitle(shadow) {
|
|
65
|
+
if (shadow.kind === 'ring') return 'Ring';
|
|
66
|
+
if (shadow.kind === 'inset') return 'Inner shadow';
|
|
67
|
+
return 'Drop shadow';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Creates an opaque identity for a newly inserted editor layer. */
|
|
71
|
+
function createId() {
|
|
72
|
+
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
box: { type: Object, required: true },
|
|
4
|
+
scale: { type: Number, default: 1 },
|
|
5
|
+
positioning: { type: Boolean, default: false },
|
|
6
|
+
});
|
|
7
|
+
const emit = defineEmits(['begin', 'move', 'finish', 'cancel', 'nudge']);
|
|
8
|
+
const handles = [
|
|
9
|
+
{ id: 'nw', label: 'top left', x: 0, y: 0, cursor: 'nwse-resize' },
|
|
10
|
+
{ id: 'n', label: 'top', x: 50, y: 0, cursor: 'ns-resize' },
|
|
11
|
+
{ id: 'ne', label: 'top right', x: 100, y: 0, cursor: 'nesw-resize' },
|
|
12
|
+
{ id: 'e', label: 'right', x: 100, y: 50, cursor: 'ew-resize' },
|
|
13
|
+
{ id: 'se', label: 'bottom right', x: 100, y: 100, cursor: 'nwse-resize' },
|
|
14
|
+
{ id: 's', label: 'bottom', x: 50, y: 100, cursor: 'ns-resize' },
|
|
15
|
+
{ id: 'sw', label: 'bottom left', x: 0, y: 100, cursor: 'nesw-resize' },
|
|
16
|
+
{ id: 'w', label: 'left', x: 0, y: 50, cursor: 'ew-resize' },
|
|
17
|
+
];
|
|
18
|
+
/** Keeps handles constant in screen pixels at every stage zoom. */
|
|
19
|
+
function handleStyle(handle) {
|
|
20
|
+
return { left: `${handle.x}%`, top: `${handle.y}%`, width: `${12 / props.scale}px`, height: `${12 / props.scale}px`, borderWidth: `${2 / props.scale}px`, cursor: handle.cursor };
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div data-designer-overlay class="pointer-events-none absolute z-[2147483647] border-primary" :style="{ borderWidth: `${2 / scale}px`, left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` }">
|
|
26
|
+
<button v-if="positioning" type="button" class="pointer-events-auto absolute inset-0 cursor-move touch-none outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Move selected layer. Arrow keys move; Alt and arrows resize; Shift uses ten pixels." @pointerdown="emit('begin', { event: $event, handle: '' })" @pointermove="emit('move', $event)" @pointerup="emit('finish')" @pointercancel="emit('cancel')" @lostpointercapture="emit('cancel')" @keydown="emit('nudge', { event: $event, handle: '' })" />
|
|
27
|
+
<template v-if="positioning">
|
|
28
|
+
<button v-for="handle in handles" :key="handle.id" type="button" class="pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2 touch-none border-primary bg-canvas outline-none focus-visible:ring-2 focus-visible:ring-ring" :style="handleStyle(handle)" :aria-label="`Resize selected layer: ${handle.label}`" @pointerdown.stop="emit('begin', { event: $event, handle: handle.id })" @pointermove="emit('move', $event)" @pointerup="emit('finish')" @pointercancel="emit('cancel')" @lostpointercapture="emit('cancel')" @keydown="emit('nudge', { event: $event, handle: handle.id })" />
|
|
29
|
+
</template>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|