Port the color picker popover to a Rust-defined layout (#4102)

* Break out VisualColorPickersInput.svelte

* Break out ColorComparisonInput.svelte and ColorPresetsInput.svelte

* Add backend definitions and plumbing for the 4 new widgets

* Port the ColorPicker.svelte layout and business logic to Rust

* Port more ColorComparisonInput.svelte logic to Rust

* Port more SpectrumInput.svelte logic to Rust

* Port more frontend logic to Rust

* Code review

* Code review

* Fix some CSS
This commit is contained in:
Keavon Chambers
2026-05-05 02:47:53 -07:00
committed by GitHub
parent 62203cb171
commit e59612c4ce
31 changed files with 2260 additions and 1333 deletions

View File

@@ -9,6 +9,7 @@
import { createPanicManager, destroyPanicManager } from "/src/managers/panic";
import { createPersistenceManager, destroyPersistenceManager } from "/src/managers/persistence";
import { createAppWindowStore, destroyAppWindowStore } from "/src/stores/app-window";
import { createColorPickerStore, destroyColorPickerStore } from "/src/stores/color-picker";
import { createDialogStore, destroyDialogStore } from "/src/stores/dialog";
import { createDocumentStore, destroyDocumentStore } from "/src/stores/document";
import { createFullscreenStore, destroyFullscreenStore } from "/src/stores/fullscreen";
@@ -32,6 +33,7 @@
nodeGraph: createNodeGraphStore(subscriptions),
portfolio: createPortfolioStore(subscriptions, editor),
appWindow: createAppWindowStore(subscriptions),
colorPicker: createColorPickerStore(subscriptions),
};
Object.entries(stores).forEach(([key, store]) => setContext(key, store));
@@ -61,6 +63,7 @@
destroyNodeGraphStore();
destroyPortfolioStore();
destroyAppWindowStore();
destroyColorPickerStore();
// Managers
destroyClipboardManager();

File diff suppressed because it is too large Load Diff

View File

