Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs (#4149)

* Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs

* Code review fixes
This commit is contained in:
Keavon Chambers
2026-05-14 22:48:33 -07:00
committed by GitHub
parent 456a7c868d
commit a56746c6bf
67 changed files with 1210 additions and 941 deletions
@@ -5,14 +5,14 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
import type { ColorPickerCallbacks, ColorPickerStore } from "/src/stores/color-picker";
import type { EditorWrapper, FillChoice, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { EditorWrapper, FillChoiceUI, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>();
const dispatch = createEventDispatcher<{ colorOrGradient: FillChoiceUI; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>();
const editor = getContext<EditorWrapper>("editor");
const colorPickerStore = getContext<ColorPickerStore>("colorPicker");
export let colorOrGradient: FillChoice;
export let colorOrGradient: FillChoiceUI;
export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement
export let disabled = false;
+13 -13
View File
@@ -12,12 +12,12 @@
import type { DocumentStore } from "/src/stores/document";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { MessageBody } from "/src/subscriptions-router";
import { fillChoiceColor, createColor } from "/src/utility-functions/colors";
import { fillChoiceUIColor, createSRgba8 } from "/src/utility-functions/colors";
import { pasteFile } from "/src/utility-functions/files";
import { textInputCleanup } from "/src/utility-functions/keyboard-entry";
import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization";
import { setupViewportResizeObserver } from "/src/utility-functions/viewports";
import type { Color, EditorWrapper, MenuDirection, MouseCursorIcon } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { EditorWrapper, MenuDirection, MouseCursorIcon, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
let rulerHorizontal: RulerInput | undefined;
let rulerVertical: RulerInput | undefined;
@@ -70,7 +70,7 @@
let cursorEyedropperPreviewColorSecondary = "";
// Gradient stop color picker
let gradientStopPickerColor: Color | undefined = undefined;
let gradientStopPickerColor: SRGBA8 | undefined = undefined;
let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined;
// Canvas dimensions
@@ -223,7 +223,7 @@
mousePosition: [number, number] | undefined,
colorPrimary: string,
colorSecondary: string,
): Promise<[number, number, number] | undefined> {
): Promise<SRGBA8 | undefined> {
if (mousePosition === undefined) {
cursorEyedropper = false;
return undefined;
@@ -275,14 +275,14 @@
};
})();
const hex = [centerPixel.r, centerPixel.g, centerPixel.b].map((x) => x.toString(16).padStart(2, "0")).join("");
const rgb: [number, number, number] = [centerPixel.r / 255, centerPixel.g / 255, centerPixel.b / 255];
const sRgba8: SRGBA8 = { red: centerPixel.r, green: centerPixel.g, blue: centerPixel.b, alpha: 255 };
cursorEyedropperPreviewColorChoice = "#" + hex;
cursorEyedropperPreviewColorPrimary = colorPrimary;
cursorEyedropperPreviewColorSecondary = colorSecondary;
cursorEyedropperPreviewImageData = preview;
return rgb;
return sRgba8;
}
// Update scrollbars and rulers
@@ -477,11 +477,11 @@
const { image, mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
const imageData = image !== undefined ? new ImageData(new Uint8ClampedArray(image.data), image.width, image.height) : undefined;
const rgb = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor);
const sRgba8 = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor);
if (setColorChoice && rgb) {
if (setColorChoice === "Primary") editor.updatePrimaryColor(...rgb, 1);
if (setColorChoice === "Secondary") editor.updateSecondaryColor(...rgb, 1);
if (setColorChoice && sRgba8) {
if (setColorChoice === "Primary") editor.updatePrimaryColor(sRgba8);
if (setColorChoice === "Secondary") editor.updateSecondaryColor(sRgba8);
}
});
@@ -668,10 +668,10 @@
gradientStopPickerColor = undefined;
}
}}
colorOrGradient={{ Solid: gradientStopPickerColor || createColor(0, 0, 0, 1) }}
colorOrGradient={{ Solid: gradientStopPickerColor || createSRgba8(0, 0, 0, 255) }}
on:colorOrGradient={({ detail }) => {
const color = fillChoiceColor(detail);
if (color) editor.updateGradientStopColor(color.red, color.green, color.blue, color.alpha);
const color = fillChoiceUIColor(detail);
if (color) editor.updateGradientStopColor(color);
}}
on:startHistoryTransaction={() => editor.startGradientStopColorTransaction()}
on:commitHistoryTransaction={() => editor.commitGradientStopColorTransaction()}
@@ -26,7 +26,7 @@
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 { parseFillChoiceUI } from "/src/utility-functions/colors";
import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper";
// Extract the discriminant key names from the Widget tagged enum union (e.g. "TextButton" | "CheckboxInput" | ...)
@@ -134,7 +134,7 @@
component: ColorInput,
getProps: (props, index) => ({
...props,
value: parseFillChoice(props.value),
value: parseFillChoiceUI(props.value),
$$events: {
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
startHistoryTransaction: () => widgetValueCommit(index, props.value),
@@ -146,7 +146,7 @@
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).
// The widget dispatches `"None"` or a bare `SRGBA8`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoiceUI` 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),
},
@@ -4,12 +4,12 @@
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";
import type { SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ swap: undefined }>();
export let newColor: Color | undefined;
export let oldColor: Color | undefined;
export let newColor: SRGBA8 | undefined;
export let oldColor: SRGBA8 | undefined;
export let newColorCSS: string;
export let newColorContrasting: string;
export let oldColorCSS: string;
@@ -21,7 +21,7 @@
export let outlineAmount: number;
$: outlined = outlineAmount > 0.0001;
$: transparency = (newColor?.alpha ?? 1) < 1 || (oldColor?.alpha ?? 1) < 1;
$: transparency = (newColor?.alpha ?? 255) < 255 || (oldColor?.alpha ?? 255) < 255;
</script>
<LayoutRow
@@ -2,13 +2,13 @@
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 } from "/src/utility-functions/colors";
import type { FillChoice, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
import { contrastingOutlineFactor, fillChoiceUIColor, fillChoiceUIGradientStops } from "/src/utility-functions/colors";
import type { FillChoiceUI, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
const dispatch = createEventDispatcher<{ value: FillChoiceUI; startHistoryTransaction: undefined }>();
// Content
export let value: FillChoice;
export let value: FillChoiceUI;
export let chosenGradient: string | undefined = undefined;
export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement
@@ -29,10 +29,10 @@
$: outlineFactor = contrastingOutlineFactor(value, "--color-3-darkgray", 0.01);
$: outlined = outlineFactor > 0.0001;
$: gradientStops = fillChoiceGradientStops(value);
$: solidColor = fillChoiceColor(value);
$: gradientStops = fillChoiceUIGradientStops(value);
$: solidColor = fillChoiceUIColor(value);
$: none = value === "None";
$: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 1) : solidColor ? solidColor.alpha < 1 : false;
$: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 255) : solidColor ? solidColor.alpha < 255 : false;
</script>
<LayoutCol
@@ -3,20 +3,20 @@
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";
import { createSRgba8 } from "/src/utility-functions/colors";
import type { SRGBA8 } 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],
White: [255, 255, 255],
Red: [255, 0, 0],
Yellow: [255, 255, 0],
Green: [0, 255, 0],
Cyan: [0, 255, 255],
Blue: [0, 0, 255],
Magenta: [255, 0, 255],
};
const PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [
["Red", "#ff0000", "#4c4c4c"],
@@ -28,7 +28,7 @@
];
const dispatch = createEventDispatcher<{
preset: Color | "None";
preset: SRGBA8 | "None";
eyedropperColorCode: string;
}>();
@@ -37,7 +37,7 @@
function pickPreset(preset: PresetColor | "None") {
if (disabled) return;
dispatch("preset", preset === "None" ? "None" : createColor(...PURE_COLORS[preset], 1));
dispatch("preset", preset === "None" ? "None" : createSRgba8(...PURE_COLORS[preset], 255));
}
// 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
@@ -3,7 +3,7 @@
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";
import { sRgba8ContrastingColor, sRgba8Opaque, sRgba8ToHexNoAlpha, sRgba8ToRgbCSS, createSRgba8, createSRgba8FromHsva } from "/src/utility-functions/colors";
const dispatch = createEventDispatcher<{
update: { hue: number; saturation: number; value: number; alpha: number };
@@ -184,20 +184,20 @@
removeEvents();
});
$: newColor = isNone ? undefined : createColorFromHSVA(hue, saturation, value, alpha);
$: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1);
$: opaqueColorOnly = newColor ? colorOpaque(newColor) : createColor(0, 0, 0, 1);
$: newColor = isNone ? undefined : createSRgba8FromHsva(hue, saturation, value, alpha);
$: opaqueHueColor = createSRgba8FromHsva(hue, 1, 1, 1);
$: opaqueColorOnly = newColor ? sRgba8Opaque(newColor) : createSRgba8(0, 0, 0, 255);
</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),
"--hue-color": sRgba8ToRgbCSS(opaqueHueColor),
"--hue-color-contrasting": sRgba8ContrastingColor(opaqueHueColor),
"--opaque-color": sRgba8ToHexNoAlpha(opaqueColorOnly),
"--opaque-color-contrasting": sRgba8ContrastingColor(opaqueColorOnly),
"--new-color-contrasting": sRgba8ContrastingColor(newColor),
}}
>
{@const hueDescription = "The shade along the spectrum of the rainbow."}
@@ -3,14 +3,14 @@
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import { fillChoiceColor, colorToRgbaCSS } from "/src/utility-functions/colors";
import type { Color, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
import { fillChoiceUIColor, sRgba8ToRgbaCSS } from "/src/utility-functions/colors";
import type { SRGBA8, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
const editor = getContext<EditorWrapper>("editor");
// Content
export let primary: Color;
export let secondary: Color;
export let primary: SRGBA8;
export let secondary: SRGBA8;
let primaryOpen = false;
let secondaryOpen = false;
@@ -25,37 +25,37 @@
secondaryOpen = true;
}
function primaryColorChanged(color: Color) {
editor.updatePrimaryColor(color.red, color.green, color.blue, color.alpha);
function primaryColorChanged(color: SRGBA8) {
editor.updatePrimaryColor(color);
}
function secondaryColorChanged(color: Color) {
editor.updateSecondaryColor(color.red, color.green, color.blue, color.alpha);
function secondaryColorChanged(color: SRGBA8) {
editor.updateSecondaryColor(color);
}
</script>
<LayoutCol class="working-colors-button">
<LayoutRow class="primary swatch">
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={colorToRgbaCSS(primary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={sRgba8ToRgbaCSS(primary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
<ColorPicker
open={primaryOpen}
on:open={({ detail }) => (primaryOpen = detail)}
colorOrGradient={{ Solid: primary }}
on:colorOrGradient={({ detail }) => {
const color = fillChoiceColor(detail);
const color = fillChoiceUIColor(detail);
if (color) primaryColorChanged(color);
}}
direction="Right"
/>
</LayoutRow>
<LayoutRow class="secondary swatch">
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={colorToRgbaCSS(secondary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={sRgba8ToRgbaCSS(secondary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
<ColorPicker
open={secondaryOpen}
on:open={({ detail }) => (secondaryOpen = detail)}
colorOrGradient={{ Solid: secondary }}
on:colorOrGradient={({ detail }) => {
const color = fillChoiceColor(detail);
const color = fillChoiceUIColor(detail);
if (color) secondaryColorChanged(color);
}}
direction="Right"
+3 -3
View File
@@ -11,7 +11,7 @@
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover, updateDockingHover } from "/src/stores/panel-drag";
import type { DockingEdge } from "/src/stores/panel-drag";
import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { DockingSplitDirection, EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
const PANEL_COMPONENTS = {
Welcome,
@@ -40,7 +40,7 @@
export let emptySpaceAction: (() => void) | undefined = undefined;
export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
export let groupDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
export let splitDropAction: ((targetPanelId: string, direction: DockingEdge, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined;
export let splitDropAction: ((targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined;
let className = "";
export { className as class };
@@ -222,7 +222,7 @@
dropAction?.(panelId, crossPanelState.hoverDockingPanelId, Number.MAX_SAFE_INTEGER);
}
// Edge docking drop: create a new split adjacent to the target panel
else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge) {
else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge && crossPanelState.hoverDockingEdge !== "Center") {
splitDropAction?.(
crossPanelState.hoverDockingPanelId,
crossPanelState.hoverDockingEdge,
@@ -4,7 +4,7 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import Panel from "/src/components/window/Panel.svelte";
import type { PortfolioStore } from "/src/stores/portfolio";
import type { DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { DockingSplitDirection, DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
const MIN_PANEL_SIZE = 100;
const DOUBLE_CLICK_MILLISECONDS = 500;
@@ -85,7 +85,7 @@
sizeOverrides = sizeOverrides;
const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(splitPath, allSizes);
editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
return;
}
@@ -140,7 +140,7 @@
// Persist the resized sizes to the backend
if ("Split" in subdivision) {
const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(splitPath, allSizes);
editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
}
};
@@ -179,7 +179,7 @@
editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
}
function splitDrop(targetPanelId: string, direction: string, tabs: string[], activeTabIndex: number) {
function splitDrop(targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) {
editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex);
}
+4 -4
View File
@@ -2,10 +2,10 @@ 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";
import type { FillChoiceUI, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
export type ColorPickerCallbacks = {
onColorChanged?: (value: FillChoice) => void;
onColorChanged?: (value: FillChoiceUI) => void;
onStartTransaction?: () => void;
onCommitTransaction?: () => void;
};
@@ -40,9 +40,9 @@ export type ColorPickerStore = {
setDragging: (dragging: boolean) => void;
};
// The Rust handler keeps a single shared layout per target, but multiple `<ColorPicker>` Svelte instances may be mounted across
// 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
// 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();
+2 -2
View File
@@ -1,8 +1,8 @@
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { DockingSplitDirection, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
export type DockingEdge = "Left" | "Right" | "Top" | "Bottom" | "Center";
export type DockingEdge = DockingSplitDirection | "Center";
export type PanelDragState = {
active: boolean;
+54 -57
View File
@@ -1,4 +1,4 @@
import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { FillChoiceUI, GradientStopsUI, SRGBA8 } 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
export type HSV = { h: number; s: number; v: number };
@@ -6,26 +6,33 @@ export type RGB = { r: number; g: number; b: number };
// COLOR FACTORY FUNCTIONS
export function createColor(red: number, green: number, blue: number, alpha: number): Color {
export function createSRgba8(red: number, green: number, blue: number, alpha: number): SRGBA8 {
return { red, green, blue, alpha };
}
export function createColorFromHSVA(h: number, s: number, v: number, a: number): Color {
// Build an `SRGBA8` from HSVA components on the 0..1 range.
export function createSRgba8FromHsva(h: number, s: number, v: number, a: number): SRGBA8 {
const convert = (n: number): number => {
const k = (n + h * 6) % 6;
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
};
return { red: convert(5), green: convert(3), blue: convert(1), alpha: a };
return {
red: Math.round(convert(5) * 255),
green: Math.round(convert(3) * 255),
blue: Math.round(convert(1) * 255),
alpha: Math.round(a * 255),
};
}
// COLOR UTILITY FUNCTIONS
export function isColor(value: unknown): value is Color {
export function isSRgba8(value: unknown): value is SRGBA8 {
return typeof value === "object" && value !== null && "red" in value;
}
export function colorFromCSS(colorCode: string): Color | undefined {
// Parse a CSS color string into an `SRGBA8`. Uses a canvas to delegate parsing to the browser.
export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined {
// Allow single-digit hex value inputs
let colorValue = colorCode.trim();
if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) {
@@ -52,52 +59,40 @@ export function colorFromCSS(colorCode: string): Color | undefined {
// Invalid color
if (comparisonA !== comparisonB) {
// If this color code didn't start with a #, add it and try again
if (colorValue.trim().charAt(0) !== "#") return colorFromCSS(`#${colorValue.trim()}`);
if (colorValue.trim().charAt(0) !== "#") return sRgba8FromCSS(`#${colorValue.trim()}`);
return undefined;
}
context.fillRect(0, 0, 1, 1);
const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data];
return createColor(r / 255, g / 255, b / 255, a / 255);
return createSRgba8(r, g, b, a);
}
export function colorToHexNoAlpha(color: Color): string {
const r = Math.round(color.red * 255)
.toString(16)
.padStart(2, "0");
const g = Math.round(color.green * 255)
.toString(16)
.padStart(2, "0");
const b = Math.round(color.blue * 255)
.toString(16)
.padStart(2, "0");
export function sRgba8ToHexNoAlpha(color: SRGBA8): string {
const r = color.red.toString(16).padStart(2, "0");
const g = color.green.toString(16).padStart(2, "0");
const b = color.blue.toString(16).padStart(2, "0");
return `#${r}${g}${b}`;
}
export function colorToRgb255(color: Color): RGB {
return {
r: Math.round(color.red * 255),
g: Math.round(color.green * 255),
b: Math.round(color.blue * 255),
};
export function sRgba8ToRgb255(color: SRGBA8): RGB {
return { r: color.red, g: color.green, b: color.blue };
}
export function colorToRgbCSS(color: Color): string {
const rgb = colorToRgb255(color);
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
export function sRgba8ToRgbCSS(color: SRGBA8): string {
return `rgb(${color.red}, ${color.green}, ${color.blue})`;
}
export function colorToRgbaCSS(color: Color): string {
const rgb = colorToRgb255(color);
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${color.alpha})`;
export function sRgba8ToRgbaCSS(color: SRGBA8): string {
return `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha / 255})`;
}
export function colorToHSV(color: Color): HSV {
const { red: r, green: g, blue: b } = color;
export function sRgba8ToHSV(color: SRGBA8): HSV {
const r = color.red / 255;
const g = color.green / 255;
const b = color.blue / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
@@ -126,15 +121,17 @@ export function colorToHSV(color: Color): HSV {
return { h, s, v };
}
export function colorOpaque(color: Color): Color {
return createColor(color.red, color.green, color.blue, 1);
export function sRgba8Opaque(color: SRGBA8): SRGBA8 {
return createSRgba8(color.red, color.green, color.blue, 255);
}
export function colorLuminance(color: Color): number {
// WCAG-style relative luminance computed from an `SRGBA8` (alpha composited over white).
export function sRgba8Luminance(color: SRGBA8): number {
const a = color.alpha / 255;
// Convert alpha into white
const r = color.red * color.alpha + (1 - color.alpha);
const g = color.green * color.alpha + (1 - color.alpha);
const b = color.blue * color.alpha + (1 - color.alpha);
const r = (color.red / 255) * a + (1 - a);
const g = (color.green / 255) * a + (1 - a);
const b = (color.blue / 255) * a + (1 - a);
// https://stackoverflow.com/a/3943023/775283
@@ -145,32 +142,32 @@ export function colorLuminance(color: Color): number {
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
}
export function colorContrastingColor(color: Color | undefined): "black" | "white" {
export function sRgba8ContrastingColor(color: SRGBA8 | undefined): "black" | "white" {
if (!color) return "black";
const luminance = colorLuminance(color);
const luminance = sRgba8Luminance(color);
return luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
}
export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number {
export function contrastingOutlineFactor(value: FillChoiceUI, proximityColor: string | [string, string], proximityRange: number): number {
const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor];
const [range1, range2] = pair.map((color) => colorFromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
const [range1, range2] = pair.map((color) => sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
const contrast = (color: Color | undefined): number => {
const contrast = (color: SRGBA8 | undefined): number => {
if (!color) return 0;
const lum = colorLuminance(color);
let rangeLuminance1 = range1 ? colorLuminance(range1) : 0;
let rangeLuminance2 = range2 ? colorLuminance(range2) : 0;
const lum = sRgba8Luminance(color);
let rangeLuminance1 = range1 ? sRgba8Luminance(range1) : 0;
let rangeLuminance2 = range2 ? sRgba8Luminance(range2) : 0;
[rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)];
const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2);
return (1 - Math.min(distance / proximityRange, 1)) * (1 - colorToHSV(color).s);
return (1 - Math.min(distance / proximityRange, 1)) * (1 - sRgba8ToHSV(color).s);
};
const gradientStops = fillChoiceGradientStops(value);
const gradientStops = fillChoiceUIGradientStops(value);
if (gradientStops) {
if (gradientStops.color.length === 0) return 0;
@@ -180,30 +177,30 @@ export function contrastingOutlineFactor(value: FillChoice, proximityColor: stri
return Math.min(first, last);
}
return contrast(fillChoiceColor(value));
return contrast(fillChoiceUIColor(value));
}
// GRADIENT UTILITY FUNCTIONS
export function isGradientStops(value: unknown): value is GradientStops {
export function isGradientStopsUI(value: unknown): value is GradientStopsUI {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
}
// FILL CHOICE UTILITY FUNCTIONS
export function fillChoiceColor(value: FillChoice): Color | undefined {
export function fillChoiceUIColor(value: FillChoiceUI): SRGBA8 | undefined {
if (typeof value === "object" && "Solid" in value) return value.Solid;
return undefined;
}
export function fillChoiceGradientStops(value: FillChoice): GradientStops | undefined {
export function fillChoiceUIGradientStops(value: FillChoiceUI): GradientStopsUI | undefined {
if (typeof value === "object" && "Gradient" in value) return value.Gradient;
return undefined;
}
export function parseFillChoice(value: unknown): FillChoice {
export function parseFillChoiceUI(value: unknown): FillChoiceUI {
if (value === "None" || value === undefined || value === null) return "None";
if (typeof value === "object" && value !== null && "Solid" in value && isColor(value.Solid)) return { Solid: value.Solid };
if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStops(value.Gradient)) return { Gradient: value.Gradient };
if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid };
if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStopsUI(value.Gradient)) return { Gradient: value.Gradient };
return "None";
}
+1
View File
@@ -25,6 +25,7 @@ editor = { path = "../../editor", package = "graphite-editor", features = ["gpu"
graphene-std = { workspace = true }
# Workspace dependencies
bytemuck = { workspace = true }
graph-craft = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
+43 -83
View File
@@ -16,14 +16,17 @@ use editor::consts::FILE_EXTENSION;
use editor::messages::clipboard::utility_types::ClipboardContentRaw;
use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
use editor::messages::layout::utility_types::layout_widget::LayoutTarget;
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCatalog, FontCatalogFamily, PanelGroupId, PanelType};
use editor::messages::prelude::*;
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
use graph_craft::document::NodeId;
use graphene_std::color::SRGBA8;
use graphene_std::graphene_hash::CacheHashWrapper;
use graphene_std::raster::color::Color;
use graphene_std::vector::style::{FillChoice, FillChoiceUI};
use serde::Serialize;
use serde_wasm_bindgen::{self, from_value};
use std::cell::RefCell;
@@ -319,20 +322,20 @@ impl EditorWrapper {
/// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that)
#[wasm_bindgen(js_name = widgetValueUpdate)]
pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
pub fn widget_value_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)
}
/// Commit the value of a given UI widget to the history
#[wasm_bindgen(js_name = widgetValueCommit)]
pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> {
pub fn widget_value_commit(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> {
self.widget_value_commit_helper(layout_target, widget_id, value)
}
/// Update the value of a given UI widget, and commit it to the history
#[wasm_bindgen(js_name = widgetValueCommitAndUpdate)]
pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
self.widget_value_commit_helper(layout_target.clone(), widget_id, value.clone())?;
pub fn widget_value_commit_and_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
self.widget_value_commit_helper(layout_target, widget_id, value.clone())?;
self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)?;
// Close out a transaction that the widget's `on_commit` opened (if any), so a single click on widgets like the
// NumberInput's increment buttons collapses into one history step instead of leaving the transaction in `Modified`
@@ -346,34 +349,24 @@ impl EditorWrapper {
self.dispatch(DocumentMessage::EndTransaction);
}
pub fn widget_value_update_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
pub fn widget_value_update_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
let widget_id = WidgetId(widget_id);
match (from_value(layout_target), from_value(value)) {
(Ok(layout_target), Ok(value)) => {
let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value };
self.dispatch(message);
if resend_widget {
let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id };
self.dispatch(resend_message);
}
Ok(())
}
(target, val) => Err(Error::new(&format!("Could not update UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()),
let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not update UI: {e}")))?;
let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value };
self.dispatch(message);
if resend_widget {
let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id };
self.dispatch(resend_message);
}
Ok(())
}
pub fn widget_value_commit_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> {
pub fn widget_value_commit_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> {
let widget_id = WidgetId(widget_id);
match (from_value(layout_target), from_value(value)) {
(Ok(layout_target), Ok(value)) => {
let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value };
self.dispatch(message);
Ok(())
}
(target, val) => Err(Error::new(&format!("Could not commit UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()),
}
let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not commit UI: {e}")))?;
let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value };
self.dispatch(message);
Ok(())
}
#[wasm_bindgen(js_name = loadPreferences)]
@@ -389,14 +382,8 @@ impl EditorWrapper {
}
#[wasm_bindgen(js_name = loadPersistedState)]
pub fn load_persisted_state(&self, state: JsValue) {
let Ok(state) = serde_wasm_bindgen::from_value(state) else {
log::error!("Failed to deserialize persisted state");
return;
};
let message = PersistentStateMessage::LoadState { state };
self.dispatch(message);
pub fn load_persisted_state(&self, state: editor::messages::frontend::utility_types::PersistedState) {
self.dispatch(PersistentStateMessage::LoadState { state });
}
#[wasm_bindgen(js_name = loadDocumentContent)]
@@ -494,9 +481,7 @@ impl EditorWrapper {
}
#[wasm_bindgen(js_name = splitPanelGroup)]
pub fn split_panel_group(&self, target_group: u64, direction: String, tabs: JsValue, active_tab_index: usize) {
let direction: DockingSplitDirection = serde_wasm_bindgen::from_value(JsValue::from_str(&direction)).unwrap();
let tabs: Vec<PanelType> = serde_wasm_bindgen::from_value(tabs).unwrap();
pub fn split_panel_group(&self, target_group: u64, direction: DockingSplitDirection, tabs: Vec<PanelType>, active_tab_index: usize) {
let message = PortfolioMessage::SplitPanelGroup {
target_group: PanelGroupId(target_group),
direction,
@@ -507,9 +492,8 @@ impl EditorWrapper {
}
#[wasm_bindgen(js_name = setPanelGroupSizes)]
pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) {
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
let sizes: Vec<f64> = serde_wasm_bindgen::from_value(sizes).unwrap();
pub fn set_panel_group_sizes(&self, split_path: Vec<u32>, sizes: Vec<f64>) {
let split_path = split_path.into_iter().map(|i| i as usize).collect();
let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes };
self.dispatch(message);
}
@@ -645,13 +629,8 @@ impl EditorWrapper {
/// The font catalog has been loaded
#[wasm_bindgen(js_name = onFontCatalogLoad)]
pub fn on_font_catalog_load(&self, catalog: JsValue) -> Result<(), JsValue> {
// Deserializing from TS type: `{ name: string; styles: { weight: number, italic: boolean, url: string }[] }[]`
let families = serde_wasm_bindgen::from_value::<Vec<FontCatalogFamily>>(catalog)?;
let message = PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(families) };
self.dispatch(message);
Ok(())
pub fn on_font_catalog_load(&self, catalog: Vec<FontCatalogFamily>) {
self.dispatch(PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(catalog) });
}
/// A font has been downloaded
@@ -679,44 +658,29 @@ impl EditorWrapper {
Ok(())
}
/// Update primary color with values on a scale from 0 to 1.
/// Update primary color from sRGB bytes (the wire format at the JS boundary).
#[wasm_bindgen(js_name = updatePrimaryColor)]
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
let Some(primary_color) = Color::from_rgbaf32(red, green, blue, alpha) else {
return Err(Error::new("Invalid color").into());
};
let message = ToolMessage::SelectWorkingColor {
color: primary_color.to_linear_srgb(),
pub fn update_primary_color(&self, color: SRGBA8) {
self.dispatch(ToolMessage::SelectWorkingColor {
color: Color::from(color),
primary: true,
};
self.dispatch(message);
Ok(())
});
}
/// Update secondary color with values on a scale from 0 to 1.
/// Update secondary color from sRGB bytes (the wire format at the JS boundary).
#[wasm_bindgen(js_name = updateSecondaryColor)]
pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
let Some(secondary_color) = Color::from_rgbaf32(red, green, blue, alpha) else {
return Err(Error::new("Invalid color").into());
};
let message = ToolMessage::SelectWorkingColor {
color: secondary_color.to_linear_srgb(),
pub fn update_secondary_color(&self, color: SRGBA8) {
self.dispatch(ToolMessage::SelectWorkingColor {
color: Color::from(color),
primary: false,
};
self.dispatch(message);
Ok(())
});
}
/// Initialize the Rust color picker handler with a starting value (used when the frontend `<ColorPicker>` opens).
/// 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}")))?;
pub fn open_color_picker(&self, initial_value: FillChoiceUI, allow_none: bool, disabled: bool) {
let initial_value = FillChoice::from(&initial_value);
self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled });
Ok(())
}
/// Tell the Rust color picker handler that the popover is closing.
@@ -725,14 +689,10 @@ impl EditorWrapper {
self.dispatch(ColorPickerMessage::Close);
}
/// Update the color of the currently-edited gradient stop
/// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary).
#[wasm_bindgen(js_name = updateGradientStopColor)]
pub fn update_gradient_stop_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
let Some(color) = Color::from_rgbaf32(red, green, blue, alpha) else {
return Err(Error::new("Invalid color").into());
};
self.dispatch(GradientToolMessage::UpdateStopColor { color: color.to_linear_srgb() });
Ok(())
pub fn update_gradient_stop_color(&self, color: SRGBA8) {
self.dispatch(GradientToolMessage::UpdateStopColor { color: Color::from(color) });
}
/// Start a new undo transaction for gradient stop color editing
+5 -4
View File
@@ -6,8 +6,8 @@ use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER};
use editor::application::Editor;
use editor::messages::input_mapper::utility_types::input_keyboard::Key;
use editor::messages::prelude::*;
use graphene_std::color::SRGBA8;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use js_sys::{Object, Reflect};
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -119,7 +119,7 @@ pub(crate) fn auto_save_all_documents() {
});
}
pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<SRGBA8>)]) {
let window = match window() {
Some(window) => window,
None => {
@@ -167,8 +167,9 @@ pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)])
.expect("2d context was not found")
.dyn_into::<CanvasRenderingContext2d>()
.expect("Failed to cast context to CanvasRenderingContext2d");
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
// `SRGBA8` is `#[repr(C)]` of four `u8`s, so the data buffer is already the byte format the canvas expects
let u8_data: &[u8] = bytemuck::cast_slice(&image.data);
let clamped_u8_data = wasm_bindgen::Clamped(u8_data);
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
Ok(image_data_obj) => {
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {