Refactor messages.ts by removing class-transformer and JS classes (#3858)

* Fix gamma correction with HTML-based editable Text tool text

* Migrate simple, undecorated classes to types

* Remove TupleToVec2 transformation

* Remove @Transform from tooltips

* Cleanup: replace value.toString() with String(value) everywhere

* Convert documentId from string to bigint

* Migrate the rest of the easy @Transform/@Type decorations

* Migrate FillChoice

* Migrate WidgetDiffUpdate

* Migrate WidgetInstance

* Migrate away from classes that extend WidgetProps

* Remove class-transformer and all classes in messages.ts

* Migrate UI layout passing

* Remove dead code

* Remove unnecessary export and readonly prefixes

* Remove HSVA type

* Break out Color, Gradient, and FillChoice functions into a utility-functions file

* Move widget helper functions from messages.ts into a new utility-functions file; restructure type imports

* Reduce internal type defs

* Rename JsMessage to FrontendMessage

* Code review fixes

* Fix other usages

* Tidying up
This commit is contained in:
Keavon Chambers
2026-03-05 01:43:21 -08:00
committed by GitHub
parent f00a15a4c9
commit 8a1dfb9d8f
64 changed files with 1436 additions and 2093 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { type Editor as GraphiteEditor, initWasm, createEditor } from "@graphite/editor";
import { initWasm, createEditor } from "@graphite/editor";
import type { Editor as GraphiteEditor } from "@graphite/editor";
import Editor from "@graphite/components/Editor.svelte";
+1 -1
View File
@@ -38,7 +38,7 @@ Defines the message formats and data types received from the backend. Since Rust
## Subscription router: `subscription-router.ts`
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeJsMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleJsMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`.
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeFrontendMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleFrontendMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`.
## Svelte app entry point: `App.svelte`
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy, setContext } from "svelte";
import { type Editor } from "@graphite/editor";
import type { Editor } from "@graphite/editor";
import { createClipboardManager } from "@graphite/io-managers/clipboard";
import { createHyperlinkManager } from "@graphite/io-managers/hyperlink";
import { createInputManager } from "@graphite/io-managers/input";
@@ -1,9 +1,10 @@
<script lang="ts">
import { getContext, onDestroy, createEventDispatcher, tick } from "svelte";
import type { HSV, RGB, FillChoice, MenuDirection } from "@graphite/messages";
import type { FillChoice, MenuDirection } from "@graphite/messages";
import type { Color } from "@graphite/messages";
import type { TooltipState } from "@graphite/state-providers/tooltip";
import {
type Color,
contrastingOutlineFactor,
isColor,
isGradient,
@@ -12,7 +13,7 @@
createColorFromHSVA,
colorFromCSS,
colorToRgb255,
colorToHSVA,
colorToHSV,
colorToHexOptionalAlpha,
colorToHexNoAlpha,
colorToRgbCSS,
@@ -20,8 +21,8 @@
colorOpaque,
colorEquals,
gradientFirstColor,
} from "@graphite/messages";
import type { TooltipState } from "@graphite/state-providers/tooltip";
} from "@graphite/utility-functions/colors";
import type { HSV, RGB } from "@graphite/utility-functions/colors";
import { clamp } from "@graphite/utility-functions/math";
import { isDesktop } from "@graphite/utility-functions/platform";
@@ -70,8 +71,8 @@
export let open: boolean;
const colorForHSVA = isColor(colorOrGradient) ? colorOrGradient : gradientFirstColor(colorOrGradient);
const hsvaOrNone = colorForHSVA ? colorToHSVA(colorForHSVA) : undefined;
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
const hsvOrNone = colorForHSVA ? colorToHSV(colorForHSVA) : undefined;
const hsv = hsvOrNone || { h: 0, s: 0, v: 0 };
// Gradient color stops
$: gradient = isGradient(colorOrGradient) ? colorOrGradient : undefined;
@@ -81,17 +82,17 @@
// Currently viewed color
$: color = isColor(colorOrGradient) ? colorOrGradient : selectedGradientColor;
// New color components
let hue = hsva.h;
let saturation = hsva.s;
let value = hsva.v;
let alpha = hsva.a;
let isNone = hsvaOrNone === undefined;
let hue = hsv.h;
let saturation = hsv.s;
let value = hsv.v;
let alpha = colorForHSVA ? colorForHSVA.alpha : 1;
let isNone = hsvOrNone === undefined;
// Old color components
let oldHue = hsva.h;
let oldSaturation = hsva.s;
let oldValue = hsva.v;
let oldAlpha = hsva.a;
let oldIsNone = hsvaOrNone === undefined;
let oldHue = hsv.h;
let oldSaturation = hsv.s;
let oldValue = hsv.v;
let oldAlpha = colorForHSVA ? colorForHSVA.alpha : 1;
let oldIsNone = hsvOrNone === undefined;
// Transient state
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
let strayCloses = true;
@@ -114,8 +115,8 @@
$: watchOpen(open);
$: watchColor(color);
$: oldColor = generateColor(oldHue, oldSaturation, oldValue, oldAlpha, oldIsNone);
$: newColor = generateColor(hue, saturation, value, alpha, isNone);
$: oldColor = oldIsNone ? createNoneColor() : createColorFromHSVA(oldHue, oldSaturation, oldValue, oldAlpha);
$: newColor = isNone ? createNoneColor() : createColorFromHSVA(hue, saturation, value, alpha);
$: rgbChannels = Object.entries(colorToRgb255(newColor) || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
$: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
$: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1);
@@ -123,11 +124,6 @@
$: outlined = outlineFactor > 0.0001;
$: transparency = newColor.alpha < 1 || oldColor.alpha < 1;
function generateColor(h: number, s: number, v: number, a: number, none: boolean) {
if (none) return createNoneColor();
return createColorFromHSVA(h, s, v, a);
}
async function watchOpen(open: boolean) {
if (open) {
setTimeout(() => hexCodeInputWidget?.focus(), 0);
@@ -138,9 +134,9 @@
}
function watchColor(color: Color) {
const hsva = colorToHSVA(color);
const hsv = colorToHSV(color);
if (hsva === undefined) {
if (hsv === undefined) {
setNewHSVA(0, 0, 0, 1, true);
return;
}
@@ -149,14 +145,14 @@
// - ...jump the user's hue from 360° (top) to the equivalent 0° (bottom)
// - ...reset the hue to 0° if the color is fully desaturated, where all hues are equivalent
// - ...reset the hue to 0° if the color's value is black, where all hues are equivalent
if (!(hsva.h === 0 && hue === 1) && hsva.s > 0 && hsva.v > 0) hue = hsva.h;
if (!(hsv.h === 0 && hue === 1) && hsv.s > 0 && hsv.v > 0) hue = hsv.h;
// Update the saturation, but only if it is necessary so we don't:
// - ...reset the saturation to the left if the color's value is black along the bottom edge, where all saturations are equivalent
if (hsva.v !== 0) saturation = hsva.s;
if (hsv.v !== 0) saturation = hsv.s;
// Update the value
value = hsva.v;
value = hsv.v;
// Update the alpha
alpha = hsva.a;
alpha = color.alpha;
// Update the status of this not being a color
isNone = false;
}
@@ -375,9 +371,10 @@
setColor(createNoneColor());
} else {
const presetColor = createColor(...PURE_COLORS[preset], 1);
const hsva = colorToHSVA(presetColor) || { h: 0, s: 0, v: 0, a: 0 };
const hsv = colorToHSV(presetColor);
if (!hsv) return;
setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false);
setNewHSVA(hsv.h, hsv.s, hsv.v, presetColor.alpha, false);
setColor(presetColor);
}
}
@@ -425,13 +422,13 @@
activeIndexIsMidpoint = activeMarkerIsMidpoint;
const color = activeMarkerIndex === undefined ? undefined : gradient?.color[activeMarkerIndex];
const hsva = color ? colorToHSVA(color) : undefined;
if (!color || !hsva) return;
const hsv = color ? colorToHSV(color) : undefined;
if (!color || !hsv) return;
setColor(color);
setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, color.none);
setOldHSVA(hsva.h, hsva.s, hsva.v, hsva.a, color.none);
setNewHSVA(hsv.h, hsv.s, hsv.v, color.alpha, color.none);
setOldHSVA(hsv.h, hsv.s, hsv.v, color.alpha, color.none);
}
export function div(): HTMLDivElement | undefined {
+4 -3
View File
@@ -2,7 +2,8 @@
import { getContext, onMount, onDestroy } from "svelte";
import type { Editor } from "@graphite/editor";
import { patchLayout, UpdateDataPanelLayout, type Layout } from "@graphite/messages";
import type { Layout } from "@graphite/messages";
import { patchLayout } from "@graphite/utility-functions/widgets";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
@@ -12,14 +13,14 @@
let dataPanelLayout: Layout = [];
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateDataPanelLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("DataPanel", (data) => {
patchLayout(dataPanelLayout, data);
dataPanelLayout = dataPanelLayout;
});
});
onDestroy(() => {
editor.subscriptions.unsubscribeJsMessage(UpdateDataPanelLayout);
editor.subscriptions.unsubscribeLayoutUpdate("DataPanel");
});
</script>
+53 -54
View File
@@ -2,33 +2,15 @@
import { getContext, onMount, onDestroy, tick } from "svelte";
import type { Editor } from "@graphite/editor";
import {
type MenuDirection,
type MouseCursorIcon,
type XY,
type Color,
isColor,
createColor,
colorToHexOptionalAlpha,
DisplayEditableTextbox,
DisplayEditableTextboxUpdateFontData,
DisplayEditableTextboxTransform,
DisplayRemoveEditableTextbox,
TriggerTextCommit,
UpdateDocumentArtwork,
UpdateDocumentRulers,
UpdateDocumentScrollbars,
UpdateEyedropperSamplingState,
UpdateGradientStopColorPickerPosition,
UpdateMouseCursor,
isWidgetSpanRow,
} from "@graphite/messages";
import type { Color, FrontendMessages, MenuDirection } from "@graphite/messages";
import type { AppWindowState } from "@graphite/state-providers/app-window";
import type { DocumentState } from "@graphite/state-providers/document";
import { isColor, createColor } from "@graphite/utility-functions/colors";
import { pasteFile } from "@graphite/utility-functions/files";
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
import { rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
import { setupViewportResizeObserver, cleanupViewportResizeObserver } from "@graphite/utility-functions/viewports";
import { isWidgetSpanRow } from "@graphite/utility-functions/widgets";
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte";
@@ -39,6 +21,8 @@
import ScrollbarInput from "@graphite/components/widgets/inputs/ScrollbarInput.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
type DisplayEditableTextbox = FrontendMessages["DisplayEditableTextbox"];
let rulerHorizontal: RulerInput | undefined;
let rulerVertical: RulerInput | undefined;
let viewport: HTMLDivElement | undefined;
@@ -54,12 +38,12 @@
let textInputMatrix: number[];
// Scrollbars
let scrollbarPos: XY = { x: 0.5, y: 0.5 };
let scrollbarSize: XY = { x: 0.5, y: 0.5 };
let scrollbarMultiplier: XY = { x: 0, y: 0 };
let scrollbarPos = { x: 0.5, y: 0.5 };
let scrollbarSize = { x: 0.5, y: 0.5 };
let scrollbarMultiplier = { x: 0, y: 0 };
// Rulers
let rulerOrigin: XY = { x: 0, y: 0 };
let rulerOrigin = { x: 0, y: 0 };
let rulerSpacing = 100;
let rulerInterval = 100;
let rulersVisible = true;
@@ -227,7 +211,7 @@
export async function updateEyedropperSamplingState(
// `image` is currently only used for Vello renders
image: ImageData | undefined,
mousePosition: XY | undefined,
mousePosition: [number, number] | undefined,
colorPrimary: string,
colorSecondary: string,
): Promise<[number, number, number] | undefined> {
@@ -239,8 +223,8 @@
if (canvasWidth === undefined || canvasHeight === undefined) return undefined;
cursorLeft = mousePosition.x;
cursorTop = mousePosition.y;
cursorLeft = mousePosition[0];
cursorTop = mousePosition[1];
let preview = image;
if (!preview) {
@@ -262,8 +246,8 @@
if (!rasterizedContext) return undefined;
preview = rasterizedContext.getImageData(
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
mousePosition[0] * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
mousePosition[1] * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
ZOOM_WINDOW_DIMENSIONS,
ZOOM_WINDOW_DIMENSIONS,
);
@@ -293,25 +277,41 @@
}
// Update scrollbars and rulers
export function updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
scrollbarPos = position;
scrollbarSize = size;
scrollbarMultiplier = multiplier;
export function updateDocumentScrollbars(position: [number, number], size: [number, number], multiplier: [number, number]) {
scrollbarPos = { x: position[0], y: position[1] };
scrollbarSize = { x: size[0], y: size[1] };
scrollbarMultiplier = { x: multiplier[0], y: multiplier[1] };
}
export function updateDocumentRulers(origin: XY, spacing: number, interval: number, visible: boolean) {
rulerOrigin = origin;
export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean) {
rulerOrigin = { x: origin[0], y: origin[1] };
rulerSpacing = spacing;
rulerInterval = interval;
rulersVisible = visible;
}
// Update mouse cursor icon
export function updateMouseCursor(cursor: MouseCursorIcon) {
let cursorString: string = cursor;
export function updateMouseCursor(cursor: string) {
const mouseCursorIconCSSNames: Record<string, string> = {
Default: "default",
Alias: "alias",
None: "none",
ZoomIn: "zoom-in",
ZoomOut: "zoom-out",
Grabbing: "grabbing",
Crosshair: "crosshair",
Text: "text",
Move: "move",
NSResize: "ns-resize",
EWResize: "ew-resize",
NESWResize: "nesw-resize",
NWSEResize: "nwse-resize",
Rotate: "custom-rotate",
};
let cursorString = mouseCursorIconCSSNames[cursor] || mouseCursorIconCSSNames["Alias"];
// This isn't very clean but it's good enough for now until we need more icons, then we can build something more robust (consider blob URLs)
if (cursor === "custom-rotate") {
if (cursor === "Rotate") {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<path fill="none" stroke="black" stroke-width="2" d="M10,15.8c-3.2,0-5.8-2.6-5.8-5.8S6.8,4.2,10,4.2c0.999,0,1.999,0.273,2.877,0.771L11.7,7h5.8l-2.9-5l-1.013,1.746C12.5,3.125,11.271,2.8,10,2.8C6,2.8,2.8,6,2.8,10S6,17.2,10,17.2s7.2-3.2,7.2-7.2h-1.4C15.8,13.2,13.2,15.8,10,15.8z" />
@@ -363,7 +363,7 @@
textInput.style.height = height;
textInput.style.lineHeight = `${data.lineHeightRatio}`;
textInput.style.fontSize = `${data.fontSize}px`;
textInput.style.color = colorToHexOptionalAlpha(data.color) || "transparent";
textInput.style.color = data.color;
textInput.style.textAlign = data.align;
textInput.oninput = () => {
@@ -418,7 +418,7 @@
// which provides pixel-perfect physical dimensions via devicePixelContentBoxSize
}
function gradientStopPickerDirection(position: XY | undefined, viewport: HTMLDivElement | undefined): MenuDirection {
function gradientStopPickerDirection(position: { x: number; y: number } | undefined, viewport: HTMLDivElement | undefined): MenuDirection {
const picker = (gradientStopPicker?.div()?.querySelector("[data-floating-menu-content]") || undefined) as HTMLElement | undefined;
if (!picker || !position || !viewport) return "Bottom";
@@ -448,12 +448,12 @@
updatePixelRatio();
// Update rendered SVGs
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateDocumentArtwork", async (data) => {
await tick();
updateDocumentArtwork(data.svg);
});
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateEyedropperSamplingState", async (data) => {
await tick();
const { image, mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
@@ -467,19 +467,19 @@
});
// Gradient stop color picker
editor.subscriptions.subscribeJsMessage(UpdateGradientStopColorPickerPosition, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateGradientStopColorPickerPosition", (data) => {
gradientStopPickerColor = data.color;
gradientStopPickerPosition = { x: data.x, y: data.y };
});
// Update scrollbars and rulers
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateDocumentScrollbars", async (data) => {
await tick();
const { position, size, multiplier } = data;
updateDocumentScrollbars(position, size, multiplier);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => {
await tick();
const { origin, spacing, interval, visible } = data;
@@ -487,25 +487,24 @@
});
// Update mouse cursor icon
editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateMouseCursor", async (data) => {
await tick();
const { cursor } = data;
updateMouseCursor(cursor);
updateMouseCursor(data.cursor);
});
// Text entry
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerTextCommit", async () => {
await tick();
triggerTextCommit();
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (data) => {
editor.subscriptions.subscribeFrontendMessage("DisplayEditableTextbox", async (data) => {
await tick();
displayEditableTextbox(data);
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxUpdateFontData, async (data) => {
editor.subscriptions.subscribeFrontendMessage("DisplayEditableTextboxUpdateFontData", async (data) => {
await tick();
const fontData = new Uint8Array(data.fontData);
@@ -514,10 +513,10 @@
textInput.style.fontFamily = "text-font";
}
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxTransform, async (data) => {
editor.subscriptions.subscribeFrontendMessage("DisplayEditableTextboxTransform", async (data) => {
textInputMatrix = data.transform;
});
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
editor.subscriptions.subscribeFrontendMessage("DisplayRemoveEditableTextbox", async () => {
await tick();
displayRemoveEditableTextbox();
+11 -18
View File
@@ -3,19 +3,12 @@
import { SvelteMap } from "svelte/reactivity";
import type { Editor } from "@graphite/editor";
import {
patchLayout,
UpdateDocumentLayerDetails,
UpdateDocumentLayerStructure,
UpdateLayersPanelControlBarLeftLayout,
UpdateLayersPanelControlBarRightLayout,
UpdateLayersPanelBottomBarLayout,
} from "@graphite/messages";
import type { LayerPanelEntry, LayerStructureEntry, Layout } from "@graphite/messages";
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
import type { TooltipState } from "@graphite/state-providers/tooltip";
import { pasteFile } from "@graphite/utility-functions/files";
import { operatingSystem } from "@graphite/utility-functions/platform";
import { patchLayout } from "@graphite/utility-functions/widgets";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@@ -76,26 +69,26 @@
let layersPanelBottomBarLayout: Layout = [];
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarLeftLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", (data) => {
patchLayout(layersPanelControlBarLeftLayout, data);
layersPanelControlBarLeftLayout = layersPanelControlBarLeftLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarRightLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("LayersPanelControlRightBar", (data) => {
patchLayout(layersPanelControlBarRightLayout, data);
layersPanelControlBarRightLayout = layersPanelControlBarRightLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelBottomBarLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("LayersPanelBottomBar", (data) => {
patchLayout(layersPanelBottomBarLayout, data);
layersPanelBottomBarLayout = layersPanelBottomBarLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerStructure, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => {
rebuildLayerHierarchy(data.layerStructure);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateDocumentLayerDetails", (data) => {
const targetLayer = data.data;
const targetId = targetLayer.id;
@@ -114,11 +107,11 @@
});
onDestroy(() => {
editor.subscriptions.unsubscribeJsMessage(UpdateLayersPanelControlBarLeftLayout);
editor.subscriptions.unsubscribeJsMessage(UpdateLayersPanelControlBarRightLayout);
editor.subscriptions.unsubscribeJsMessage(UpdateLayersPanelBottomBarLayout);
editor.subscriptions.unsubscribeJsMessage(UpdateDocumentLayerStructure);
editor.subscriptions.unsubscribeJsMessage(UpdateDocumentLayerDetails);
editor.subscriptions.unsubscribeLayoutUpdate("LayersPanelControlLeftBar");
editor.subscriptions.unsubscribeLayoutUpdate("LayersPanelControlRightBar");
editor.subscriptions.unsubscribeLayoutUpdate("LayersPanelBottomBar");
editor.subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerStructure");
editor.subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerDetails");
removeEventListener("pointerup", draggingPointerUp);
removeEventListener("pointermove", draggingPointerMove);
@@ -2,7 +2,8 @@
import { getContext, onMount, onDestroy } from "svelte";
import type { Editor } from "@graphite/editor";
import { patchLayout, UpdatePropertiesPanelLayout, type Layout } from "@graphite/messages";
import type { Layout } from "@graphite/messages";
import { patchLayout } from "@graphite/utility-functions/widgets";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
@@ -12,14 +13,14 @@
let propertiesPanelLayout: Layout = [];
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdatePropertiesPanelLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("PropertiesPanel", (data) => {
patchLayout(propertiesPanelLayout, data);
propertiesPanelLayout = propertiesPanelLayout;
});
});
onDestroy(() => {
editor.subscriptions.unsubscribeJsMessage(UpdatePropertiesPanelLayout);
editor.subscriptions.unsubscribeLayoutUpdate("PropertiesPanel");
});
</script>
@@ -3,9 +3,9 @@
import type { Editor } from "@graphite/editor";
import type { Layout } from "@graphite/messages";
import { patchLayout, UpdateWelcomeScreenButtonsLayout } from "@graphite/messages";
import { pasteFile } from "@graphite/utility-functions/files";
import { isDesktop } from "@graphite/utility-functions/platform";
import { patchLayout } from "@graphite/utility-functions/widgets";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@@ -18,14 +18,14 @@
let welcomePanelButtonsLayout: Layout = [];
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateWelcomeScreenButtonsLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", (data) => {
patchLayout(welcomePanelButtonsLayout, data);
welcomePanelButtonsLayout = welcomePanelButtonsLayout;
});
});
onDestroy(() => {
editor.subscriptions.unsubscribeJsMessage(UpdateWelcomeScreenButtonsLayout);
editor.subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons");
});
function dropFile(e: DragEvent) {
+31 -31
View File
@@ -108,7 +108,7 @@
function createNode(identifier: string) {
if ($nodeGraph.contextMenuInformation === undefined) return;
editor.handle.createNode(identifier, $nodeGraph.contextMenuInformation.contextMenuCoordinates.x, $nodeGraph.contextMenuInformation.contextMenuCoordinates.y);
editor.handle.createNode(identifier, $nodeGraph.contextMenuInformation.contextMenuCoordinates[0], $nodeGraph.contextMenuInformation.contextMenuCoordinates[1]);
}
function nodeBorderMask(nodeWidth: number, primaryInputExists: boolean, exposedSecondaryInputs: number, primaryOutputExists: boolean, exposedSecondaryOutputs: number): string {
@@ -203,8 +203,8 @@
class="context-menu"
data-context-menu
styles={{
left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.x * $nodeGraph.transform.scale + $nodeGraph.transform.x}px`,
top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.y * $nodeGraph.transform.scale + $nodeGraph.transform.y}px`,
left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates[0] * $nodeGraph.transform.scale + $nodeGraph.transform.x}px`,
top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates[1] * $nodeGraph.transform.scale + $nodeGraph.transform.y}px`,
}}
open={true}
type="Popover"
@@ -257,10 +257,10 @@
{#if $nodeGraph.error}
<div class="node-error-container" style:transform-origin="0 0" style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.scale})`}>
<span class="node-error faded" style:left={`${$nodeGraph.error.position.x}px`} style:top={`${$nodeGraph.error.position.y}px`} transition:fade={FADE_TRANSITION}>
<span class="node-error faded" style:left={`${$nodeGraph.error.position[0]}px`} style:top={`${$nodeGraph.error.position[1]}px`} transition:fade={FADE_TRANSITION}>
{$nodeGraph.error.error}
</span>
<span class="node-error hover" style:left={`${$nodeGraph.error.position.x}px`} style:top={`${$nodeGraph.error.position.y}px`} transition:fade={FADE_TRANSITION}>
<span class="node-error hover" style:left={`${$nodeGraph.error.position[0]}px`} style:top={`${$nodeGraph.error.position[1]}px`} transition:fade={FADE_TRANSITION}>
{$nodeGraph.error.error}
</span>
</div>
@@ -324,8 +324,8 @@
data-datatype={frontendOutput.dataType}
style:--data-color={`var(--color-data-${frontendOutput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${frontendOutput.dataType.toLowerCase()}-dim)`}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition.x - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition.y - 8) / 24 + index}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 8) / 24 + index}
>
{#if frontendOutput.connectedTo.length > 0}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color)" />
@@ -340,8 +340,8 @@
class="edit-import-export import"
class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport}
class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition.x - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition.y - 8) / 24 + index}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 8) / 24 + index}
>
{#if editingNameImportIndex === index}
<input
@@ -377,8 +377,8 @@
{:else}
<div
class="plus"
style:--offset-top={($nodeGraph.updateImportsExports.importPosition.y - 12) / 24}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition.x - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 12) / 24}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 12) / 24}
>
<IconButton size={24} icon="Add" action={() => editor.handle.addPrimaryImport()} />
</div>
@@ -397,8 +397,8 @@
data-datatype={frontendInput.dataType}
style:--data-color={`var(--color-data-${frontendInput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${frontendInput.dataType.toLowerCase()}-dim)`}
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition.x - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition.y - 8) / 24 + index}
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 8) / 24 + index}
>
{#if frontendInput.connectedTo !== "Connected to nothing."}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color)" />
@@ -412,8 +412,8 @@
class="edit-import-export export"
class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport}
class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport}
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition.x - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition.y - 8) / 24 + index}
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 8) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 8) / 24 + index}
>
{#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraph.updateImportsExports.addImportExport}
{#if index > 0}
@@ -448,8 +448,8 @@
{:else}
<div
class="plus"
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition.x - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition.y - 12) / 24}
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 12) / 24}
>
<IconButton size={24} icon="Add" action={() => editor.handle.addPrimaryExport()} />
</div>
@@ -459,15 +459,15 @@
{#if $nodeGraph.updateImportsExports.addImportExport}
<div
class="plus"
style:--offset-left={($nodeGraph.updateImportsExports.importPosition.x - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition.y - 12) / 24 + $nodeGraph.updateImportsExports.imports.length}
style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 12) / 24 + $nodeGraph.updateImportsExports.imports.length}
>
<IconButton size={24} icon="Add" action={() => editor.handle.addSecondaryImport()} />
</div>
<div
class="plus"
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition.x - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition.y - 12) / 24 + $nodeGraph.updateImportsExports.exports.length}
style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 12) / 24}
style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 12) / 24 + $nodeGraph.updateImportsExports.exports.length}
>
<IconButton size={24} icon="Add" action={() => editor.handle.addSecondaryExport()} />
</div>
@@ -475,16 +475,16 @@
{#if $nodeGraph.reorderImportIndex !== undefined}
{@const position = {
x: Number($nodeGraph.updateImportsExports.importPosition.x),
y: Number($nodeGraph.updateImportsExports.importPosition.y) + Number($nodeGraph.reorderImportIndex) * 24,
x: Number($nodeGraph.updateImportsExports.importPosition[0]),
y: Number($nodeGraph.updateImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24,
}}
<div class="reorder-bar" style:--offset-left={(position.x - 48) / 24} style:--offset-top={(position.y - 12) / 24}></div>
{/if}
{#if $nodeGraph.reorderExportIndex !== undefined}
{@const position = {
x: Number($nodeGraph.updateImportsExports.exportPosition.x),
y: Number($nodeGraph.updateImportsExports.exportPosition.y) + Number($nodeGraph.reorderExportIndex) * 24,
x: Number($nodeGraph.updateImportsExports.exportPosition[0]),
y: Number($nodeGraph.updateImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24,
}}
<div class="reorder-bar" style:--offset-left={position.x / 24} style:--offset-top={(position.y - 12) / 24}></div>
{/if}
@@ -510,8 +510,8 @@
class:previewed={node.previewed}
class:disabled={!node.visible}
class:locked={node.locked}
style:--offset-left={node.position?.x || 0}
style:--offset-top={node.position?.y || 0}
style:--offset-left={node.position?.[0] || 0}
style:--offset-top={node.position?.[1] || 0}
style:--clip-path-id={`url(#${clipPathId})`}
style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`}
@@ -519,7 +519,7 @@
style:--node-chain-area-left-extension={layerChainWidth !== 0 ? layerChainWidth + 0.5 : 0}
data-tooltip-label={nodeNameTooltipLabel(node)}
data-tooltip-description={`
${(description || "").trim()}${editor.handle.inDevelopmentMode() ? `\n\nID: ${node.id}. Position: (${node.position.x}, ${node.position.y}).` : ""}
${(description || "").trim()}${editor.handle.inDevelopmentMode() ? `\n\nID: ${node.id}. Position: (${node.position[0]}, ${node.position[1]}).` : ""}
`.trim()}
data-node={node.id}
>
@@ -675,14 +675,14 @@
class:selected={$nodeGraph.selected.includes(node.id)}
class:previewed={node.previewed}
class:disabled={!node.visible}
style:--offset-left={node.position?.x || 0}
style:--offset-top={node.position?.y || 0}
style:--offset-left={node.position?.[0] || 0}
style:--offset-top={node.position?.[1] || 0}
style:--clip-path-id={`url(#${clipPathId})`}
style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`}
data-tooltip-label={nodeNameTooltipLabel(node)}
data-tooltip-description={`
${(description || "").trim()}${editor.handle.inDevelopmentMode() ? `\n\nID: ${node.id}. Position: (${node.position.x}, ${node.position.y}).` : ""}
${(description || "").trim()}${editor.handle.inDevelopmentMode() ? `\n\nID: ${node.id}. Position: (${node.position[0]}, ${node.position[1]}).` : ""}
`.trim()}
data-node={node.id}
>
@@ -1,5 +1,6 @@
<script lang="ts">
import { isWidgetSpanColumn, isWidgetSpanRow, isWidgetSection, type Layout, isWidgetTable, type LayoutTarget } from "@graphite/messages";
import type { Layout, LayoutTarget } from "@graphite/messages";
import { isWidgetSpanColumn, isWidgetSpanRow, isWidgetTable, isWidgetSection } from "@graphite/utility-functions/widgets";
import WidgetSection from "@graphite/components/widgets/WidgetSection.svelte";
import WidgetSpan from "@graphite/components/widgets/WidgetSpan.svelte";
@@ -2,14 +2,15 @@
import { getContext } from "svelte";
import type { Editor } from "@graphite/editor";
import { isWidgetSpanRow, isWidgetSection, type WidgetSection as WidgetSectionFromJsMessages, type LayoutTarget } from "@graphite/messages";
import type { WidgetSection as WidgetSectionData, LayoutTarget } from "@graphite/messages";
import { isWidgetSpanRow, isWidgetSection } from "@graphite/utility-functions/widgets";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
import WidgetSpan from "@graphite/components/widgets/WidgetSpan.svelte";
export let widgetData: WidgetSectionFromJsMessages;
export let widgetData: WidgetSectionData;
export let layoutTarget: LayoutTarget;
let className = "";
@@ -3,8 +3,9 @@
import type { Editor } from "@graphite/editor";
import type { LayoutTarget, WidgetInstance, WidgetPropsNames, WidgetPropsSet, WidgetTypes, WidgetSpanColumn, WidgetSpanRow } from "@graphite/messages";
import { isWidgetSpanColumn, isWidgetSpanRow } from "@graphite/messages";
import { parseFillChoice } from "@graphite/utility-functions/colors";
import { debouncer } from "@graphite/utility-functions/debounce";
import { isWidgetSpanColumn, isWidgetSpanRow, createLayoutGroup } from "@graphite/utility-functions/widgets";
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
@@ -97,6 +98,7 @@
component: ColorInput,
getProps: (props: WidgetTypes["ColorInput"], index) => ({
...exclude(props),
value: parseFillChoice(props.value),
$$events: {
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
startHistoryTransaction: () => widgetValueCommit(index, props.value),
@@ -191,6 +193,7 @@
getProps: (props: WidgetTypes["PopoverButton"]) => ({
...exclude(props),
layoutTarget,
popoverLayout: props.popoverLayout.map(createLayoutGroup),
}),
},
RadioInput: {
@@ -1,9 +1,9 @@
<script lang="ts">
import { type LayoutTarget, type WidgetTable as WidgetTableFromJsMessages } from "@graphite/messages";
import type { LayoutTarget, WidgetTable as WidgetTableData } from "@graphite/messages";
import WidgetSpan from "@graphite/components/widgets/WidgetSpan.svelte";
export let widgetData: WidgetTableFromJsMessages;
export let widgetData: WidgetTableData;
export let layoutTarget: LayoutTarget;
export let unstyled = false;
@@ -1,5 +1,5 @@
<script lang="ts">
import { type IconName, type IconSize } from "@graphite/icons";
import type { IconName, IconSize } from "@graphite/icons";
import type { ActionShortcut } from "@graphite/messages";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
@@ -1,5 +1,5 @@
<script lang="ts">
import { type IconName, type PopoverButtonStyle } from "@graphite/icons";
import type { IconName, PopoverButtonStyle } from "@graphite/icons";
import type { MenuDirection, ActionShortcut, Layout, LayoutTarget } from "@graphite/messages";
@@ -2,7 +2,8 @@
import { createEventDispatcher } from "svelte";
import type { FillChoice, MenuDirection, ActionShortcut } from "@graphite/messages";
import { type Color, contrastingOutlineFactor, isColor, isGradient, colorToHexOptionalAlpha, gradientToLinearGradientCSS } from "@graphite/messages";
import type { Color } from "@graphite/messages";
import { contrastingOutlineFactor, isColor, isGradient, colorToHexOptionalAlpha, gradientToLinearGradientCSS } from "@graphite/utility-functions/colors";
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
@@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { type RadioEntryData } from "@graphite/messages";
import type { RadioEntryData } from "@graphite/messages";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
@@ -7,7 +7,8 @@
import { createEventDispatcher, onDestroy } from "svelte";
import { evaluateGradientAtPosition } from "@graphite/../wasm/pkg/graphite_wasm";
import { type Color, type Gradient, createColor, colorToHexOptionalAlpha, colorToRgbCSS, gradientFirstColor, gradientLastColor, gradientToLinearGradientCSS } from "@graphite/messages";
import type { Color, Gradient } from "@graphite/messages";
import { createColor, colorToHexOptionalAlpha, colorToRgbCSS, gradientFirstColor, gradientLastColor, gradientToLinearGradientCSS } from "@graphite/utility-functions/colors";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
@@ -2,7 +2,8 @@
import { getContext } from "svelte";
import type { Editor } from "@graphite/editor";
import { type Color, isColor, colorToRgbaCSS } from "@graphite/messages";
import type { Color } from "@graphite/messages";
import { isColor, colorToRgbaCSS } from "@graphite/utility-functions/colors";
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
@@ -1,5 +1,6 @@
<script lang="ts">
import { type IconName, ICONS, ICON_SVG_STRINGS } from "@graphite/icons";
import { ICONS, ICON_SVG_STRINGS } from "@graphite/icons";
import type { IconName } from "@graphite/icons";
import type { ActionShortcut } from "@graphite/messages";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@@ -1,5 +1,5 @@
<script lang="ts">
import { type SeparatorDirection, type SeparatorStyle } from "@graphite/messages";
import type { SeparatorDirection, SeparatorStyle } from "@graphite/messages";
// Content
export let direction: SeparatorDirection = "Horizontal";
@@ -3,7 +3,7 @@
import type { Editor } from "@graphite/editor";
import type { Layout } from "@graphite/messages";
import { patchLayout, UpdateStatusBarHintsLayout, UpdateStatusBarInfoLayout } from "@graphite/messages";
import { patchLayout } from "@graphite/utility-functions/widgets";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
@@ -15,11 +15,11 @@
let statusBarInfoLayout: Layout = [];
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateStatusBarHintsLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("StatusBarHints", (data) => {
patchLayout(statusBarHintsLayout, data);
statusBarHintsLayout = statusBarHintsLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateStatusBarInfoLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("StatusBarInfo", (data) => {
patchLayout(statusBarInfoLayout, data);
statusBarInfoLayout = statusBarInfoLayout;
});
@@ -3,10 +3,10 @@
import type { Editor } from "@graphite/editor";
import type { Layout } from "@graphite/messages";
import { patchLayout, UpdateMenuBarLayout } from "@graphite/messages";
import type { AppWindowState } from "@graphite/state-providers/app-window";
import type { FullscreenState } from "@graphite/state-providers/fullscreen";
import type { TooltipState } from "@graphite/state-providers/tooltip";
import { patchLayout } from "@graphite/utility-functions/widgets";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
@@ -26,7 +26,7 @@
$: height = $appWindow.platform === "Mac" ? 28 * (1 / $appWindow.uiScale) : 28;
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("MenuBar", (data) => {
patchLayout(menuBarLayout, data);
menuBarLayout = menuBarLayout;
});
+5 -5
View File
@@ -2,8 +2,9 @@
import { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
import init, { wasmMemory, receiveNativeMessage } from "@graphite/../wasm/pkg/graphite_wasm";
import { type JsMessageType } from "@graphite/messages";
import { createSubscriptionRouter, type SubscriptionRouter } from "@graphite/subscription-router";
import type { FrontendMessages } from "@graphite/messages";
import { createSubscriptionRouter } from "@graphite/subscription-router";
import type { SubscriptionRouter } from "@graphite/subscription-router";
import { operatingSystem } from "@graphite/utility-functions/platform";
// TODO: Remove `raw`, split out `subscriptions`, and unwrap the remaining `handle` so `EditorHandle` can replace `Editor` and then it can also be renamed to `Editor` to fully remove `EditorHandle`.
@@ -45,10 +46,9 @@ export function createEditor(): Editor {
const randomSeed = BigInt(randomSeedFloat);
// Handle: object containing many functions from `editor_api.rs` that are part of the `EditorHandle` struct (generated by wasm-bindgen)
const handle = EditorHandle.create(operatingSystem(), randomSeed, (messageType: JsMessageType, messageData: Record<string, unknown>) => {
const handle = EditorHandle.create(operatingSystem(), randomSeed, (messageType: keyof FrontendMessages, messageData: Record<string, unknown>) => {
// This callback is called by Wasm when a FrontendMessage is received from the Wasm wrapper `EditorHandle`
// We pass along the first two arguments then add our own `raw` and `handle` context for the last two arguments
subscriptions.handleJsMessage(messageType, messageData, raw, handle);
subscriptions.handleFrontendMessage(messageType, messageData);
});
// Subscriptions: allows subscribing to messages in JS that are sent from the Wasm backend
+5 -6
View File
@@ -1,16 +1,15 @@
import { type Editor } from "@graphite/editor";
import { TriggerClipboardWrite, TriggerSelectionRead, TriggerSelectionWrite } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
export function createClipboardManager(editor: Editor) {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerClipboardWrite, (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
navigator.clipboard?.writeText?.(data.content);
});
editor.subscriptions.subscribeJsMessage(TriggerSelectionRead, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerSelectionRead", async (data) => {
editor.handle.readSelection(readAtCaret(data.cut), data.cut);
});
editor.subscriptions.subscribeJsMessage(TriggerSelectionWrite, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => {
insertAtCaret(data.content);
});
}
@@ -44,7 +43,7 @@ function readAtCaret(cut: boolean): string | undefined {
return undefined;
}
const selectedText = selection.toString();
const selectedText = String(selection);
if (!selectedText) return undefined;
if (cut) {
+3 -4
View File
@@ -1,5 +1,4 @@
import { type Editor } from "@graphite/editor";
import { TriggerFontCatalogLoad, TriggerFontDataLoad } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
type ApiResponse = { family: string; variants: string[]; files: Record<string, string> }[];
@@ -7,7 +6,7 @@ const FONT_LIST_API = "https://api.graphite.art/font-list";
export function createFontsManager(editor: Editor) {
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerFontCatalogLoad, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerFontCatalogLoad", async () => {
const response = await fetch(FONT_LIST_API);
const fontListResponse = (await response.json()) as { items: ApiResponse };
const fontListData = fontListResponse.items;
@@ -26,7 +25,7 @@ export function createFontsManager(editor: Editor) {
editor.handle.onFontCatalogLoad(catalog);
});
editor.subscriptions.subscribeJsMessage(TriggerFontDataLoad, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerFontDataLoad", async (data) => {
const { fontFamily, fontStyle } = data.font;
try {
+2 -3
View File
@@ -1,9 +1,8 @@
import { type Editor } from "@graphite/editor";
import { TriggerVisitLink } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
export function createHyperlinkManager(editor: Editor) {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerVisitLink, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerVisitLink", async (data) => {
window.open(data.url, "_blank");
});
}
+7 -8
View File
@@ -1,11 +1,10 @@
import { get } from "svelte/store";
import { type Editor } from "@graphite/editor";
import { TriggerClipboardRead, WindowPointerLockMove } from "@graphite/messages";
import { type DialogState } from "@graphite/state-providers/dialog";
import { type DocumentState } from "@graphite/state-providers/document";
import { type FullscreenState } from "@graphite/state-providers/fullscreen";
import { type PortfolioState } from "@graphite/state-providers/portfolio";
import type { Editor } from "@graphite/editor";
import type { DialogState } from "@graphite/state-providers/dialog";
import type { DocumentState } from "@graphite/state-providers/document";
import type { FullscreenState } from "@graphite/state-providers/fullscreen";
import type { PortfolioState } from "@graphite/state-providers/portfolio";
import { pasteFile } from "@graphite/utility-functions/files";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
import { isDesktop, operatingSystem } from "@graphite/utility-functions/platform";
@@ -392,7 +391,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// Frontend message subscriptions
editor.subscriptions.subscribeJsMessage(TriggerClipboardRead, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerClipboardRead", async () => {
// In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers
// In the catch block, explain to the user why the paste failed and how to fix or work around the problem
try {
@@ -487,7 +486,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
});
// Pointer lock movement events on desktop
editor.subscriptions.subscribeJsMessage(WindowPointerLockMove, (data) => {
editor.subscriptions.subscribeFrontendMessage("WindowPointerLockMove", (data) => {
const event = new CustomEvent("pointerlockmove", { detail: data });
window.dispatchEvent(event);
});
+2 -3
View File
@@ -1,9 +1,8 @@
import { type Editor } from "@graphite/editor";
import { TriggerAboutGraphiteLocalizedCommitDate } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
export function createLocalizationManager(editor: Editor) {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerAboutGraphiteLocalizedCommitDate, (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate", (data) => {
const localized = localizeTimestamp(data.commitDate);
editor.handle.requestAboutGraphiteDialogWithLocalizedCommitDate(localized.timestamp, localized.year);
});
+4 -5
View File
@@ -1,12 +1,11 @@
import { type Editor } from "@graphite/editor";
import { DisplayDialogPanic } from "@graphite/messages";
import { type DialogState } from "@graphite/state-providers/dialog";
import type { Editor } from "@graphite/editor";
import type { DialogState } from "@graphite/state-providers/dialog";
import { browserVersion, operatingSystem } from "@graphite/utility-functions/platform";
import { stripIndents } from "@graphite/utility-functions/strip-indents";
export function createPanicManager(editor: Editor, dialogState: DialogState) {
// Code panic dialog and console error
editor.subscriptions.subscribeJsMessage(DisplayDialogPanic, (data) => {
editor.subscriptions.subscribeFrontendMessage("DisplayDialogPanic", (data) => {
// `Error.stackTraceLimit` is only available in V8/Chromium
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Error as any).stackTraceLimit = Infinity;
@@ -68,7 +67,7 @@ export function githubUrl(panicDetails: string): string {
if (value) url.searchParams.set(field, value);
});
return url.toString();
return String(url);
};
let urlString = buildUrl(true);
+61 -40
View File
@@ -1,18 +1,12 @@
import { createStore, del, get, set, update } from "idb-keyval";
import { get as getFromStore } from "svelte/store";
import { type Editor } from "@graphite/editor";
import {
TriggerPersistenceWriteDocument,
TriggerPersistenceRemoveDocument,
TriggerSavePreferences,
TriggerLoadPreferences,
TriggerLoadFirstAutoSaveDocument,
TriggerLoadRestAutoSaveDocuments,
TriggerSaveActiveDocument,
TriggerOpenLaunchDocuments,
} from "@graphite/messages";
import { type PortfolioState } from "@graphite/state-providers/portfolio";
import type { Editor } from "@graphite/editor";
import type { FrontendMessages } from "@graphite/messages";
import type { PortfolioState } from "@graphite/state-providers/portfolio";
type TriggerPersistenceWriteDocument = FrontendMessages["TriggerPersistenceWriteDocument"];
type TriggerSavePreferences = FrontendMessages["TriggerSavePreferences"];
const graphiteStore = createStore("graphite", "store");
@@ -33,14 +27,14 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta
"documents",
(old) => {
const documents = old || {};
documents[autoSaveDocument.documentId] = autoSaveDocument;
documents[String(autoSaveDocument.documentId)] = autoSaveDocument;
return documents;
},
graphiteStore,
);
await storeDocumentOrder();
await storeCurrentDocumentId(autoSaveDocument.documentId);
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
}
async function removeDocument(id: string) {
@@ -79,35 +73,56 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta
async function loadFirstDocument() {
const previouslySavedDocuments = await get<Record<string, TriggerPersistenceWriteDocument>>("documents", graphiteStore);
// TODO: Eventually remove this document upgrade code
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
if (previouslySavedDocuments) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Object.values(previouslySavedDocuments).forEach((doc: any) => {
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
});
}
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
const currentDocumentId = await get<string>("current_document_id", graphiteStore);
const currentDocumentIdString = await get<string>("current_document_id", graphiteStore);
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
if (!previouslySavedDocuments || !documentOrder) return;
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
if (currentDocumentId && currentDocumentId in previouslySavedDocuments) {
const doc = previouslySavedDocuments[currentDocumentId];
editor.handle.openAutoSavedDocument(BigInt(doc.documentId), doc.details.name, doc.details.isSaved, doc.document, false);
editor.handle.selectDocument(BigInt(currentDocumentId));
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
const doc = previouslySavedDocuments[String(currentDocumentId)];
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
editor.handle.selectDocument(currentDocumentId);
} else {
const len = orderedSavedDocuments.length;
if (len > 0) {
const doc = orderedSavedDocuments[len - 1];
editor.handle.openAutoSavedDocument(BigInt(doc.documentId), doc.details.name, doc.details.isSaved, doc.document, false);
editor.handle.selectDocument(BigInt(doc.documentId));
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
editor.handle.selectDocument(doc.documentId);
}
}
}
async function loadRestDocuments() {
const previouslySavedDocuments = await get<Record<string, TriggerPersistenceWriteDocument>>("documents", graphiteStore);
// TODO: Eventually remove this document upgrade code
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
if (previouslySavedDocuments) {
Object.values(previouslySavedDocuments).forEach((doc) => {
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
});
}
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
const currentDocumentId = await get<string>("current_document_id", graphiteStore);
const currentDocumentIdString = await get<string>("current_document_id", graphiteStore);
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
if (!previouslySavedDocuments || !documentOrder) return;
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
if (currentDocumentId) {
if (currentDocumentId !== undefined) {
const currentIndex = orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId);
const beforeCurrentIndex = currentIndex - 1;
const afterCurrentIndex = currentIndex + 1;
@@ -115,28 +130,25 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta
for (let i = beforeCurrentIndex; i >= 0; i--) {
const { documentId, document, details } = orderedSavedDocuments[i];
const { name, isSaved } = details;
editor.handle.openAutoSavedDocument(BigInt(documentId), name, isSaved, document, true);
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
}
for (let i = afterCurrentIndex; i < orderedSavedDocuments.length; i++) {
const { documentId, document, details } = orderedSavedDocuments[i];
const { name, isSaved } = details;
editor.handle.openAutoSavedDocument(BigInt(documentId), name, isSaved, document, false);
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, false);
}
editor.handle.selectDocument(BigInt(currentDocumentId));
editor.handle.selectDocument(currentDocumentId);
} else {
const length = orderedSavedDocuments.length;
for (let i = length - 2; i >= 0; i--) {
const { documentId, document, details } = orderedSavedDocuments[i];
const { name, isSaved } = details;
editor.handle.openAutoSavedDocument(BigInt(documentId), name, isSaved, document, true);
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
}
if (length > 0) {
const id = orderedSavedDocuments[length - 1].documentId;
editor.handle.selectDocument(BigInt(id));
}
if (length > 0) editor.handle.selectDocument(orderedSavedDocuments[length - 1].documentId);
}
}
@@ -154,30 +166,39 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta
// FRONTEND MESSAGE SUBSCRIPTIONS
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerSavePreferences", async (data) => {
await savePreferences(data.preferences);
});
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerLoadPreferences", async () => {
await loadPreferences();
});
editor.subscriptions.subscribeJsMessage(TriggerPersistenceWriteDocument, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
await storeDocument(data);
});
editor.subscriptions.subscribeJsMessage(TriggerPersistenceRemoveDocument, async (data) => {
await removeDocument(data.documentId);
editor.subscriptions.subscribeFrontendMessage("TriggerPersistenceRemoveDocument", async (data) => {
await removeDocument(String(data.documentId));
});
editor.subscriptions.subscribeJsMessage(TriggerLoadFirstAutoSaveDocument, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument", async () => {
await loadFirstDocument();
});
editor.subscriptions.subscribeJsMessage(TriggerLoadRestAutoSaveDocuments, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments", async () => {
await loadRestDocuments();
});
editor.subscriptions.subscribeJsMessage(TriggerOpenLaunchDocuments, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
// TODO: Could be used to load documents from URL params or similar on launch
});
editor.subscriptions.subscribeJsMessage(TriggerSaveActiveDocument, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerSaveActiveDocument", async (data) => {
const documentId = String(data.documentId);
const previouslySavedDocuments = await get<Record<string, TriggerPersistenceWriteDocument>>("documents", graphiteStore);
// TODO: Eventually remove this document upgrade code
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
if (previouslySavedDocuments) {
Object.values(previouslySavedDocuments).forEach((doc) => {
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
});
}
if (!previouslySavedDocuments) return;
if (documentId in previouslySavedDocuments) {
await storeCurrentDocumentId(documentId);
-4
View File
@@ -1,9 +1,5 @@
// This file is the browser's entry point for the JS bundle
// `reflect-metadata` allows for runtime reflection of types in JavaScript.
// It is needed for class-transformer to work and is imported as a side effect.
// The library replaces the Reflect API on the window to support more features.
import "reflect-metadata";
import { mount } from "svelte";
import App from "@graphite/App.svelte";
+509 -1452
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import { type AppWindowPlatform, UpdatePlatform, UpdateViewportHolePunch, UpdateMaximized, UpdateFullscreen, UpdateUIScale } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
import type { AppWindowPlatform } from "@graphite/messages";
export function createAppWindowState(editor: Editor) {
const { subscribe, update } = writable({
@@ -13,31 +13,31 @@ export function createAppWindowState(editor: Editor) {
});
// Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdatePlatform, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdatePlatform", (data) => {
update((state) => {
state.platform = data.platform;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateMaximized, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateMaximized", (data) => {
update((state) => {
state.maximized = data.maximized;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateFullscreen, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateFullscreen", (data) => {
update((state) => {
state.fullscreen = data.fullscreen;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateViewportHolePunch, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateViewportHolePunch", (data) => {
update((state) => {
state.viewportHolePunch = data.active;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateUIScale, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateUIScale", (data) => {
update((state) => {
state.uiScale = data.scale;
return state;
+9 -9
View File
@@ -1,9 +1,9 @@
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import { type IconName } from "@graphite/icons";
import { DisplayDialog, DialogClose, UpdateDialogButtons, UpdateDialogColumn1, UpdateDialogColumn2, patchLayout, TriggerDisplayThirdPartyLicensesDialog } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
import type { IconName } from "@graphite/icons";
import type { Layout } from "@graphite/messages";
import { patchLayout } from "@graphite/utility-functions/widgets";
export function createDialogState(editor: Editor) {
const { subscribe, update } = writable({
@@ -45,7 +45,7 @@ export function createDialogState(editor: Editor) {
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(DisplayDialog, (data) => {
editor.subscriptions.subscribeFrontendMessage("DisplayDialog", (data) => {
update((state) => {
state.visible = true;
@@ -55,30 +55,30 @@ export function createDialogState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateDialogButtons, (data) => {
editor.subscriptions.subscribeLayoutUpdate("DialogButtons", (data) => {
update((state) => {
patchLayout(state.buttons, data);
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateDialogColumn1, (data) => {
editor.subscriptions.subscribeLayoutUpdate("DialogColumn1", (data) => {
update((state) => {
patchLayout(state.column1, data);
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateDialogColumn2, (data) => {
editor.subscriptions.subscribeLayoutUpdate("DialogColumn2", (data) => {
update((state) => {
patchLayout(state.column2, data);
return state;
});
});
editor.subscriptions.subscribeJsMessage(DialogClose, dismissDialog);
editor.subscriptions.subscribeFrontendMessage("DialogClose", dismissDialog);
editor.subscriptions.subscribeJsMessage(TriggerDisplayThirdPartyLicensesDialog, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog", async () => {
const BACKUP_URL = "https://editor.graphite.art/third-party-licenses.txt";
let licenseText = `Content was not able to load. Please check your network connection and try again.\n\nOr visit ${BACKUP_URL} for the license notices.`;
+9 -19
View File
@@ -1,19 +1,9 @@
import { tick } from "svelte";
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import {
patchLayout,
UpdateDocumentBarLayout,
UpdateToolOptionsLayout,
UpdateToolShelfLayout,
UpdateWorkingColorsLayout,
UpdateNodeGraphControlBarLayout,
UpdateGraphViewOverlay,
UpdateGraphFadeArtwork,
} from "@graphite/messages";
import type { Editor } from "@graphite/editor";
import type { Layout } from "@graphite/messages";
import { patchLayout } from "@graphite/utility-functions/widgets";
export function createDocumentState(editor: Editor) {
const state = writable({
@@ -30,13 +20,13 @@ export function createDocumentState(editor: Editor) {
const { subscribe, update } = state;
// Update layouts
editor.subscriptions.subscribeJsMessage(UpdateGraphFadeArtwork, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateGraphFadeArtwork", (data) => {
update((state) => {
state.fadeArtwork = data.percentage;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (data) => {
editor.subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => {
await tick();
update((state) => {
@@ -44,7 +34,7 @@ export function createDocumentState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (data) => {
editor.subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => {
await tick();
update((state) => {
@@ -52,7 +42,7 @@ export function createDocumentState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (data) => {
editor.subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => {
await tick();
update((state) => {
@@ -60,7 +50,7 @@ export function createDocumentState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (data) => {
editor.subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => {
await tick();
update((state) => {
@@ -68,7 +58,7 @@ export function createDocumentState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphControlBarLayout, (data) => {
editor.subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", (data) => {
update((state) => {
patchLayout(state.nodeGraphControlBarLayout, data);
return state;
@@ -76,7 +66,7 @@ export function createDocumentState(editor: Editor) {
});
// Show or hide the graph view overlay
editor.subscriptions.subscribeJsMessage(UpdateGraphViewOverlay, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => {
update((state) => {
state.graphViewOverlayOpen = data.open;
return state;
+2 -3
View File
@@ -1,7 +1,6 @@
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import { WindowFullscreen } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
export function createFullscreenState(editor: Editor) {
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard
@@ -51,7 +50,7 @@ export function createFullscreenState(editor: Editor) {
});
}
editor.subscriptions.subscribeJsMessage(WindowFullscreen, () => {
editor.subscriptions.subscribeFrontendMessage("WindowFullscreen", () => {
toggleFullscreen();
});
+23 -47
View File
@@ -1,33 +1,9 @@
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import type { NodeGraphError } from "@graphite/messages";
import {
type Box,
type FrontendClickTargets,
type ContextMenuInformation,
type FrontendNode,
type FrontendNodeType,
type WirePath,
ClearAllNodeGraphWires,
SendUIMetadata,
UpdateBox,
UpdateClickTargets,
UpdateContextMenuInformation,
UpdateInSelectedNetwork,
UpdateImportReorderIndex,
UpdateExportReorderIndex,
UpdateImportsExports,
UpdateLayerWidths,
UpdateNodeGraphNodes,
UpdateVisibleNodes,
UpdateNodeGraphWires,
UpdateNodeGraphSelection,
UpdateNodeGraphTransform,
UpdateNodeThumbnail,
UpdateWirePathInProgress,
UpdateNodeGraphErrorDiagnostic,
} from "@graphite/messages";
import type { Editor } from "@graphite/editor";
import type { NodeGraphError, Box, FrontendClickTargets, ContextMenuInformation, FrontendNode, FrontendNodeType, WirePath, FrontendMessages } from "@graphite/messages";
type UpdateImportsExports = FrontendMessages["UpdateImportsExports"];
export function createNodeGraphState(editor: Editor) {
const { subscribe, update } = writable({
@@ -62,56 +38,56 @@ export function createNodeGraphState(editor: Editor) {
}
// Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(SendUIMetadata, (data) => {
editor.subscriptions.subscribeFrontendMessage("SendUIMetadata", (data) => {
update((state) => {
state.nodeDescriptions = data.nodeDescriptions;
state.nodeDescriptions = new Map(data.nodeDescriptions);
state.nodeTypes = data.nodeTypes;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateBox, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateBox", (data) => {
update((state) => {
state.box = data.box;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateClickTargets, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateClickTargets", (data) => {
update((state) => {
state.clickTargets = data.clickTargets;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateContextMenuInformation, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateContextMenuInformation", (data) => {
update((state) => {
state.contextMenuInformation = data.contextMenuInformation;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateImportReorderIndex, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateImportReorderIndex", (data) => {
update((state) => {
state.reorderImportIndex = data.importIndex;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateExportReorderIndex, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateExportReorderIndex", (data) => {
update((state) => {
state.reorderExportIndex = data.exportIndex;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateImportsExports, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateImportsExports", (data) => {
update((state) => {
state.updateImportsExports = data;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateInSelectedNetwork, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateInSelectedNetwork", (data) => {
update((state) => {
state.inSelectedNetwork = data.inSelectedNetwork;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateLayerWidths, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateLayerWidths", (data) => {
update((state) => {
state.layerWidths = data.layerWidths;
state.chainWidths = data.chainWidths;
@@ -119,7 +95,7 @@ export function createNodeGraphState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphNodes, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphNodes", (data) => {
update((state) => {
state.nodes.clear();
data.nodes.forEach((node) => {
@@ -128,19 +104,19 @@ export function createNodeGraphState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphErrorDiagnostic, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphErrorDiagnostic", (data) => {
update((state) => {
state.error = data.error;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateVisibleNodes, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateVisibleNodes", (data) => {
update((state) => {
state.visibleNodes = new Set<bigint>(data.nodes);
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphWires, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphWires", (data) => {
update((state) => {
data.wires.forEach((wireUpdate) => {
let inputMap = state.wires.get(wireUpdate.id);
@@ -158,31 +134,31 @@ export function createNodeGraphState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(ClearAllNodeGraphWires, () => {
editor.subscriptions.subscribeFrontendMessage("ClearAllNodeGraphWires", () => {
update((state) => {
state.wires.clear();
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphSelection, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphSelection", (data) => {
update((state) => {
state.selected = data.selected;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphTransform, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphTransform", (data) => {
update((state) => {
state.transform = data.transform;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeThumbnail, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateNodeThumbnail", (data) => {
update((state) => {
state.thumbnails.set(data.id, data.value);
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateWirePathInProgress, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateWirePathInProgress", (data) => {
update((state) => {
state.wirePathInProgress = data.wirePath;
return state;
+13 -26
View File
@@ -1,20 +1,7 @@
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import type { Editor } from "@graphite/editor";
import type { OpenDocument } from "@graphite/messages";
import {
TriggerFetchAndOpenDocument,
TriggerSaveDocument,
TriggerExportImage,
TriggerSaveFile,
TriggerImport,
TriggerOpen,
UpdateActiveDocument,
UpdateOpenDocumentsList,
UpdateDataPanelState,
UpdatePropertiesPanelState,
UpdateLayersPanelState,
} from "@graphite/messages";
import { downloadFile, downloadFileBlob, upload } from "@graphite/utility-functions/files";
import { rasterizeSVG } from "@graphite/utility-functions/rasterization";
@@ -29,13 +16,13 @@ export function createPortfolioState(editor: Editor) {
});
// Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateOpenDocumentsList, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateOpenDocumentsList", (data) => {
update((state) => {
state.documents = data.openDocuments;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {
update((state) => {
// Assume we receive a correct document id
const activeId = state.documents.findIndex((doc) => doc.id === data.documentId);
@@ -43,7 +30,7 @@ export function createPortfolioState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(TriggerFetchAndOpenDocument, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerFetchAndOpenDocument", async (data) => {
try {
const url = new URL(`demo-artwork/${data.filename}`, document.location.href);
const response = await fetch(url);
@@ -55,22 +42,22 @@ export function createPortfolioState(editor: Editor) {
}, 0);
}
});
editor.subscriptions.subscribeJsMessage(TriggerOpen, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerOpen", async () => {
const data = await upload(`image/*,.${editor.handle.fileExtension()}`, "data");
editor.handle.openFile(data.filename, data.content);
});
editor.subscriptions.subscribeJsMessage(TriggerImport, async () => {
editor.subscriptions.subscribeFrontendMessage("TriggerImport", async () => {
// TODO: Use the same `accept` string as in the `TriggerOpen` handler once importing Graphite documents as nodes is supported
const data = await upload("image/*", "data");
editor.handle.importFile(data.filename, data.content);
});
editor.subscriptions.subscribeJsMessage(TriggerSaveDocument, (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => {
downloadFile(data.name, data.content);
});
editor.subscriptions.subscribeJsMessage(TriggerSaveFile, (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerSaveFile", (data) => {
downloadFile(data.name, data.content);
});
editor.subscriptions.subscribeJsMessage(TriggerExportImage, async (data) => {
editor.subscriptions.subscribeFrontendMessage("TriggerExportImage", async (data) => {
const { svg, name, mime, size } = data;
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
@@ -78,7 +65,7 @@ export function createPortfolioState(editor: Editor) {
// Rasterize the SVG to an image file
try {
const blob = await rasterizeSVG(svg, size.x, size.y, mime, backgroundColor);
const blob = await rasterizeSVG(svg, size[0], size[1], mime, backgroundColor);
// Have the browser download the file to the user's disk
downloadFileBlob(name, blob);
@@ -86,19 +73,19 @@ export function createPortfolioState(editor: Editor) {
// Fail silently if there's an error rasterizing the SVG, such as a zero-sized image
}
});
editor.subscriptions.subscribeJsMessage(UpdateDataPanelState, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateDataPanelState", async (data) => {
update((state) => {
state.dataPanelOpen = data.open;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdatePropertiesPanelState, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdatePropertiesPanelState", async (data) => {
update((state) => {
state.propertiesPanelOpen = data.open;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelState, async (data) => {
editor.subscriptions.subscribeFrontendMessage("UpdateLayersPanelState", async (data) => {
update((state) => {
state.layersPanelOpen = data.open;
return state;
+5 -5
View File
@@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor";
import { SendShortcutAltClick, SendShortcutFullscreen, SendShortcutShiftClick, type ActionShortcut } from "@graphite/messages";
import type { Editor } from "@graphite/editor";
import type { ActionShortcut } from "@graphite/messages";
import { operatingSystem } from "@graphite/utility-functions/platform";
const SHOW_TOOLTIP_DELAY_MS = 500;
@@ -65,19 +65,19 @@ export function createTooltipState(editor: Editor) {
document.addEventListener("keydown", closeTooltip);
document.addEventListener("wheel", closeTooltip);
editor.subscriptions.subscribeJsMessage(SendShortcutShiftClick, async (data) => {
editor.subscriptions.subscribeFrontendMessage("SendShortcutShiftClick", async (data) => {
update((state) => {
state.shiftClickShortcut = data.shortcut;
return state;
});
});
editor.subscriptions.subscribeJsMessage(SendShortcutAltClick, async (data) => {
editor.subscriptions.subscribeFrontendMessage("SendShortcutAltClick", async (data) => {
update((state) => {
state.altClickShortcut = data.shortcut;
return state;
});
});
editor.subscriptions.subscribeJsMessage(SendShortcutFullscreen, async (data) => {
editor.subscriptions.subscribeFrontendMessage("SendShortcutFullscreen", async (data) => {
update((state) => {
state.fullscreenShortcut = operatingSystem() === "Mac" ? data.shortcutMac : data.shortcut;
return state;
+48 -48
View File
@@ -1,68 +1,66 @@
import { plainToInstance } from "class-transformer";
import type { FrontendMessages, LayoutTarget, WidgetDiff } from "@graphite/messages";
import { parseWidgetDiffs } from "@graphite/utility-functions/widgets";
import { type EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
import { type JsMessageType, messageMakers, type JsMessage } from "@graphite/messages";
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
// Don't know a better way of typing this since it can be any subclass of JsMessage
// The functions interacting with this map are strongly typed though around JsMessage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JsMessageCallbackMap = Record<string, JsMessageCallback<any> | undefined>;
type FrontendMessageCallbacks = Record<string, ((messageData: any) => void) | undefined>;
export function createSubscriptionRouter() {
const subscriptions: JsMessageCallbackMap = {};
const subscriptions: FrontendMessageCallbacks = {};
const layoutCallbacks: Partial<Record<LayoutTarget, (diffs: WidgetDiff[]) => void>> = {};
const subscribeJsMessage = <T extends JsMessage, Args extends unknown[]>(messageType: new (...args: Args) => T, callback: JsMessageCallback<T>) => {
subscriptions[messageType.name] = callback;
const subscribeFrontendMessage = <T extends keyof FrontendMessages>(messageType: T, callback: (data: FrontendMessages[T]) => void) => {
subscriptions[messageType] = callback;
};
const unsubscribeJsMessage = <T extends JsMessage>(messageType: new () => T) => {
delete subscriptions[messageType.name];
const unsubscribeFrontendMessage = (messageType: keyof FrontendMessages) => {
delete subscriptions[messageType];
};
const handleJsMessage = (messageType: JsMessageType, messageData: Record<string, unknown>, wasm: WebAssembly.Memory, handle: EditorHandle) => {
// Find the message maker for the message type, which can either be a JS class constructor or a function that returns an instance of the JS class
const messageMaker = messageMakers[messageType];
if (!messageMaker) {
// eslint-disable-next-line no-console
console.error(
`Received a frontend message of type "${messageType}" but was not able to parse the data. ` +
"(Perhaps this message parser isn't exported in `messageMakers` at the bottom of `messages.ts`.)",
);
return;
const subscribeLayoutUpdate = (target: LayoutTarget, callback: (diffs: WidgetDiff[]) => void) => {
layoutCallbacks[target] = callback;
};
const unsubscribeLayoutUpdate = (target: LayoutTarget) => {
delete layoutCallbacks[target];
};
const handleFrontendMessage = (messageType: keyof FrontendMessages, messageData: Record<string, unknown>) => {
// Messages with non-empty data are provided by Serde JSON as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
// Messages with empty data are provided by Serde JSON as a string with the message name, like: "NameOfThisMessage"
// Here we extract the payload object or use an empty object depending on the situation.
const message = messageData[messageType] || {};
// Resolve the callback lookup and the data to pass, depending on whether this is a layout update or a regular message.
// UpdateLayout messages are dispatched to layout-specific callbacks based on the layout target.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let getCallback: () => ((data: any) => void) | undefined;
let callbackData: unknown;
let errorLabel: string;
if (messageType === "UpdateLayout") {
const { layoutTarget, diff } = message as FrontendMessages["UpdateLayout"];
getCallback = () => layoutCallbacks[layoutTarget];
callbackData = parseWidgetDiffs(diff);
errorLabel = `UpdateLayout for layout target "${layoutTarget}"`;
} else {
getCallback = () => subscriptions[messageType];
callbackData = message;
errorLabel = messageType;
}
// Checks if the provided `messageMaker` is a class extending `JsMessage`. All classes inheriting from `JsMessage` will have a static readonly `jsMessageMarker` which is `true`.
const isJsMessageMaker = (fn: typeof messageMaker): fn is typeof JsMessage => "jsMessageMarker" in fn;
const messageIsClass = isJsMessageMaker(messageMaker);
// Messages with non-empty data are provided by wasm-bindgen as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
// Messages with empty data are provided by wasm-bindgen as a string with the message name, like: "NameOfThisMessage"
// Here we extract the payload object or use an empty object depending on the situation.
const unwrappedMessageData = messageData[messageType] || {};
// Converts to a `JsMessage` object by turning the JSON message data into an instance of the message class, either automatically or by calling the function that builds it.
// If the `messageMaker` is a `JsMessage` class then we use the class-transformer library's `plainToInstance` function in order to convert the JSON data into the destination class.
// If it is not a `JsMessage` then it should be a custom function that creates a JsMessage from a JSON, so we call the function itself with the raw JSON as an argument.
// The resulting `message` is an instance of a class that extends `JsMessage`.
const message = messageIsClass ? plainToInstance(messageMaker, unwrappedMessageData) : messageMaker(unwrappedMessageData, wasm, handle);
// If we have constructed a valid message, then we try and execute the callback that the frontend has associated with this message.
// The frontend should always have a callback for all messages, but due to message ordering, we might have to delay a few stack frames until we do.
// Try to execute the callback. Due to message ordering, the callback may not be registered yet,
// so we retry a few times on the next stack frame to give onMount a chance to run.
let retries = 0;
const callCallback = () => {
// It is ok to use constructor.name even with minification since it is used consistently with registerHandler
const callback = subscriptions[message.constructor.name];
const callback = getCallback();
// Attempt to call the callback, but try again several times on the next stack frame if it is not yet registered due to message ordering.
if (callback) {
callback(message);
callback(callbackData);
} else if (retries <= 3) {
retries += 1;
setTimeout(callCallback, 0);
} else {
// eslint-disable-next-line no-console
console.error(`Received a frontend message of type "${messageType}" but no handler was registered for it from the client.`);
console.error(`Received a frontend message of type "${errorLabel}" but no handler was registered for it from the client.`);
}
};
@@ -70,9 +68,11 @@ export function createSubscriptionRouter() {
};
return {
subscribeJsMessage,
unsubscribeJsMessage,
handleJsMessage,
subscribeFrontendMessage,
unsubscribeFrontendMessage,
subscribeLayoutUpdate,
unsubscribeLayoutUpdate,
handleFrontendMessage,
};
}
export type SubscriptionRouter = ReturnType<typeof createSubscriptionRouter>;
+257
View File
@@ -0,0 +1,257 @@
import { sampleInterpolatedGradient } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Color, FillChoice, Gradient } from "@graphite/messages";
// 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 };
export type RGB = { r: number; g: number; b: number };
// COLOR FACTORY FUNCTIONS
export function createColor(red: number, green: number, blue: number, alpha: number): Color {
return { red, green, blue, alpha, none: false };
}
export function createNoneColor(): Color {
return { red: 0, green: 0, blue: 0, alpha: 1, none: true };
}
export function createColorFromHSVA(h: number, s: number, v: number, a: number): Color {
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, none: false };
}
// COLOR UTILITY FUNCTIONS
export function isColor(value: unknown): value is Color {
return typeof value === "object" && value !== null && "red" in value;
}
export function colorFromCSS(colorCode: string): Color | 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))) {
const digit = colorValue.charAt(1);
colorValue = `#${digit}${digit}${digit}`;
}
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return undefined;
context.clearRect(0, 0, 1, 1);
context.fillStyle = "black";
context.fillStyle = colorValue;
const comparisonA = context.fillStyle;
context.fillStyle = "white";
context.fillStyle = colorValue;
const comparisonB = context.fillStyle;
// 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()}`);
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);
}
export function colorEquals(c1: Color, c2: Color): boolean {
if (c1.none !== c2.none) return false;
if (c1.none && c2.none) return true;
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 | undefined {
if (color.none) return undefined;
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");
return `#${r}${g}${b}`;
}
export function colorToHexOptionalAlpha(color: Color): string | undefined {
if (color.none) return undefined;
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 | undefined {
if (color.none) return undefined;
return {
r: Math.round(color.red * 255),
g: Math.round(color.green * 255),
b: Math.round(color.blue * 255),
};
}
export function colorToRgbCSS(color: Color): string | undefined {
const rgb = colorToRgb255(color);
if (!rgb) return undefined;
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
}
export function colorToRgbaCSS(color: Color): string | undefined {
const rgb = colorToRgb255(color);
if (!rgb) return undefined;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${color.alpha})`;
}
export function colorToHSV(color: Color): HSV | undefined {
if (color.none) return undefined;
const { red: r, green: g, blue: b } = color;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
const s = max === 0 ? 0 : d / max;
const v = max;
let h = 0;
if (max !== min) {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
}
h /= 6;
}
return { h, s, v };
}
export function colorOpaque(color: Color): Color | undefined {
if (color.none) return undefined;
return createColor(color.red, color.green, color.blue, 1);
}
export function colorLuminance(color: Color): number | undefined {
if (color.none) return undefined;
// 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);
// https://stackoverflow.com/a/3943023/775283
const linearR = r <= 0.04045 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
const linearG = g <= 0.04045 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
const linearB = b <= 0.04045 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
}
export function colorContrastingColor(color: Color): "black" | "white" {
if (color.none) return "black";
const luminance = colorLuminance(color);
return luminance && luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
}
export function contrastingOutlineFactor(value: FillChoice, 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)) || createNoneColor());
const contrast = (color: Color): number => {
const lum = colorLuminance(color) || 0;
let rangeLuminance1 = colorLuminance(range1) || 0;
let rangeLuminance2 = colorLuminance(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 || 0));
};
if (isGradient(value)) {
if (value.color.length === 0) return 0;
const first = contrast(value.color[0]);
const last = contrast(value.color[value.color.length - 1]);
return Math.min(first, last);
}
return contrast(value);
}
// GRADIENT UTILITY FUNCTIONS
export function isGradient(value: unknown): value is Gradient {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value;
}
export function gradientToLinearGradientCSS(gradient: Gradient): 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: Gradient): Color | undefined {
return gradient.color[0];
}
export function gradientLastColor(gradient: Gradient): Color | undefined {
return gradient.color[gradient.color.length - 1];
}
// FILL CHOICE UTILITY FUNCTIONS
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseFillChoice(value: any): FillChoice {
if (isColor(value)) return value;
if (isGradient(value)) return value;
const gradient: Gradient | undefined = value["Gradient"];
if (gradient) {
const color = gradient.color.map((c) => createColor(c.red, c.green, c.blue, c.alpha));
return { ...gradient, color };
}
const solid = value["Solid"];
if (solid) return createColor(solid.red, solid.green, solid.blue, solid.alpha);
return createNoneColor();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Editor } from "@graphite/editor";
import type { Editor } from "@graphite/editor";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
export function downloadFileURL(filename: string, url: string) {
@@ -10,7 +10,7 @@ export function panicProxy<T extends object>(module: T): T {
if (!isFunction) return targetValue;
// Special handling to wrap the return of a constructor in the proxy
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
const isClass = isFunction && /^\s*class\s+/.test(String(targetValue));
if (isClass) {
return function (...args: unknown[]): unknown {
// All three of these comment lines are necessary to suppress errors at both compile time and while editing this file (@ts-expect-error doesn't work here while editing the file)
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Editor } from "@graphite/editor";
import type { Editor } from "@graphite/editor";
let resizeObserver: ResizeObserver | undefined;
+131
View File
@@ -0,0 +1,131 @@
import type { Layout, LayoutGroup, UIItem, WidgetDiff, WidgetInstance, WidgetSection, WidgetSpanColumn, WidgetSpanRow, WidgetTable } from "@graphite/messages";
export function isWidgetSpanColumn(layoutColumn: LayoutGroup): layoutColumn is WidgetSpanColumn {
return Boolean((layoutColumn as WidgetSpanColumn)?.columnWidgets);
}
export function isWidgetSpanRow(layoutRow: LayoutGroup): layoutRow is WidgetSpanRow {
return Boolean((layoutRow as WidgetSpanRow)?.rowWidgets);
}
export function isWidgetTable(layoutTable: LayoutGroup): layoutTable is WidgetTable {
return Boolean((layoutTable as WidgetTable)?.tableWidgets);
}
export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection {
return Boolean((layoutRow as WidgetSection)?.layout);
}
/// Unwraps the Serde tagged enum `{ widgetId, widget: { Kind: props } }` into `{ widgetId, props: { kind, ...props } }`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseWidgetInstance(widgetInstance: any): WidgetInstance {
const widgetId = widgetInstance.widgetId;
const kind = Object.keys(widgetInstance.widget)[0];
const props = widgetInstance.widget[kind];
props.kind = kind;
return { widgetId, props };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseWidgetDiffs(rawDiffs: any): WidgetDiff[] {
return rawDiffs.map((diff: WidgetDiff) => {
const { widgetPath, newValue } = diff;
if ("layout" in newValue) return { widgetPath, newValue: newValue.layout.map(createLayoutGroup) };
if ("layoutGroup" in newValue) return { widgetPath, newValue: createLayoutGroup(newValue.layoutGroup) };
if ("widget" in newValue) return { widgetPath, newValue: parseWidgetInstance(newValue.widget) };
// This code should be unreachable
throw new Error("DiffUpdate invalid");
});
}
// Updates a widget layout based on a list of updates, giving the new layout by mutating the `layout` argument
export function patchLayout(layout: /* &mut */ Layout, diffs: WidgetDiff[]) {
diffs.forEach((update) => {
// Find the object where the diff applies to
const diffObject = update.widgetPath.reduce((targetLayout: UIItem | undefined, index: number): UIItem | undefined => {
if (targetLayout && "columnWidgets" in targetLayout) return targetLayout.columnWidgets[index];
if (targetLayout && "rowWidgets" in targetLayout) return targetLayout.rowWidgets[index];
if (targetLayout && "tableWidgets" in targetLayout) return targetLayout.tableWidgets[index];
if (targetLayout && "layout" in targetLayout) return targetLayout.layout[index];
if (targetLayout && "props" in targetLayout && "widgetId" in targetLayout) {
if (targetLayout.props.kind === "PopoverButton" && "popoverLayout" in targetLayout.props && targetLayout.props.popoverLayout) {
targetLayout.props.popoverLayout = targetLayout.props.popoverLayout.map(createLayoutGroup);
return targetLayout.props.popoverLayout[index];
}
// eslint-disable-next-line no-console
console.error("Tried to index widget");
return targetLayout;
}
return targetLayout?.[index];
}, layout as UIItem);
// Exit if we failed to produce a valid patch for the existing layout.
// This means that the backend assumed an existing layout that doesn't exist in the frontend. This can happen, for
// example, if a panel is destroyed in the frontend but was never cleared in the backend, so the next time the backend
// tries to update the layout, it attempts to insert only the changes against the old layout that no longer exists.
if (diffObject === undefined) {
// eslint-disable-next-line no-console
console.error("In `patchLayout`, the `diffObject` is undefined. The layout has not been updated. See the source code comment above this error for hints.");
return;
}
// If this is a list with a length, then set the length to 0 to clear the list
if ("length" in diffObject) {
diffObject.length = 0;
}
// Remove all of the keys from the old object
Object.keys(diffObject).forEach((key) => delete (diffObject as Record<string, unknown>)[key]);
// Assign keys to the new object
// `Object.assign` works but `diffObject = update.newValue;` doesn't.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
Object.assign(diffObject, update.newValue);
});
}
// Unpacking a layout group
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createLayoutGroup(layoutGroup: any): LayoutGroup {
// Detect if this has already been parsed and, if so, return it as-is so this function can be idempotent
if ("columnWidgets" in layoutGroup || "rowWidgets" in layoutGroup || "tableWidgets" in layoutGroup || ("name" in layoutGroup && "layout" in layoutGroup)) return layoutGroup;
if (layoutGroup.column) {
const columnWidgets = layoutGroup.column.columnWidgets.map(parseWidgetInstance);
const result: WidgetSpanColumn = { columnWidgets };
return result;
}
if (layoutGroup.row) {
const result: WidgetSpanRow = { rowWidgets: layoutGroup.row.rowWidgets.map(parseWidgetInstance) };
return result;
}
if (layoutGroup.section) {
const result: WidgetSection = {
name: layoutGroup.section.name,
description: layoutGroup.section.description,
visible: layoutGroup.section.visible,
pinned: layoutGroup.section.pinned,
id: layoutGroup.section.id,
layout: layoutGroup.section.layout.map(createLayoutGroup),
};
return result;
}
if (layoutGroup.table) {
const result: WidgetTable = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tableWidgets: layoutGroup.table.tableWidgets.map((row: any) => row.map(parseWidgetInstance)),
unstyled: layoutGroup.table.unstyled,
};
return result;
}
throw new Error("Layout row type does not exist");
}