From aa148b720cbb0d03f3a3bb078238646d8f635197 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 21 Aug 2026 22:38:41 -0500 Subject: [PATCH] feat: contextual selection controls --- TODO.md | 16 +- .../ui/src/lib/components/ColorPicker.svelte | 49 +- .../lib/editor/components/ArrowPopover.svelte | 634 ++++++++---- .../lib/editor/components/LayerPanel.svelte | 8 + .../components/SelectionControls.svelte | 928 ++++++++++++++++++ .../src/lib/editor/components/Toolbar.svelte | 774 +-------------- .../__tests__/ArrowPopover.svelte.test.ts | 14 +- .../SelectionControls.svelte.test.ts | 167 ++++ scripts/images/capture-selection-controls.mjs | 115 +++ 9 files changed, 1730 insertions(+), 975 deletions(-) create mode 100644 packages/ui/src/lib/editor/components/SelectionControls.svelte create mode 100644 packages/ui/src/lib/editor/components/__tests__/SelectionControls.svelte.test.ts create mode 100644 scripts/images/capture-selection-controls.mjs diff --git a/TODO.md b/TODO.md index 8eeb006..9205d38 100644 --- a/TODO.md +++ b/TODO.md @@ -39,18 +39,18 @@ selection-specific controls. Expose only controls that apply to the active tool or current selection. -- [ ] Define contextual controls from shape capabilities rather than one shared +- [x] Define contextual controls from shape capabilities rather than one shared selection panel -- [ ] Show fill, stroke, opacity, and shape-specific controls only when +- [x] Show fill, stroke, opacity, and shape-specific controls only when applicable -- [ ] Give arrows a focused stroke, endpoint, and connection control surface -- [ ] Give text and Markdown selections focused typography controls -- [ ] Give multi-selection a focused align, distribute, group, and arrange +- [x] Give arrows a focused stroke, endpoint, and connection control surface +- [x] Give text and Markdown selections focused typography controls +- [x] Give multi-selection a focused align, distribute, group, and arrange surface -- [ ] Preserve mixed-value states when selected shapes do not share a property -- [ ] Keep contextual controls keyboard accessible and return focus correctly +- [x] Preserve mixed-value states when selected shapes do not share a property +- [x] Keep contextual controls keyboard accessible and return focus correctly when popovers close -- [ ] Add interaction tests for contextual controls across representative +- [x] Add interaction tests for contextual controls across representative selection types ### Theme and component consistency diff --git a/packages/ui/src/lib/components/ColorPicker.svelte b/packages/ui/src/lib/components/ColorPicker.svelte index cc3c0e2..880d7b4 100644 --- a/packages/ui/src/lib/components/ColorPicker.svelte +++ b/packages/ui/src/lib/components/ColorPicker.svelte @@ -11,6 +11,8 @@ recentColors?: readonly string[]; /** Prevents opening and changing the color. */ disabled?: boolean; + /** Indicates that the selected shapes do not share one color. */ + mixed?: boolean; /** Horizontal alignment of the palette below the trigger. */ align?: 'start' | 'end'; } @@ -32,6 +34,7 @@ onchange, recentColors = [], disabled = false, + mixed = false, align = 'start' }: ColorPickerProps = $props(); @@ -44,6 +47,7 @@ let hexError = $state(false); let recent = $state([]); let restoreFocus = false; + let panelPosition = $state({ left: 8, top: 8 }); let pickerId = $derived(`color-picker-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`); let currentColor = $derived(normalizeHex(value) ?? value); @@ -81,10 +85,26 @@ const panel = panelEl; const picker = pickerEl; - if (!panel || !picker || typeof document === 'undefined') return; + const trigger = triggerEl; + if (!panel || !picker || !trigger || typeof document === 'undefined') return; const pickerElement = picker; queueMicrotask(() => { + const triggerBounds = trigger.getBoundingClientRect(); + const panelBounds = panel.getBoundingClientRect(); + const gutter = 8; + const preferredTop = triggerBounds.bottom + gutter; + const top = + preferredTop + panelBounds.height <= window.innerHeight - gutter + ? preferredTop + : Math.max(gutter, triggerBounds.top - panelBounds.height - gutter); + const preferredLeft = + align === 'end' ? triggerBounds.right - panelBounds.width : triggerBounds.left; + const left = Math.max( + gutter, + Math.min(preferredLeft, window.innerWidth - panelBounds.width - gutter) + ); + panelPosition = { left, top }; const selectedOption = panel.querySelector('[aria-pressed="true"]'); (selectedOption ?? panel).focus(); }); @@ -182,16 +202,17 @@
{#if isOpen} @@ -199,6 +220,8 @@ class="color-picker__panel" class:color-picker__panel--end={align === 'end'} bind:this={panelEl} + style:left={`${panelPosition.left}px`} + style:top={`${panelPosition.top}px`} role="dialog" tabindex="-1" aria-label={label}> @@ -330,6 +353,16 @@ box-shadow var(--ink-duration-fast) var(--ink-ease-out); } + .color-picker__trigger--mixed { + background: repeating-linear-gradient( + -45deg, + var(--ink-surface-hover) 0, + var(--ink-surface-hover) 5px, + var(--ink-border-strong) 5px, + var(--ink-border-strong) 7px + ); + } + .color-picker__trigger:hover:not(:disabled) { translate: -1px -1px; box-shadow: calc(var(--ink-shadow-offset) + 1px) calc(var(--ink-shadow-offset) + 1px) 0 @@ -355,9 +388,9 @@ } .color-picker__panel { - position: absolute; - top: calc(100% + var(--ink-space-2)); - left: 0; + position: fixed; + top: 8px; + left: 8px; z-index: 10; width: min(15rem, calc(100vw - 1rem)); padding: var(--ink-space-3); diff --git a/packages/ui/src/lib/editor/components/ArrowPopover.svelte b/packages/ui/src/lib/editor/components/ArrowPopover.svelte index 0fcd886..17279d6 100644 --- a/packages/ui/src/lib/editor/components/ArrowPopover.svelte +++ b/packages/ui/src/lib/editor/components/ArrowPopover.svelte @@ -1,43 +1,53 @@ @@ -136,65 +247,144 @@ {#if isOpen} @@ -202,115 +392,181 @@ diff --git a/packages/ui/src/lib/editor/components/LayerPanel.svelte b/packages/ui/src/lib/editor/components/LayerPanel.svelte index a0a8ee1..6637244 100644 --- a/packages/ui/src/lib/editor/components/LayerPanel.svelte +++ b/packages/ui/src/lib/editor/components/LayerPanel.svelte @@ -584,6 +584,14 @@ font: 600 var(--ink-type-xs) / 1 var(--ink-font-body); } + @media (min-width: 1181px) { + .layer-panel { + top: 14rem; + bottom: auto; + max-height: calc(100% - 15rem); + } + } + @media (max-width: 720px) and (pointer: coarse) { .layer-panel { top: auto; diff --git a/packages/ui/src/lib/editor/components/SelectionControls.svelte b/packages/ui/src/lib/editor/components/SelectionControls.svelte new file mode 100644 index 0000000..17f7571 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionControls.svelte @@ -0,0 +1,928 @@ + + +{#if showContextControls} + + + (layoutMenuOpen = value)} + onSelect={handleLayoutMenuAction} /> +{/if} + + diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index a18c491..c4b3e11 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -1,43 +1,16 @@
@@ -895,115 +433,7 @@
- {#if showContextControls} - - (layoutMenuOpen = value)} - onSelect={handleLayoutMenuAction} /> - {/if} +