@motion-proto/live-tokens 0.93.0 → 0.94.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 +19 -0
- package/package.json +1 -1
- package/src/editor/ui/PaletteEditor.svelte +35 -1
- package/src/editor/ui/colors/ColorsTab.svelte +15 -24
- package/src/editor/ui/colors/paletteBaseColor.ts +13 -6
- package/src/editor/ui/palette/OverridesPanel.svelte +2 -0
- package/src/editor/ui/palette/PaletteBase.svelte +49 -0
- package/src/editor/ui/swatchClipboard.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.94.0 — Tokens-view swatch swap and copy-paste
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The Tokens view swaps two palettes' base colors by drag and drop.** Drop
|
|
8
|
+
one family's header swatch on another's and the two trade seed colors in one
|
|
9
|
+
undo step. A swap with a functional family leaves any harmony axis on the
|
|
10
|
+
family that held it, and the axis follows the new hue.
|
|
11
|
+
- **Every Tokens-view swatch copies and pastes its color.** Click a header,
|
|
12
|
+
palette step, or override swatch open, and ⌘C (Ctrl+C) copies its color as
|
|
13
|
+
`oklch()` text. ⌘V (Ctrl+V) pastes a hex or `oklch()` color into the open
|
|
14
|
+
swatch, inside its edit session, so Cancel takes the paste back.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Pasting a swatch color needs no clipboard permission.** Paste reads the
|
|
19
|
+
browser's paste event, so Chrome no longer asks for clipboard access and
|
|
20
|
+
Safari no longer shows its Paste confirmation before ⌘V lands.
|
|
21
|
+
|
|
3
22
|
## 0.93.0 — Swatch swap and copy-paste
|
|
4
23
|
|
|
5
24
|
### Added
|
package/package.json
CHANGED
|
@@ -30,9 +30,16 @@
|
|
|
30
30
|
import { editorState, mutate, setPaletteConfig, beginSliderGesture, beginScope, commitScope, cancelScope, type Scope } from '../core/store/editorStore';
|
|
31
31
|
// Base-color edits route through the shared setter so a bound harmony axis
|
|
32
32
|
// follows the hue (invariant 1); the local `edit` would silently detach it.
|
|
33
|
-
import { setBaseColor } from './colors/paletteBaseColor';
|
|
33
|
+
import { setBaseColor, swapBaseColors } from './colors/paletteBaseColor';
|
|
34
34
|
import { pendingPaletteFocus } from '../core/store/paletteFocus';
|
|
35
35
|
import { showCopyPopover } from './copyPopover';
|
|
36
|
+
import {
|
|
37
|
+
activeSwatchFamily,
|
|
38
|
+
isSwatchCopy,
|
|
39
|
+
swatchPasteColor,
|
|
40
|
+
copySwatchColor,
|
|
41
|
+
showSwatchColor,
|
|
42
|
+
} from './swatchClipboard';
|
|
36
43
|
|
|
37
44
|
// Full sRGB chroma range (gamutClamp trims per hue/lightness). Neutrals default
|
|
38
45
|
// low but are not capped; their calm character comes from defaults, not a ceiling.
|
|
@@ -565,6 +572,29 @@
|
|
|
565
572
|
if (!panelOpen || isEditingBase || !editingColor) return;
|
|
566
573
|
dock = { title: editPanelTitle, color: editingColor, paletteIndex: editingPaletteIndex };
|
|
567
574
|
});
|
|
575
|
+
$effect(() => {
|
|
576
|
+
if (editingKey !== null) activeSwatchFamily.set(label);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
let ownsClipboard = $derived($activeSwatchFamily === label && editingKey !== null);
|
|
580
|
+
let openSwatchEl = $derived(rootEl?.querySelector(`[data-swatch-key="${editingKey}"]`) ?? null);
|
|
581
|
+
|
|
582
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
583
|
+
if (!ownsClipboard || !isSwatchCopy(e)) return;
|
|
584
|
+
e.preventDefault();
|
|
585
|
+
copySwatchColor(editing.kind === 'editingStep' ? editing.draft : baseColor, openSwatchEl);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Paste lands in the open session like a slider edit: Cancel takes it back.
|
|
589
|
+
function onWindowPaste(e: ClipboardEvent) {
|
|
590
|
+
const color = ownsClipboard ? swatchPasteColor(e) : null;
|
|
591
|
+
if (color === null) return;
|
|
592
|
+
e.preventDefault();
|
|
593
|
+
if (isEditingBase) setBaseColor(label, color);
|
|
594
|
+
else handleColorChange(color);
|
|
595
|
+
showSwatchColor(color, openSwatchEl);
|
|
596
|
+
}
|
|
597
|
+
|
|
568
598
|
$effect(() => {
|
|
569
599
|
// Touch each input so the effect tracks them (resnapScales reads indirectly).
|
|
570
600
|
void baseColor;
|
|
@@ -595,6 +625,8 @@
|
|
|
595
625
|
{/if}
|
|
596
626
|
{/snippet}
|
|
597
627
|
|
|
628
|
+
<svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
|
|
629
|
+
|
|
598
630
|
<div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
|
|
599
631
|
<PaletteBase
|
|
600
632
|
{label}
|
|
@@ -606,6 +638,7 @@
|
|
|
606
638
|
{copiedKey}
|
|
607
639
|
onStartEdit={startBaseEdit}
|
|
608
640
|
onCopyBaseHex={copyHex}
|
|
641
|
+
onSwapFrom={(family) => swapBaseColors(family, label)}
|
|
609
642
|
>
|
|
610
643
|
{#snippet setting()}
|
|
611
644
|
<!-- The family's own swatch and name sit alongside, so the label drops
|
|
@@ -687,6 +720,7 @@
|
|
|
687
720
|
class:anchored={ps.anchored}
|
|
688
721
|
title={ps.anchored ? 'Base color' : undefined}
|
|
689
722
|
style="background: {ps.effective}"
|
|
723
|
+
data-swatch-key={ps.key}
|
|
690
724
|
onclick={() => handlePaletteClick(ps)}
|
|
691
725
|
role="button"
|
|
692
726
|
tabindex="0"
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cubicOut } from 'svelte/easing';
|
|
3
|
-
import {
|
|
3
|
+
import { oklchToHexClamped } from '../../core/palettes/oklch';
|
|
4
4
|
import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
|
|
5
5
|
import { editorState, beginSliderGesture } from '../../core/store/editorStore';
|
|
6
|
-
import { isEditable } from '../../core/store/editorKeybindings';
|
|
7
6
|
import { selectedPalette } from '../../core/store/paletteFocus';
|
|
8
7
|
import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
|
|
9
8
|
import AxisNumeral from './AxisNumeral.svelte';
|
|
@@ -14,7 +13,7 @@
|
|
|
14
13
|
import PaletteStepStrip from './PaletteStepStrip.svelte';
|
|
15
14
|
import HarmonyAxesList from './HarmonyAxesList.svelte';
|
|
16
15
|
import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
|
|
17
|
-
import {
|
|
16
|
+
import { isSwatchCopy, swatchPasteColor, copySwatchColor, showSwatchColor } from '../swatchClipboard';
|
|
18
17
|
import {
|
|
19
18
|
setBaseColor,
|
|
20
19
|
setAxisHues,
|
|
@@ -157,31 +156,21 @@
|
|
|
157
156
|
// Copy and paste act on the selected swatch, never the focused one: Safari
|
|
158
157
|
// and macOS Firefox do not focus a button on click.
|
|
159
158
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const key = e.key.toLowerCase();
|
|
165
|
-
if (key === 'c') {
|
|
166
|
-
// Selected page text keeps the browser's own copy.
|
|
167
|
-
if (window.getSelection()?.isCollapsed === false) return;
|
|
168
|
-
e.preventDefault();
|
|
169
|
-
const css = oklchToCss(sw.color.l, sw.color.c, sw.color.h);
|
|
170
|
-
navigator.clipboard?.writeText(css);
|
|
171
|
-
showCopyPopover(css, swatchEls[sw.label]);
|
|
172
|
-
} else if (key === 'v') {
|
|
173
|
-
e.preventDefault();
|
|
174
|
-
pasteInto(sw, swatchEls[sw.label]);
|
|
175
|
-
}
|
|
159
|
+
const sw = selectedSwatch;
|
|
160
|
+
if (!sw || !isSwatchCopy(e)) return;
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
copySwatchColor(sw.color, swatchEls[sw.label]);
|
|
176
163
|
}
|
|
177
164
|
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
165
|
+
function onWindowPaste(e: ClipboardEvent) {
|
|
166
|
+
const sw = selectedSwatch;
|
|
167
|
+
const color = sw ? swatchPasteColor(e) : null;
|
|
168
|
+
if (!sw || color === null) return;
|
|
169
|
+
e.preventDefault();
|
|
181
170
|
// A bound color drags its axis along, so an applied harmony no longer holds.
|
|
182
171
|
if (sw.axis && color.h !== sw.color.h) activeMode = 'custom';
|
|
183
172
|
setBaseColor(sw.label, color);
|
|
184
|
-
|
|
173
|
+
showSwatchColor(color, swatchEls[sw.label]);
|
|
185
174
|
}
|
|
186
175
|
|
|
187
176
|
let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
|
|
@@ -204,6 +193,8 @@
|
|
|
204
193
|
|
|
205
194
|
type Swatch = (typeof swatches)[number];
|
|
206
195
|
|
|
196
|
+
let selectedSwatch = $derived(swatches.find((s) => s.label === selected));
|
|
197
|
+
|
|
207
198
|
const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
|
|
208
199
|
// Declaration order, never a computed one: sorting by luminance made the row
|
|
209
200
|
// reshuffle under the pointer while a lightness edit was in flight.
|
|
@@ -229,7 +220,7 @@
|
|
|
229
220
|
}
|
|
230
221
|
</script>
|
|
231
222
|
|
|
232
|
-
<svelte:window onkeydown={onWindowKeydown} />
|
|
223
|
+
<svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
|
|
233
224
|
|
|
234
225
|
<div class="colors-tab">
|
|
235
226
|
<div class="pane">
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { get } from 'svelte/store';
|
|
21
21
|
import type { Oklch } from '../../core/palettes/oklch';
|
|
22
|
-
import { axisLabel } from '../../core/palettes/colorHarmony';
|
|
22
|
+
import { axisLabel, HARMONY_ELIGIBLE } from '../../core/palettes/colorHarmony';
|
|
23
23
|
import { PALETTE_SPECS, syncBaseAnchor, type PaletteSpec } from '../../core/palettes/paletteDerivation';
|
|
24
24
|
import { defaultPaletteConfig } from '../palette/paletteMath';
|
|
25
25
|
import { mutate, transaction, editorState } from '../../core/store/editorStore';
|
|
@@ -208,8 +208,10 @@ function giveAxisHue(s: EditorState, index: number, family: string): boolean {
|
|
|
208
208
|
return false;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
/** Two families trade seed colors.
|
|
212
|
-
*
|
|
211
|
+
/** Two families trade seed colors. Between two families an axis can hold, each
|
|
212
|
+
* binding travels with its color, so every axis keeps its hue and an applied
|
|
213
|
+
* harmony stays true. Otherwise a bound family keeps its axis, which follows
|
|
214
|
+
* the new hue as it does on any direct color edit. */
|
|
213
215
|
export function swapBaseColors(a: string, b: string): void {
|
|
214
216
|
if (a === b) return;
|
|
215
217
|
mutate(`colors: swap ${a} and ${b}`, (s) => {
|
|
@@ -218,9 +220,14 @@ export function swapBaseColors(a: string, b: string): void {
|
|
|
218
220
|
[ca.baseColor, cb.baseColor] = [cb.baseColor, ca.baseColor];
|
|
219
221
|
syncBaseAnchor(ca);
|
|
220
222
|
syncBaseAnchor(cb);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
if (HARMONY_ELIGIBLE.includes(a) && HARMONY_ELIGIBLE.includes(b)) {
|
|
224
|
+
for (const axis of s.harmonyAxes) {
|
|
225
|
+
if (axis.family === a) axis.family = b;
|
|
226
|
+
else if (axis.family === b) axis.family = a;
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
syncBoundAxisHue(s, a);
|
|
230
|
+
syncBoundAxisHue(s, b);
|
|
224
231
|
}
|
|
225
232
|
});
|
|
226
233
|
}
|
|
@@ -180,6 +180,7 @@
|
|
|
180
180
|
class:active={editingKey === k}
|
|
181
181
|
class:populated={k in overrides}
|
|
182
182
|
class:matching={k in overrides && overrides[k] === dHex}
|
|
183
|
+
data-swatch-key={k}
|
|
183
184
|
onclick={() => onOverrideClick(k, step, scale.title)}
|
|
184
185
|
role="button"
|
|
185
186
|
tabindex="0"
|
|
@@ -212,6 +213,7 @@
|
|
|
212
213
|
class:active={editingKey === k || snapPickerKey === k}
|
|
213
214
|
class:populated={k in overrides}
|
|
214
215
|
class:matching={k in overrides && overrides[k] === dHex}
|
|
216
|
+
data-swatch-key={k}
|
|
215
217
|
onclick={() => snapped ? onSnappedClick(k) : onOverrideClick(k, step, scale.title)}
|
|
216
218
|
role="button"
|
|
217
219
|
tabindex="0"
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
// Every family's header reads one drag source, because dataTransfer data is
|
|
3
|
+
// unreadable during dragover.
|
|
4
|
+
let swapSource: string | null = null;
|
|
5
|
+
const SWAP_DRAG_TYPE = 'application/x-palette-family';
|
|
6
|
+
</script>
|
|
7
|
+
|
|
1
8
|
<script lang="ts">
|
|
2
9
|
import type { Snippet } from 'svelte';
|
|
3
10
|
import { oklchToHexClamped, type Oklch } from '../../core/palettes/oklch';
|
|
11
|
+
import { BASE_KEY } from './paletteEditorState';
|
|
4
12
|
|
|
5
13
|
interface Props {
|
|
6
14
|
/**
|
|
@@ -20,6 +28,8 @@
|
|
|
20
28
|
copiedKey: string | null;
|
|
21
29
|
onStartEdit: () => void;
|
|
22
30
|
onCopyBaseHex: (key: string, hex: string, event?: MouseEvent) => void;
|
|
31
|
+
/** Another family's header swatch dropped on this one. */
|
|
32
|
+
onSwapFrom: (family: string) => void;
|
|
23
33
|
actions?: Snippet;
|
|
24
34
|
/** A family-level setting, shown beside the name while this family's
|
|
25
35
|
* editor is open. Collapsed families keep the band to identity + actions. */
|
|
@@ -36,10 +46,34 @@
|
|
|
36
46
|
copiedKey,
|
|
37
47
|
onStartEdit,
|
|
38
48
|
onCopyBaseHex,
|
|
49
|
+
onSwapFrom,
|
|
39
50
|
actions,
|
|
40
51
|
setting
|
|
41
52
|
}: Props = $props();
|
|
42
53
|
|
|
54
|
+
let swapHover = $state(false);
|
|
55
|
+
|
|
56
|
+
function onDragStart(e: DragEvent) {
|
|
57
|
+
if (!e.dataTransfer) return;
|
|
58
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
59
|
+
e.dataTransfer.setData(SWAP_DRAG_TYPE, label);
|
|
60
|
+
swapSource = label;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onDragOver(e: DragEvent) {
|
|
64
|
+
if (swapSource === null || swapSource === label) return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
67
|
+
swapHover = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onDrop(e: DragEvent) {
|
|
71
|
+
swapHover = false;
|
|
72
|
+
if (swapSource === null || swapSource === label) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
onSwapFrom(swapSource);
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
let baseHex = $derived(oklchToHexClamped(baseColor.l, baseColor.c, baseColor.h));
|
|
44
78
|
|
|
45
79
|
// `pinnedOpen || isEditingBase` is exactly when the base panel shows: editing
|
|
@@ -54,11 +88,19 @@
|
|
|
54
88
|
class="header-swatch"
|
|
55
89
|
class:active={isEditingBase || pinnedOpen}
|
|
56
90
|
class:selected
|
|
91
|
+
class:swap-target={swapHover}
|
|
57
92
|
style="background: {baseHex}"
|
|
93
|
+
data-swatch-key={BASE_KEY}
|
|
94
|
+
draggable="true"
|
|
58
95
|
onclick={onStartEdit}
|
|
59
96
|
role="button"
|
|
60
97
|
tabindex="0"
|
|
61
98
|
onkeydown={(e) => e.key === 'Enter' && onStartEdit()}
|
|
99
|
+
ondragstart={onDragStart}
|
|
100
|
+
ondragend={() => (swapSource = null)}
|
|
101
|
+
ondragover={onDragOver}
|
|
102
|
+
ondragleave={() => (swapHover = false)}
|
|
103
|
+
ondrop={onDrop}
|
|
62
104
|
></div>
|
|
63
105
|
<div class="primary-info">
|
|
64
106
|
<span class="editor-label">{displayLabel ?? label}</span>
|
|
@@ -163,6 +205,13 @@
|
|
|
163
205
|
inset 0 0 0 4px var(--ui-surface-lowest);
|
|
164
206
|
}
|
|
165
207
|
|
|
208
|
+
/* About to receive a swap: the axis row's doubled drop ring. */
|
|
209
|
+
.header-swatch.swap-target {
|
|
210
|
+
border-color: var(--ui-text-primary);
|
|
211
|
+
outline: 1px solid var(--ui-text-primary);
|
|
212
|
+
outline-offset: 1px;
|
|
213
|
+
}
|
|
214
|
+
|
|
166
215
|
.editor-label {
|
|
167
216
|
font-size: var(--ui-font-size-xl);
|
|
168
217
|
font-weight: var(--ui-font-weight-semibold);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { writable } from 'svelte/store';
|
|
2
|
+
import { cssColorToOklch, oklchToCss, type Oklch } from '../core/palettes/oklch';
|
|
3
|
+
import { isEditable } from '../core/store/editorKeybindings';
|
|
4
|
+
import { showCopyPopover } from './copyPopover';
|
|
5
|
+
|
|
6
|
+
/** The Tokens-view family whose swatch opened last. Every family can hold an
|
|
7
|
+
* open swatch at once, and copy and paste act on this one alone. */
|
|
8
|
+
export const activeSwatchFamily = writable<string | null>(null);
|
|
9
|
+
|
|
10
|
+
const inTextField = (e: Event) => e.target instanceof HTMLElement && isEditable(e.target);
|
|
11
|
+
|
|
12
|
+
/** ⌘C on a swatch view. A text field or selected page text keeps the browser's
|
|
13
|
+
* own copy. Copy rides the key rather than the copy event because Safari fires
|
|
14
|
+
* no copy event without a text selection. */
|
|
15
|
+
export function isSwatchCopy(e: KeyboardEvent): boolean {
|
|
16
|
+
if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'c') return false;
|
|
17
|
+
return !inTextField(e) && window.getSelection()?.isCollapsed !== false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The color a paste carries to a swatch, or null when a text field keeps the
|
|
21
|
+
* paste or the clipboard holds no hex or `oklch()` color. Paste rides the
|
|
22
|
+
* event because its clipboardData needs no read permission. */
|
|
23
|
+
export function swatchPasteColor(e: ClipboardEvent): Oklch | null {
|
|
24
|
+
if (inTextField(e)) return null;
|
|
25
|
+
return cssColorToOklch(e.clipboardData?.getData('text/plain') ?? '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function showSwatchColor(color: Oklch, anchor: Element | null): void {
|
|
29
|
+
showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function copySwatchColor(color: Oklch, anchor: Element | null): void {
|
|
33
|
+
navigator.clipboard?.writeText(oklchToCss(color.l, color.c, color.h));
|
|
34
|
+
showSwatchColor(color, anchor);
|
|
35
|
+
}
|