@@ -141,7 +141,7 @@
display: none;
}
.body {
> .body {
padding: 0 7px;
padding-top: 1px;
margin-top: -1px;
@@ -150,7 +150,7 @@
border-radius: 0 0 4px 4px;
overflow: hidden;
.widget-span.row {
> .widget-span.row {
&:first-child {
margin-top: calc(4px - 1px);
}

View File

@@ -8,20 +8,25 @@
import PopoverButton from "/src/components/widgets/buttons/PopoverButton.svelte";
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
import CheckboxInput from "/src/components/widgets/inputs/CheckboxInput.svelte";
import ColorComparisonInput from "/src/components/widgets/inputs/ColorComparisonInput.svelte";
import ColorInput from "/src/components/widgets/inputs/ColorInput.svelte";
import ColorPresetsInput from "/src/components/widgets/inputs/ColorPresetsInput.svelte";
import CurveInput from "/src/components/widgets/inputs/CurveInput.svelte";
import DropdownInput from "/src/components/widgets/inputs/DropdownInput.svelte";
import NumberInput from "/src/components/widgets/inputs/NumberInput.svelte";
import RadioInput from "/src/components/widgets/inputs/RadioInput.svelte";
import ReferencePointInput from "/src/components/widgets/inputs/ReferencePointInput.svelte";
import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte";
import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte";
import TextInput from "/src/components/widgets/inputs/TextInput.svelte";
import VisualColorPickersInput from "/src/components/widgets/inputs/VisualColorPickersInput.svelte";
import WorkingColorsInput from "/src/components/widgets/inputs/WorkingColorsInput.svelte";
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
import ImageLabel from "/src/components/widgets/labels/ImageLabel.svelte";
import Separator from "/src/components/widgets/labels/Separator.svelte";
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { ColorPickerStore } from "/src/stores/color-picker";
import { parseFillChoice } from "/src/utility-functions/colors";
import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper";
@@ -33,6 +38,7 @@
type UnwrappedWidget = { [K in WidgetKind]: [kind: K, props: WidgetProps<K>] }[WidgetKind];
const editor = getContext<EditorWrapper>("editor");
const colorPickerStore = getContext<ColorPickerStore>("colorPicker");
export let widgets: WidgetInstance[];
export let direction: "row" | "column";
@@ -116,6 +122,15 @@
$$events: { checked: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
}),
},
ColorComparisonInput: {
component: ColorComparisonInput,
getProps: (props, index) => ({
...props,
$$events: {
swap: () => widgetValueCommitAndUpdate(index, undefined, true),
},
}),
},
ColorInput: {
component: ColorInput,
getProps: (props, index) => ({
@@ -127,6 +142,17 @@
},
}),
},
ColorPresetsInput: {
component: ColorPresetsInput,
getProps: (props, index) => ({
...props,
$$events: {
// The widget dispatches `"None"` or a bare `Color`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoice` shape (which the `Preset` variant expects).
preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true),
eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true),
},
}),
},
CurveInput: {
// TODO: CurvesInput is currently unused
component: CurveInput,
@@ -210,6 +236,28 @@
$$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
}),
},
SpectrumInput: {
component: SpectrumInput,
getProps: (props, index) => ({
...props,
$$events: {
update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
dragging: (e: CustomEvent<boolean>) => colorPickerStore.setDragging(e.detail),
},
}),
},
VisualColorPickersInput: {
component: VisualColorPickersInput,
getProps: (props, index) => ({
...props,
$$events: {
update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
startHistoryTransaction: () => widgetValueCommit(index, undefined),
commitHistoryTransaction: () => widgetValueCommit(index, undefined),
dragStateChange: (e: CustomEvent<boolean>) => colorPickerStore.setDragging(e.detail),
},
}),
},
PopoverButton: {
component: PopoverButton,
getProps: (props) => ({

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ swap: undefined }>();
export let newColor: Color | undefined;
export let oldColor: Color | undefined;
export let newColorCSS: string;
export let newColorContrasting: string;
export let oldColorCSS: string;
export let oldColorContrasting: string;
export let isNone: boolean;
export let oldIsNone: boolean;
export let disabled = false;
export let differs: boolean;
export let outlineAmount: number;
$: outlined = outlineAmount > 0.0001;
$: transparency = (newColor?.alpha ?? 1) < 1 || (oldColor?.alpha ?? 1) < 1;
</script>
<LayoutRow
class="color-comparison-input"
classes={{ outlined, transparency, disabled }}
styles={{
"--outline-amount": outlineAmount,
"--new-color": newColorCSS || undefined,
"--new-color-contrasting": newColorContrasting,
"--old-color": oldColorCSS || undefined,
"--old-color-contrasting": oldColorContrasting,
}}
tooltipDescription={differs ? "Comparison between the present color choice (left) and the color before it was changed (right)." : "The present color choice."}
>
{#if differs && !disabled}
<div class="swap-button-background"></div>
<IconButton class="swap-button" icon="SwapHorizontal" size={16} action={() => dispatch("swap")} tooltipLabel="Swap" />
{/if}
<LayoutCol class="new-color" classes={{ none: isNone }}>
{#if differs}
<TextLabel>New</TextLabel>
{/if}
</LayoutCol>
{#if differs}
<LayoutCol class="old-color" classes={{ none: oldIsNone }}>
<TextLabel>Old</TextLabel>
</LayoutCol>
{/if}
</LayoutRow>
<style lang="scss">
.color-comparison-input {
flex: 0 0 auto;
width: 100%;
height: 32px;
border-radius: 2px;
box-sizing: border-box;
overflow: hidden;
position: relative;
&.outlined::after {
content: "";
pointer-events: none;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
box-shadow: inset 0 0 0 1px rgba(var(--color-0-black-rgb), var(--outline-amount));
}
&.transparency {
background-image: var(--color-transparent-checkered-background);
background-size: var(--color-transparent-checkered-background-size);
background-position: var(--color-transparent-checkered-background-position);
background-repeat: var(--color-transparent-checkered-background-repeat);
}
.swap-button-background {
overflow: hidden;
position: absolute;
mix-blend-mode: multiply;
opacity: 0.25;
border-radius: 2px;
width: 16px;
height: 16px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&::before,
&::after {
content: "";
position: absolute;
width: 50%;
height: 100%;
}
&::before {
left: 0;
background: var(--new-color-contrasting);
}
&::after {
right: 0;
background: var(--old-color-contrasting);
}
}
.swap-button {
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
}
.new-color {
background: var(--new-color);
.text-label {
text-align: left;
margin: 2px 8px;
color: var(--new-color-contrasting);
}
}
.old-color {
background: var(--old-color);
.text-label {
text-align: right;
margin: 2px 8px;
color: var(--old-color-contrasting);
}
}
.new-color,
.old-color {
width: 50%;
height: 100%;
&.none {
background: var(--color-none);
background-repeat: var(--color-none-repeat);
background-position: var(--color-none-position);
background-size: var(--color-none-size-32px);
background-image: var(--color-none-image-32px);
.text-label {
// Many stacked white shadows helps to increase the opacity and approximate shadow spread which does not exist for text shadows
text-shadow:
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white,
0 0 4px white;
}
}
}
&.disabled {
transition: opacity 0.1s;
&:hover {
opacity: 0.5;
}
}
}
</style>

View File

@@ -2,13 +2,14 @@
import { createEventDispatcher } from "svelte";
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops, colorToHexOptionalAlpha, gradientToLinearGradientCSS } from "/src/utility-functions/colors";
import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops } from "/src/utility-functions/colors";
import type { FillChoice, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
// Content
export let value: FillChoice;
export let chosenGradient: string | undefined = undefined;
export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement
export let menuDirection: MenuDirection = "Bottom";
@@ -26,11 +27,6 @@
$: outlined = outlineFactor > 0.0001;
$: gradientStops = fillChoiceGradientStops(value);
$: solidColor = fillChoiceColor(value);
$: chosenGradient = gradientStops
? gradientToLinearGradientCSS(gradientStops)
: solidColor
? `linear-gradient(${colorToHexOptionalAlpha(solidColor)}, ${colorToHexOptionalAlpha(solidColor)})`
: undefined;
$: none = value === "None";
$: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 1) : solidColor ? solidColor.alpha < 1 : false;
</script>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import Separator from "/src/components/widgets/labels/Separator.svelte";
import { createColor } from "/src/utility-functions/colors";
import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper";
type PresetColor = "Black" | "White" | "Red" | "Yellow" | "Green" | "Cyan" | "Blue" | "Magenta";
const PURE_COLORS: Record<PresetColor, [number, number, number]> = {
Black: [0, 0, 0],
White: [1, 1, 1],
Red: [1, 0, 0],
Yellow: [1, 1, 0],
Green: [0, 1, 0],
Cyan: [0, 1, 1],
Blue: [0, 0, 1],
Magenta: [1, 0, 1],
};
const PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [
["Red", "#ff0000", "#4c4c4c"],
["Yellow", "#ffff00", "#e3e3e3"],
["Green", "#00ff00", "#969696"],
["Cyan", "#00ffff", "#b2b2b2"],
["Blue", "#0000ff", "#1c1c1c"],
["Magenta", "#ff00ff", "#696969"],
];
const dispatch = createEventDispatcher<{
preset: Color | "None";
eyedropperColorCode: string;
}>();
export let disabled = false;
export let showNoneOption = false;
function pickPreset(preset: PresetColor | "None") {
if (disabled) return;
dispatch("preset", preset === "None" ? "None" : createColor(...PURE_COLORS[preset], 1));
}
// TODO: Replace this temporary usage of the browser eyedropper API, that only works in Chromium-based browsers, with the custom color sampler system used by the Eyedropper tool
function eyedropperSupported(): boolean {
// TODO: Implement support in the desktop app for OS-level color picking
if (import.meta.env.MODE === "native") return false;
return window.EyeDropper !== undefined;
}
async function activateEyedropperSample() {
if (!eyedropperSupported()) return;
try {
const result = await new EyeDropper().open();
dispatch("eyedropperColorCode", result.sRGBHex);
} catch {
// Do nothing
}
}
</script>
<LayoutRow class="color-presets-input" classes={{ disabled }}>
{#if showNoneOption}
<button
class="preset-color none"
{disabled}
on:click={() => pickPreset("None")}
data-tooltip-label="Set to No Color"
data-tooltip-description={disabled ? "Disabled (read-only)." : ""}
tabindex="0"
></button>
<Separator style="Related" />
{/if}
<button class="preset-color black" {disabled} on:click={() => pickPreset("Black")} data-tooltip-label="Set to Black" data-tooltip-description={disabled ? "Disabled (read-only)." : ""} tabindex="0"
></button>
<Separator style="Related" />
<button class="preset-color white" {disabled} on:click={() => pickPreset("White")} data-tooltip-label="Set to White" data-tooltip-description={disabled ? "Disabled (read-only)." : ""} tabindex="0"
></button>
<Separator style="Related" />
<button class="preset-color pure" {disabled} tabindex="-1">
{#each PURE_COLORS_GRAYABLE as [preset, color, gray]}
<div
on:click={() => pickPreset(preset)}
style:--pure-color={color}
style:--pure-color-gray={gray}
data-tooltip-label={`Set to ${preset}`}
data-tooltip-description={disabled ? "Disabled (read-only)." : ""}
></div>
{/each}
</button>
{#if eyedropperSupported()}
<Separator style="Related" />
<IconButton icon="Eyedropper" size={24} {disabled} action={activateEyedropperSample} tooltipLabel="Eyedropper" tooltipDescription="Sample a pixel color from the document." />
{/if}
</LayoutRow>
<style lang="scss">
.color-presets-input {
flex: 0 0 auto;
width: 100%;
.preset-color {
border: none;
margin: 0;
padding: 0;
border-radius: 2px;
height: 24px;
flex: 1 1 100%;
&.none {
background: var(--color-none);
background-repeat: var(--color-none-repeat);
background-position: var(--color-none-position);
background-size: var(--color-none-size-24px);
background-image: var(--color-none-image-24px);
&,
& ~ .black,
& ~ .white {
width: 48px;
}
}
&.black {
background: black;
}
&.white {
background: white;
}
&.pure {
width: 24px;
font-size: 0;
overflow: hidden;
flex: 0 0 auto;
div {
display: inline-block;
width: calc(100% / 3);
height: 50%;
// For the least jarring luminance conversion, these colors are derived by placing a black layer with the "desaturate" blend mode over the colors.
// We don't use the CSS `filter: grayscale(1);` property because it produces overly dark tones for bright colors with a noticeable jump on hover.
background: var(--pure-color-gray);
transition: background-color 0.1s;
}
&:hover div {
background: var(--pure-color);
}
}
}
&.disabled {
.preset-color {
transition: opacity 0.1s;
&:hover {
opacity: 0.5;
}
}
.preset-color.pure:hover div {
background: var(--pure-color-gray);
}
}
}
</style>

View File

@@ -1,323 +1,212 @@
<script lang="ts" context="module">
export const MIN_MIDPOINT = 0.01;
export const MAX_MIDPOINT = 0.99;
</script>
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import { colorToHexOptionalAlpha, colorToRgbCSS, gradientFirstColor, gradientLastColor, gradientToLinearGradientCSS } from "/src/utility-functions/colors";
import { evaluateGradientAtPosition } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { Color, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { SpectrumInputUpdate, SpectrumMarker } from "/wrapper/pkg/graphite_wasm_wrapper";
const BUTTON_LEFT = 0;
const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: { activeMarkerIndex: number | undefined; activeMarkerIsMidpoint: boolean }; gradient: GradientStops; dragging: boolean }>();
const dispatch = createEventDispatcher<{ update: SpectrumInputUpdate; dragging: boolean }>();
export let gradient: GradientStops;
export let disabled = false;
export let trackCSS: string;
export let trackStartCSS: string;
export let trackEndCSS: string;
export let markers: SpectrumMarker[];
export let activeMarkerIndex: number | undefined = 0;
export let activeMarkerIsMidpoint = false;
// export let disabled = false;
// export let tooltipLabel: string | undefined = undefined;
// export let tooltipDescription: string | undefined = undefined;
// export let tooltipShortcut: ActionShortcut | undefined = undefined;
export let showMidpoints = true;
export let allowInsert = true;
export let allowDelete = true;
export let allowSwap = true;
export let disabled = false;
/// Reference to the marker track element so we can access its div.
let markerTrack: LayoutRow | undefined = undefined;
/// When dragging, stores the original value of the marker or midpoint being dragged, so we can restore it if the drag is cancelled.
let dragRestore: number | undefined = undefined;
/// When dragging, indicates whether this maker was inserted during the drag, so we know whether to remove it again if the drag is cancelled.
let deletionRestore: boolean | undefined = undefined;
/// When dragging, stores the previous active marker (or its midpoint) index, so we can restore active selection to that one if the drag is cancelled on a different marker.
// Reference to the marker track DOM element so we can convert pointer coordinates to a 0..1 position along the track.
let markerTrackElement: LayoutRow | undefined = undefined;
// Drag state — only TS-local; Rust owns authoritative marker data.
// Position the dragged marker (or midpoint) had at drag start, restored if the drag is cancelled.
let dragRestorePosition: number | undefined = undefined;
// True if this drag began with an insertion (so cancel must delete the inserted marker).
let dragInsertedMarker = false;
// Active marker selection at drag start, restored if the drag is cancelled.
let activeMarkerIndexRestore: number | undefined = undefined;
/// When dragging, stores whether the previously active drag item was a midpoint (matching the index kept in `activeMarkerIndexRestore`), so we can restore its active selection if cancelled.
let activeMarkerIsMidpointRestore = false;
/// When dragging a midpoint, tracks whether the midpoint has actually moved by at least a pixel, to tell between a click-then-click-and-drag (just a drag) or a double-click (reset the midpoint).
// Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag.
let midpointDragged = false;
function emit(intent: SpectrumInputUpdate) {
dispatch("update", intent);
}
function setActive(index: number | undefined, isMidpoint: boolean) {
activeMarkerIndex = index;
activeMarkerIsMidpoint = isMidpoint;
emit({ ActiveMarker: { activeMarkerIndex: index, activeMarkerIsMidpoint: isMidpoint } });
}
function pointerPosition(e: MouseEvent): number | undefined {
const rect = markerTrackElement?.div()?.getBoundingClientRect();
if (!rect) return undefined;
const ratio = (e.clientX - rect.left) / rect.width;
return Math.max(0, Math.min(1, ratio));
}
function clampToNeighbors(index: number, position: number): number {
const lower = markers[index - 1]?.position ?? 0;
const upper = markers[index + 1]?.position ?? 1;
return Math.max(lower, Math.min(upper, position));
}
function markerPointerDown(e: PointerEvent, index: number) {
if (disabled) return;
// Left-click to select and begin potentially dragging
if (e.button === BUTTON_LEFT) {
// Set restore values at this time, so later the user can cancel the drag and restore to these values
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
// Update the parent component with the newly activated marker or midpoint drag item
activeMarkerIndex = index;
activeMarkerIsMidpoint = false;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
dragRestorePosition = markers[index].position;
dragInsertedMarker = false;
setActive(index, false);
addEvents();
return;
}
// Right-click to delete
if (e.button === BUTTON_RIGHT && deletionRestore === undefined) {
deleteStopByIndex(index);
return;
if (e.button === BUTTON_RIGHT && allowDelete) {
emit({ DeleteMarker: { index } });
}
}
function markerPosition(e: MouseEvent): number | undefined {
const markerTrackRect = markerTrack?.div()?.getBoundingClientRect();
if (!markerTrackRect) return;
const ratio = (e.clientX - markerTrackRect.left) / markerTrackRect.width;
return Math.max(0, Math.min(1, ratio));
}
function midpointPointerDown(e: PointerEvent, index: number) {
if (disabled) return;
if (e.button !== BUTTON_LEFT) return;
// Since we just pressed the mouse button down, the midpoint has not been dragged by any distance
midpointDragged = false;
// Set restore values at this time, so later the user can cancel the drag and restore to these values
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
// Update the parent component with the newly activated marker or midpoint drag item
activeMarkerIndex = index;
activeMarkerIsMidpoint = true;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
dragRestorePosition = markers[index].midpoint;
setActive(index, true);
addEvents();
}
function resetMidpoint(index: number) {
function midpointDoubleClick(index: number) {
if (disabled || midpointDragged) return;
gradient.midpoint[index] = 0.5;
dispatch("gradient", gradient);
emit({ ResetMidpoint: { index } });
}
function insertStop(e: MouseEvent) {
function trackPointerDown(e: PointerEvent) {
if (disabled) return;
if (e.button !== BUTTON_LEFT) return;
if (!allowInsert) return;
// Determine the position along the gradient (0-1) based on the click position in the marker track
let position = markerPosition(e);
const position = pointerPosition(e);
if (position === undefined) return;
// Determine which index the new stop should be inserted at based on its position
let index = gradient.position.findIndex((item) => item > position);
if (index === -1) index = gradient.position.length;
// Compute the index this marker will land at after Rust inserts it (matches Rust's `insert_stop` logic).
let insertIndex = markers.findIndex((m) => m.position > position);
if (insertIndex === -1) insertIndex = markers.length;
// Determine the color of the new stop by evaluating the gradient at the position of the new stop
const color: Color = evaluateGradientAtPosition(position, new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color);
emit({ InsertMarker: { position } });
// Insert the new stop into the gradient
gradient.position.splice(index, 0, position);
// Duplicate the midpoint ratio position of the interval we're inserting into, so both new intervals have the same midpoint position ratio
gradient.midpoint.splice(index, 0, gradient.midpoint[index - 1] || 0.5);
gradient.color.splice(index, 0, color);
dispatch("gradient", gradient);
// Set restore values at this time, so later the user can cancel the drag and restore to these values
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
// Update the parent component with the newly activated marker or midpoint drag item
activeMarkerIndex = index;
dragRestorePosition = position;
dragInsertedMarker = true;
// Don't dispatch an `ActiveMarker` here — the Rust handler already updates the active marker in response to `InsertMarker` and a duplicate `ActiveMarker` would race the layout update.
activeMarkerIndex = insertIndex;
activeMarkerIsMidpoint = false;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
// Since this stop insertion can happen as part of the beginning of a drag, we set this to indicate that it should be removed again if the drag is cancelled
deletionRestore = true;
addEvents();
}
function deleteStop(e: KeyboardEvent) {
function deleteShortcut(e: KeyboardEvent) {
if (disabled) return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
if (activeMarkerIndex === undefined) return;
if (gradient.position.length <= 2 && !activeMarkerIsMidpoint) return;
// Stop dragging the marker or midpoint
stopDrag();
// Either reset the midpoint to 50% or delete the marker, based on which type is currently active
if (activeMarkerIsMidpoint) resetMidpoint(activeMarkerIndex);
else deleteStopByIndex(activeMarkerIndex);
if (activeMarkerIsMidpoint) emit({ ResetMidpoint: { index: activeMarkerIndex } });
else if (allowDelete) emit({ DeleteMarker: { index: activeMarkerIndex } });
}
function deleteStopByIndex(index: number) {
if (disabled) return;
if (gradient.position.length <= 2) return;
gradient.position.splice(index, 1);
gradient.midpoint.splice(index, 1);
gradient.color.splice(index, 1);
dispatch("gradient", gradient);
deletionRestore = undefined;
if (gradient.position.length === 0) {
activeMarkerIndex = undefined;
} else {
activeMarkerIndex = Math.max(0, Math.min(gradient.position.length - 1, index));
}
activeMarkerIsMidpoint = false;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
}
function moveMarker(e: PointerEvent, index: number) {
if (disabled) return;
// Just in case the mouseup event is lost
if (e.buttons === 0) stopDrag();
let position = markerPosition(e);
if (position === undefined) return;
if (dragRestore === undefined) dragRestore = position;
if (deletionRestore === undefined) {
deletionRestore = false;
dispatch("dragging", true);
}
setPosition(index, position, false);
}
function moveMidpoint(e: PointerEvent, index: number) {
if (disabled) return;
// Guard in case the mouseup event is lost
function moveActiveMarker(e: PointerEvent) {
if (disabled || activeMarkerIndex === undefined) return;
if (e.buttons === 0) {
stopDrag();
return;
}
let position = markerPosition(e);
let position = pointerPosition(e);
if (position === undefined) return;
if (!allowSwap) position = clampToNeighbors(activeMarkerIndex, position);
if (dragRestore === undefined) {
dragRestore = gradient.midpoint[index];
midpointDragged = true;
dispatch("dragging", true);
if (!dragInsertedMarker) dispatch("dragging", true);
emit({ MoveMarker: { index: activeMarkerIndex, position } });
}
function moveActiveMidpoint(e: PointerEvent) {
if (disabled || activeMarkerIndex === undefined) return;
if (e.buttons === 0) {
stopDrag();
return;
}
const leftStop = gradient.position[index];
const rightStop = gradient.position[index + 1];
const range = rightStop - leftStop;
const absolute = pointerPosition(e);
if (absolute === undefined) return;
const left = markers[activeMarkerIndex]?.position;
const right = markers[activeMarkerIndex + 1]?.position;
if (left === undefined || right === undefined) return;
const range = right - left;
if (range <= 0) return;
gradient.midpoint[index] = Math.max(MIN_MIDPOINT, Math.min(MAX_MIDPOINT, (position - leftStop) / range));
dispatch("gradient", gradient);
}
export function setPosition(index: number, position: number, isMidpoint: boolean) {
if (disabled) return;
const markers = toMarkers(gradient);
const active = markers[index];
if (isMidpoint) active.midpoint = position;
else active.position = position;
markers.sort((a, b) => a.position - b.position);
if (markers.indexOf(active) !== activeMarkerIndex) {
activeMarkerIndex = markers.indexOf(active);
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
}
gradient.position = markers.map((stop) => stop.position);
gradient.midpoint = markers.map((stop) => stop.midpoint);
gradient.color = markers.map((stop) => stop.color);
dispatch("gradient", gradient);
}
function toMarkers(gradient: GradientStops): { position: number; midpoint: number; color: Color }[] {
return gradient.position.map((position, i) => ({
position,
midpoint: gradient.midpoint[i],
color: gradient.color[i],
}));
}
function toMidpoints(gradient: GradientStops): number[] {
if (gradient.position.length < 2) return [];
return gradient.midpoint.slice(0, -1).map((midpoint, i) => {
const leftMarker = gradient.position[i];
const rightMarker = gradient.position[i + 1];
return leftMarker + midpoint * (rightMarker - leftMarker);
});
midpointDragged = true;
dispatch("dragging", true);
emit({ MoveMidpoint: { index: activeMarkerIndex, position: (absolute - left) / range } });
}
function abortDrag() {
if (disabled) return;
if (disabled || activeMarkerIndex === undefined) return;
if (activeMarkerIndex !== undefined) {
if (activeMarkerIsMidpoint && dragRestore !== undefined) {
gradient.midpoint[activeMarkerIndex] = dragRestore;
dispatch("gradient", gradient);
} else {
if (deletionRestore) deleteStopByIndex(activeMarkerIndex);
else if (dragRestore !== undefined) setPosition(activeMarkerIndex, dragRestore, false);
}
// Restore the dragged value, or delete the marker if it was inserted as part of this drag.
if (dragInsertedMarker) {
emit({ DeleteMarker: { index: activeMarkerIndex } });
} else if (dragRestorePosition !== undefined) {
if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: activeMarkerIndex, position: dragRestorePosition } });
else emit({ MoveMarker: { index: activeMarkerIndex, position: dragRestorePosition } });
}
activeMarkerIndex = activeMarkerIndexRestore;
activeMarkerIsMidpoint = activeMarkerIsMidpointRestore;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
setActive(activeMarkerIndexRestore, activeMarkerIsMidpointRestore);
stopDrag();
}
function stopDrag() {
if (disabled) return;
removeEvents();
dragRestore = undefined;
deletionRestore = undefined;
dragRestorePosition = undefined;
dragInsertedMarker = false;
activeMarkerIndexRestore = undefined;
activeMarkerIsMidpointRestore = false;
midpointDragged = false;
dispatch("dragging", false);
}
function onPointerMove(e: PointerEvent) {
if (disabled) return;
if (activeMarkerIsMidpoint && activeMarkerIndex !== undefined) moveMidpoint(e, activeMarkerIndex);
else if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
if (activeMarkerIsMidpoint) moveActiveMidpoint(e);
else moveActiveMarker(e);
}
function onPointerUp() {
if (disabled) return;
stopDrag();
}
function onMouseDown(e: MouseEvent) {
if (disabled) return;
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortDrag();
}
function onKeyDown(e: KeyboardEvent) {
if (disabled) return;
if (e.key === "Escape") {
const element = markerTrack?.div();
const element = markerTrackElement?.div();
if (element) preventEscapeClosingParentFloatingMenu(element);
abortDrag();
}
}
@@ -336,51 +225,36 @@
document.removeEventListener("keydown", onKeyDown);
}
// Map midpoint pairs to absolute track positions for rendering the diamond markers.
$: midpointPositions = !showMidpoints || markers.length < 2 ? [] : markers.slice(0, -1).map((marker, i) => marker.position + marker.midpoint * (markers[i + 1].position - marker.position));
onMount(() => {
document.addEventListener("keydown", deleteStop);
document.addEventListener("keydown", deleteShortcut);
});
onDestroy(() => {
removeEvents();
document.removeEventListener("keydown", deleteStop);
document.removeEventListener("keydown", deleteShortcut);
});
// Future design notes:
//
// # Backend -> Frontend
// Populate(gradient, { position, color }[], active) // The only way indexes get changed. Frontend drops marker if it's being dragged.
// UpdateGradient(gradient)
// UpdateMarkers({ index, position, color }[])
//
// # Frontend -> Backend
// SendNewActive(index)
// SendPositions({ index, position }[])
// AddMarker(position)
// RemoveMarkers(index[])
// ResetMarkerToDefault(index)
//
// We need a way to encode constraints on some markers, like locking them in place or preventing reordering
// We need a way to encode the allowability of adding new markers between certain markers, or preventing the deletion of certain markers
// We need the ability to multi-select markers and move them all at once
</script>
<LayoutCol
class="spectrum-input"
classes={{ disabled }}
styles={{
"--gradient-start": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientFirstColor(gradient)),
"--gradient-end": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientLastColor(gradient)),
"--gradient-stops": gradientToLinearGradientCSS(gradient),
"--gradient-start": trackStartCSS,
"--gradient-end": trackEndCSS,
"--gradient-stops": trackCSS,
}}
>
<LayoutRow class="gradient-strip" on:pointerdown={insertStop}></LayoutRow>
<LayoutRow class="gradient-strip" on:pointerdown={trackPointerDown}></LayoutRow>
<LayoutRow class="midpoint-track">
{#each toMidpoints(gradient) as midpoint, index}
{#each midpointPositions as midpoint, index}
<svg
class="midpoint"
class:active={index === activeMarkerIndex && activeMarkerIsMidpoint}
style:--midpoint-position={midpoint}
on:pointerdown={(e) => midpointPointerDown(e, index)}
on:dblclick={() => resetMidpoint(index)}
on:dblclick={() => midpointDoubleClick(index)}
data-gradient-midpoint
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
@@ -389,13 +263,13 @@
</svg>
{/each}
</LayoutRow>
<LayoutRow class="marker-track" bind:this={markerTrack}>
{#each toMarkers(gradient) as marker, index}
<LayoutRow class="marker-track" bind:this={markerTrackElement}>
{#each markers as marker, index}
<svg
class="marker"
class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint}
style:--marker-position={marker.position}
style:--marker-color={colorToRgbCSS(marker.color)}
style:--marker-color={marker.handleColorCSS}
on:pointerdown={(e) => markerPointerDown(e, index)}
data-gradient-marker
xmlns="http://www.w3.org/2000/svg"

View File

@@ -0,0 +1,377 @@
<script lang="ts">
import { createEventDispatcher, getContext, onDestroy } from "svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import type { TooltipStore } from "/src/stores/tooltip";
import { colorContrastingColor, colorOpaque, colorToHexNoAlpha, colorToRgbCSS, createColor, createColorFromHSVA } from "/src/utility-functions/colors";
const dispatch = createEventDispatcher<{
update: { hue: number; saturation: number; value: number; alpha: number };
startHistoryTransaction: undefined;
commitHistoryTransaction: undefined;
dragStateChange: boolean;
}>();
const tooltip = getContext<TooltipStore>("tooltip");
export let hue: number;
export let saturation: number;
export let value: number;
export let alpha: number;
export let isNone: boolean;
export let disabled = false;
// Transient drag state
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
let shiftPressed = false;
let alignedAxis: "saturation" | "value" | undefined = undefined;
let hueBeforeDrag = 0;
let saturationBeforeDrag = 0;
let valueBeforeDrag = 0;
let alphaBeforeDrag = 0;
let saturationStartOfAxisAlign: number | undefined = undefined;
let valueStartOfAxisAlign: number | undefined = undefined;
let saturationRestoreWhenShiftReleased: number | undefined = undefined;
let valueRestoreWhenShiftReleased: number | undefined = undefined;
function emitUpdate(h: number, s: number, v: number, a: number) {
dispatch("update", { hue: h, saturation: s, value: v, alpha: a });
}
function onPointerDown(e: PointerEvent) {
if (disabled) return;
const target = e.target instanceof HTMLElement ? e.target : undefined;
draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
hueBeforeDrag = hue;
saturationBeforeDrag = saturation;
valueBeforeDrag = value;
alphaBeforeDrag = alpha;
saturationStartOfAxisAlign = undefined;
valueStartOfAxisAlign = undefined;
saturationRestoreWhenShiftReleased = undefined;
valueRestoreWhenShiftReleased = undefined;
addEvents();
onPointerMove(e);
}
function onPointerMove(e: PointerEvent) {
// Just in case the mouseup event is lost
if (e.buttons === 0) removeEvents();
let nextHue = hue;
let nextSaturation = saturation;
let nextValue = value;
let nextAlpha = alpha;
if (draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
const rectangle = draggingPickerTrack.getBoundingClientRect();
nextSaturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
nextValue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
dispatch("dragStateChange", true);
if (shiftPressed) {
const locked = applyAxisLock(nextSaturation, nextValue);
nextSaturation = locked.saturation;
nextValue = locked.value;
}
} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
const rectangle = draggingPickerTrack.getBoundingClientRect();
nextHue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
dispatch("dragStateChange", true);
} else if (draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
const rectangle = draggingPickerTrack.getBoundingClientRect();
nextAlpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
dispatch("dragStateChange", true);
}
emitUpdate(nextHue, nextSaturation, nextValue, nextAlpha);
if (!e.shiftKey) {
shiftPressed = false;
alignedAxis = undefined;
} else if (!shiftPressed && draggingPickerTrack) {
shiftPressed = true;
saturationStartOfAxisAlign = saturationBeforeDrag;
valueStartOfAxisAlign = valueBeforeDrag;
}
}
function onPointerUp() {
if (draggingPickerTrack) dispatch("commitHistoryTransaction");
removeEvents();
}
function onMouseDown(e: MouseEvent) {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortDrag();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") abortDrag();
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Shift") {
shiftPressed = false;
alignedAxis = undefined;
if (saturationRestoreWhenShiftReleased !== undefined && valueRestoreWhenShiftReleased !== undefined) {
emitUpdate(hue, saturationRestoreWhenShiftReleased, valueRestoreWhenShiftReleased, alpha);
}
}
}
function addEvents() {
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
dispatch("startHistoryTransaction");
}
function removeEvents() {
draggingPickerTrack = undefined;
// The setTimeout is necessary to prevent the FloatingMenu's `escapeCloses` from becoming true immediately upon pressing the Escape key, and thus closing
setTimeout(() => dispatch("dragStateChange", false), 0);
shiftPressed = false;
alignedAxis = undefined;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
}
function applyAxisLock(s: number, v: number): { saturation: number; value: number } {
if (saturationStartOfAxisAlign === undefined || valueStartOfAxisAlign === undefined) return { saturation: s, value: v };
const deltaSaturation = s - saturationStartOfAxisAlign;
const deltaValue = v - valueStartOfAxisAlign;
saturationRestoreWhenShiftReleased = s;
valueRestoreWhenShiftReleased = v;
if (Math.abs(deltaSaturation) < Math.abs(deltaValue)) {
alignedAxis = "saturation";
return { saturation: saturationStartOfAxisAlign, value: v };
} else {
alignedAxis = "value";
return { saturation: s, value: valueStartOfAxisAlign };
}
}
function abortDrag() {
removeEvents();
emitUpdate(hueBeforeDrag, saturationBeforeDrag, valueBeforeDrag, alphaBeforeDrag);
}
function clamp(input: number, min = 0, max = 1): number {
return Math.max(min, Math.min(input, max));
}
onDestroy(() => {
removeEvents();
});
$: newColor = isNone ? undefined : createColorFromHSVA(hue, saturation, value, alpha);
$: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1);
$: opaqueColorOnly = newColor ? colorOpaque(newColor) : createColor(0, 0, 0, 1);
</script>
<LayoutRow
class="visual-color-pickers-input"
classes={{ disabled }}
styles={{
"--hue-color": colorToRgbCSS(opaqueHueColor),
"--hue-color-contrasting": colorContrastingColor(opaqueHueColor),
"--opaque-color": colorToHexNoAlpha(opaqueColorOnly),
"--opaque-color-contrasting": colorContrastingColor(opaqueColorOnly),
"--new-color-contrasting": colorContrastingColor(newColor),
}}
>
{@const hueDescription = "The shade along the spectrum of the rainbow."}
<LayoutCol
class="saturation-value-picker"
data-tooltip-label="Saturation and Value"
data-tooltip-description={`To move only along the saturation (X) or value (Y) axis, perform the shortcut shown.${disabled ? "\n\nDisabled (read-only)." : ""}`}
data-tooltip-shortcut={$tooltip.shiftClickShortcut?.shortcut ? JSON.stringify($tooltip.shiftClickShortcut.shortcut) : undefined}
on:pointerdown={onPointerDown}
data-saturation-value-picker
>
{#if alignedAxis}
<div
class="selection-circle-axis-snap-line"
style:width={alignedAxis === "value" ? "100%" : undefined}
style:height={alignedAxis === "saturation" ? "100%" : undefined}
style:top={alignedAxis === "value" ? `${(1 - value) * 100}%` : undefined}
style:left={alignedAxis === "saturation" ? `${saturation * 100}%` : undefined}
></div>
<div
class="selection-circle-axis-snap-line"
style:width={alignedAxis === "saturation" ? "100%" : undefined}
style:height={alignedAxis === "value" ? "100%" : undefined}
style:top={alignedAxis === "saturation" ? `${(1 - valueBeforeDrag) * 100}%` : undefined}
style:left={alignedAxis === "value" ? `${saturationBeforeDrag * 100}%` : undefined}
></div>
{/if}
{#if !isNone}
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`}></div>
{/if}
</LayoutCol>
<LayoutCol class="hue-picker" data-tooltip-label="Hue" data-tooltip-description={`${hueDescription}${disabled ? "\n\nDisabled (read-only)." : ""}`} on:pointerdown={onPointerDown} data-hue-picker>
{#if !isNone}
<div class="selection-needle" style:top={`${(1 - hue) * 100}%`}></div>
{/if}
</LayoutCol>
<LayoutCol
class="alpha-picker"
data-tooltip-label="Alpha"
data-tooltip-description={`The level of translucency.${disabled ? "\n\nDisabled (read-only)." : ""}`}
on:pointerdown={onPointerDown}
data-alpha-picker
>
{#if !isNone}
<div class="selection-needle" style:top={`${(1 - alpha) * 100}%`}></div>
{/if}
</LayoutCol>
</LayoutRow>
<style lang="scss">
.visual-color-pickers-input {
--picker-size: 256px;
--picker-circle-radius: 6px;
.saturation-value-picker {
width: var(--picker-size);
background-blend-mode: multiply;
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
position: relative;
}
.saturation-value-picker,
.hue-picker,
.alpha-picker {
height: var(--picker-size);
border-radius: 2px;
position: relative;
overflow: hidden;
}
.hue-picker,
.alpha-picker {
width: 24px;
margin-left: 8px;
position: relative;
}
.hue-picker {
--selection-needle-color: var(--hue-color-contrasting);
background-blend-mode: screen;
background:
// Reds
linear-gradient(to top, #ff0000ff calc(100% / 6), #ff000000 calc(200% / 6), #ff000000 calc(400% / 6), #ff0000ff calc(500% / 6)),
// Greens
linear-gradient(to top, #00ff0000 0%, #00ff00ff calc(100% / 6), #00ff00ff 50%, #00ff0000 calc(400% / 6)),
// Blues
linear-gradient(to top, #0000ff00 calc(200% / 6), #0000ffff 50%, #0000ffff calc(500% / 6), #0000ff00 100%);
}
.alpha-picker {
--selection-needle-color: var(--new-color-contrasting);
background-image: linear-gradient(to bottom, var(--opaque-color), transparent), var(--color-transparent-checkered-background);
background-size:
100% 100%,
var(--color-transparent-checkered-background-size);
background-position:
0 0,
var(--color-transparent-checkered-background-position);
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
}
.selection-circle {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
&::after {
content: "";
display: block;
position: relative;
left: calc(-1 * var(--picker-circle-radius));
top: calc(-1 * var(--picker-circle-radius));
width: calc(var(--picker-circle-radius) * 2 + 1px);
height: calc(var(--picker-circle-radius) * 2 + 1px);
border-radius: 50%;
border: 2px solid var(--opaque-color-contrasting);
background: var(--opaque-color);
box-sizing: border-box;
}
}
.selection-circle-axis-snap-line {
pointer-events: none;
position: absolute;
width: 1px;
height: 1px;
top: 0;
left: 0;
background: var(--opaque-color-contrasting);
+ .selection-circle-axis-snap-line {
opacity: 0.25;
}
}
.selection-needle {
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
height: 0;
&::before {
content: "";
position: absolute;
top: -4px;
left: 0;
border-style: solid;
border-width: 4px 0 4px 4px;
border-color: transparent transparent transparent var(--selection-needle-color);
}
&::after {
content: "";
position: absolute;
top: -4px;
right: 0;
border-style: solid;
border-width: 4px 4px 4px 0;
border-color: transparent var(--selection-needle-color) transparent transparent;
}
}
&.disabled :is(.saturation-value-picker, .hue-picker, .alpha-picker) {
transition: opacity 0.1s;
&:hover {
opacity: 0.5;
}
}
}
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
</style>

View File

@@ -0,0 +1,116 @@
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { patchLayout } from "/src/utility-functions/widgets";
import type { FillChoice, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
export type ColorPickerCallbacks = {
onColorChanged?: (value: FillChoice) => void;
onStartTransaction?: () => void;
onCommitTransaction?: () => void;
};
export type ColorPickerStoreState = {
pickersAndGradient: Layout;
details: Layout;
callbacks: ColorPickerCallbacks;
// True while the user is actively dragging one of the visual H/S/V/A pickers, so the popover knows to suppress its stray-pointer-close behavior until the drag ends.
isDragging: boolean;
};
const initialState: ColorPickerStoreState = {
pickersAndGradient: [],
details: [],
callbacks: {},
isDragging: false,
};
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
// Persist the store across HMR so subscriptions stay live.
const store: Writable<ColorPickerStoreState> = import.meta.hot?.data?.store || writable<ColorPickerStoreState>(initialState);
if (import.meta.hot) import.meta.hot.data.store = store;
const { subscribe, update } = store;
export type ColorPickerStore = {
subscribe: typeof subscribe;
setCallbacks: (callbacks: ColorPickerCallbacks) => void;
clearCallbacks: () => void;
setDragging: (dragging: boolean) => void;
};
// The Rust handler keeps a single shared layout per target, but multiple `<ColorPicker>` Svelte instances may be mounted across
// the app (one per `ColorInput`/`WorkingColorsInput`/etc.). Subscribing to the layout target from each instance is destructive,
// only the last-registered callback wins. So we maintain a single global subscription here and let each `<ColorPicker>` instance
// read from the resulting store and register its own per-open callbacks for color/transaction events.
export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore {
destroyColorPickerStore();
subscriptionsRouter = subscriptions;
subscriptions.subscribeFrontendMessage("ColorPickerColorChanged", (data) => {
update((state) => {
state.callbacks.onColorChanged?.(data.value);
return state;
});
});
subscriptions.subscribeFrontendMessage("ColorPickerStartHistoryTransaction", () => {
update((state) => {
state.callbacks.onStartTransaction?.();
return state;
});
});
subscriptions.subscribeFrontendMessage("ColorPickerCommitHistoryTransaction", () => {
update((state) => {
state.callbacks.onCommitTransaction?.();
return state;
});
});
subscriptions.subscribeLayoutUpdate("ColorPickerPickersAndGradient", (diffs) => {
update((state) => {
patchLayout(state.pickersAndGradient, diffs);
return state;
});
});
subscriptions.subscribeLayoutUpdate("ColorPickerDetails", (diffs) => {
update((state) => {
patchLayout(state.details, diffs);
return state;
});
});
return {
subscribe,
setCallbacks: (callbacks: ColorPickerCallbacks) => {
update((state) => {
state.callbacks = callbacks;
return state;
});
},
clearCallbacks: () => {
update((state) => {
state.callbacks = {};
state.isDragging = false;
return state;
});
},
setDragging: (dragging: boolean) => {
update((state) => {
state.isDragging = dragging;
return state;
});
},
};
}
export function destroyColorPickerStore() {
const subscriptions = subscriptionsRouter;
if (!subscriptions) return;
subscriptions.unsubscribeFrontendMessage("ColorPickerColorChanged");
subscriptions.unsubscribeFrontendMessage("ColorPickerStartHistoryTransaction");
subscriptions.unsubscribeFrontendMessage("ColorPickerCommitHistoryTransaction");
subscriptions.unsubscribeLayoutUpdate("ColorPickerPickersAndGradient");
subscriptions.unsubscribeLayoutUpdate("ColorPickerDetails");
}

View File

@@ -1,4 +1,3 @@
import { sampleInterpolatedGradient } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper";
// Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers
@@ -63,12 +62,6 @@ export function colorFromCSS(colorCode: string): Color | undefined {
return createColor(r / 255, g / 255, b / 255, a / 255);
}
export function colorEquals(c1: Color | undefined, c2: Color | undefined): boolean {
if (c1 === undefined && c2 === undefined) return true;
if (c1 === undefined || c2 === undefined) return false;
return Math.abs(c1.red - c2.red) < 1e-6 && Math.abs(c1.green - c2.green) < 1e-6 && Math.abs(c1.blue - c2.blue) < 1e-6 && Math.abs(c1.alpha - c2.alpha) < 1e-6;
}
export function colorToHexNoAlpha(color: Color): string {
const r = Math.round(color.red * 255)
.toString(16)
@@ -83,15 +76,6 @@ export function colorToHexNoAlpha(color: Color): string {
return `#${r}${g}${b}`;
}
export function colorToHexOptionalAlpha(color: Color): string {
const hex = colorToHexNoAlpha(color);
const a = Math.round(color.alpha * 255)
.toString(16)
.padStart(2, "0");
return a === "ff" ? hex : `${hex}${a}`;
}
export function colorToRgb255(color: Color): RGB {
return {
r: Math.round(color.red * 255),
@@ -205,23 +189,6 @@ export function isGradientStops(value: unknown): value is GradientStops {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
}
export function gradientToLinearGradientCSS(gradient: GradientStops): string {
if (gradient.position.length === 1) {
return `linear-gradient(to right, ${colorToHexOptionalAlpha(gradient.color[0])} 0%, ${colorToHexOptionalAlpha(gradient.color[0])} 100%)`;
}
const pieces = sampleInterpolatedGradient(new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color, false);
return `linear-gradient(to right, ${pieces})`;
}
export function gradientFirstColor(gradient: GradientStops): Color | undefined {
return gradient.color[0];
}
export function gradientLastColor(gradient: GradientStops): Color | undefined {
return gradient.color[gradient.color.length - 1];
}
// FILL CHOICE UTILITY FUNCTIONS
export function fillChoiceColor(value: FillChoice): Color | undefined {

View File

@@ -24,7 +24,6 @@ use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
use graph_craft::document::NodeId;
use graphene_std::graphene_hash::CacheHashWrapper;
use graphene_std::raster::color::Color;
use graphene_std::vector::GradientStops;
use serde::Serialize;
use serde_wasm_bindgen::{self, from_value};
use std::cell::RefCell;
@@ -703,6 +702,20 @@ impl EditorWrapper {
Ok(())
}
/// Initialize the Rust color picker handler with a starting value (used when the frontend `<ColorPicker>` opens).
#[wasm_bindgen(js_name = openColorPicker)]
pub fn open_color_picker(&self, initial_value: JsValue, allow_none: bool, disabled: bool) -> Result<(), JsValue> {
let initial_value = serde_wasm_bindgen::from_value(initial_value).map_err(|e| Error::new(&format!("Invalid initial picker value: {e}")))?;
self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled });
Ok(())
}
/// Tell the Rust color picker handler that the popover is closing.
#[wasm_bindgen(js_name = closeColorPicker)]
pub fn close_color_picker(&self) {
self.dispatch(ColorPickerMessage::Close);
}
/// Update the color of the currently-edited gradient stop
#[wasm_bindgen(js_name = updateGradientStopColor)]
pub fn update_gradient_stop_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
@@ -999,26 +1012,3 @@ pub fn evaluate_math_expression(expression: &str) -> Option<f64> {
};
Some(real)
}
#[wasm_bindgen(js_name = sampleInterpolatedGradient)]
pub fn sample_interpolated_gradient(position: Vec<f64>, midpoint: Vec<f64>, color: Vec<JsValue>, omit_alpha: bool) -> String {
let color = color.into_iter().filter_map(|c| serde_wasm_bindgen::from_value(c).ok()).collect();
GradientStops { position, midpoint, color }
.interpolated_samples()
.into_iter()
.map(|(position, color, _)| {
let hex = if omit_alpha { color.to_rgb_hex_srgb_from_gamma() } else { color.to_rgba_hex_srgb_from_gamma() };
let percent = ((position * 100.) * 1e2).round() / 1e2;
format!("#{hex} {percent}%")
})
.collect::<Vec<_>>()
.join(", ")
}
#[wasm_bindgen(js_name = evaluateGradientAtPosition)]
pub fn evaluate_gradient_at_position(t: f64, position: Vec<f64>, midpoint: Vec<f64>, color: Vec<JsValue>) -> JsValue {
let color = color.into_iter().filter_map(|c| serde_wasm_bindgen::from_value(c).ok()).collect();
let color = GradientStops { position, midpoint, color }.evaluate(t);
serde_wasm_bindgen::to_value(&color).unwrap()
}