diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index f6f89953ee1..e909faac8a8 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -136,9 +136,9 @@ export default defineConfig([
rules: { "import/order": ["error", { ...importOrder, "newlines-between": "ignore" }] },
},
{
- files: ["**/*.svelte"],
+ files: ["**/*.svelte", "**/*.svelte.ts"],
languageOptions: {
- // Parse the `
{#each layout as layoutGroup}
diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte
index e391d0e66c7..9e3d5f5d855 100644
--- a/frontend/src/components/widgets/WidgetSpan.svelte
+++ b/frontend/src/components/widgets/WidgetSpan.svelte
@@ -42,19 +42,21 @@
const editor = getContext("editor");
const colorPickerStore = getContext("colorPicker");
- export let widgets: WidgetInstance[];
- export let direction: "row" | "column";
- export let layoutTarget: LayoutTarget;
-
- let className = "";
- export { className as class };
- export let classes: Record = {};
-
- export let narrow = false;
+ type Props = {
+ widgets: WidgetInstance[];
+ direction: "row" | "column";
+ layoutTarget: LayoutTarget;
+ class?: string;
+ classes?: Record;
+ narrow?: boolean;
+ };
+ let { widgets, direction, layoutTarget, class: className = "", classes = {}, narrow = false }: Props = $props();
- $: extraClasses = Object.entries(classes)
- .flatMap(([className, stateName]) => (stateName ? [className] : []))
- .join(" ");
+ const extraClasses = $derived(
+ Object.entries(classes)
+ .flatMap(([className, stateName]) => (stateName ? [className] : []))
+ .join(" "),
+ );
function widgetValueCommit(widgetIndex: number, value: unknown) {
editor.widgetValueCommit(layoutTarget, widgets[widgetIndex].widgetId, value);
@@ -362,11 +364,11 @@
{#each widgets as widget, widgetIndex}
{@const unwrapped = unwrapWidget(widget)}
{#if unwrapped}
- {@const { component, props, slot } = resolveWidget(unwrapped, widgetIndex)}
+ {@const { component: Component, props, slot } = resolveWidget(unwrapped, widgetIndex)}
{#if props !== undefined && slot !== undefined}
- {slot}
+ {slot}
{:else if props !== undefined}
-
+
{/if}
{/if}
{/each}
diff --git a/frontend/src/components/widgets/WidgetTable.svelte b/frontend/src/components/widgets/WidgetTable.svelte
index f20924bbb05..a9b6e1d43f6 100644
--- a/frontend/src/components/widgets/WidgetTable.svelte
+++ b/frontend/src/components/widgets/WidgetTable.svelte
@@ -2,10 +2,13 @@
import WidgetSpan from "/src/components/widgets/WidgetSpan.svelte";
import type { LayoutTarget, WidgetTable } from "/wrapper/pkg/graphite_wasm_wrapper";
- export let widgetData: WidgetTable;
- export let layoutTarget: LayoutTarget;
+ type Props = {
+ widgetData: WidgetTable;
+ layoutTarget: LayoutTarget;
+ };
+ let { widgetData, layoutTarget }: Props = $props();
- $: columns = widgetData.tableWidgets.length > 0 ? widgetData.tableWidgets[0].length : 0;
+ const columns = $derived(widgetData.tableWidgets.length > 0 ? widgetData.tableWidgets[0].length : 0);
diff --git a/frontend/src/components/window/StatusBar.svelte b/frontend/src/components/window/StatusBar.svelte
index 99deabb6019..33c8c1e8ced 100644
--- a/frontend/src/components/window/StatusBar.svelte
+++ b/frontend/src/components/window/StatusBar.svelte
@@ -4,23 +4,21 @@
import Separator from "/src/components/widgets/labels/Separator.svelte";
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
+ import { createLayout } from "/src/utility-functions/layout-state.svelte";
import { patchLayout } from "/src/utility-functions/widgets";
- import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
const subscriptions = getContext("subscriptions");
- let statusBarHintsLayout: Layout = [];
- let statusBarInfoLayout: Layout = [];
+ const statusBarHintsLayout = createLayout();
+ const statusBarInfoLayout = createLayout();
onMount(() => {
subscriptions.subscribeLayoutUpdate("StatusBarHints", (data) => {
patchLayout(statusBarHintsLayout, data);
- statusBarHintsLayout = statusBarHintsLayout;
});
subscriptions.subscribeLayoutUpdate("StatusBarInfo", (data) => {
patchLayout(statusBarInfoLayout, data);
- statusBarInfoLayout = statusBarInfoLayout;
});
});
diff --git a/frontend/src/components/window/TitleBar.svelte b/frontend/src/components/window/TitleBar.svelte
index a93c0181efa..481c306acf5 100644
--- a/frontend/src/components/window/TitleBar.svelte
+++ b/frontend/src/components/window/TitleBar.svelte
@@ -8,8 +8,9 @@
import type { FullscreenStore } from "/src/stores/fullscreen";
import type { TooltipStore } from "/src/stores/tooltip";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
+ import { createLayout } from "/src/utility-functions/layout-state.svelte";
import { patchLayout } from "/src/utility-functions/widgets";
- import type { EditorWrapper, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
+ import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
@@ -19,7 +20,7 @@
const fullscreen = getContext("fullscreen");
const tooltip = getContext("tooltip");
- let menuBarLayout: Layout = [];
+ const menuBarLayout = createLayout();
$: showFullscreenButton = $appWindow.platform === "Web" || $fullscreen.windowFullscreen || (import.meta.env.MODE === "native" && $appWindow.fullscreen);
$: isFullscreen = import.meta.env.MODE === "native" ? $appWindow.fullscreen : $fullscreen.windowFullscreen;
@@ -29,7 +30,6 @@
onMount(() => {
subscriptions.subscribeLayoutUpdate("MenuBar", (data) => {
patchLayout(menuBarLayout, data);
- menuBarLayout = menuBarLayout;
});
});
diff --git a/frontend/src/stores/dialog.ts b/frontend/src/stores/dialog.ts
index dbc4145b612..9198034d630 100644
--- a/frontend/src/stores/dialog.ts
+++ b/frontend/src/stores/dialog.ts
@@ -1,4 +1,3 @@
-import { tick } from "svelte";
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { IconName } from "/src/icons";
@@ -51,9 +50,7 @@ export function createDialogStore(subscriptions: SubscriptionsRouter, editor: Ed
});
});
- subscriptions.subscribeLayoutUpdate("DialogButtons", async (data) => {
- await tick();
-
+ subscriptions.subscribeLayoutUpdate("DialogButtons", (data) => {
update((state) => {
patchLayout(state.buttons, data);
@@ -61,9 +58,7 @@ export function createDialogStore(subscriptions: SubscriptionsRouter, editor: Ed
});
});
- subscriptions.subscribeLayoutUpdate("DialogColumn1", async (data) => {
- await tick();
-
+ subscriptions.subscribeLayoutUpdate("DialogColumn1", (data) => {
update((state) => {
patchLayout(state.column1, data);
@@ -71,9 +66,7 @@ export function createDialogStore(subscriptions: SubscriptionsRouter, editor: Ed
});
});
- subscriptions.subscribeLayoutUpdate("DialogColumn2", async (data) => {
- await tick();
-
+ subscriptions.subscribeLayoutUpdate("DialogColumn2", (data) => {
update((state) => {
patchLayout(state.column2, data);
diff --git a/frontend/src/stores/document.ts b/frontend/src/stores/document.ts
index 905c481bd54..b67ec21ecbf 100644
--- a/frontend/src/stores/document.ts
+++ b/frontend/src/stores/document.ts
@@ -1,7 +1,7 @@
-import { tick } from "svelte";
-import { writable } from "svelte/store";
+import { get, writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
+import { createLayout } from "/src/utility-functions/layout-state.svelte";
import { patchLayout } from "/src/utility-functions/widgets";
import type { Layout, RenderStatus } from "/wrapper/pkg/graphite_wasm_wrapper";
@@ -18,11 +18,11 @@ type DocumentStoreState = {
renderStatus: RenderStatus;
};
const initialState: DocumentStoreState = {
- toolOptionsLayout: [],
- documentBarLayout: [],
- toolShelfLayout: [],
- workingColorsLayout: [],
- nodeGraphControlBarLayout: [],
+ toolOptionsLayout: createLayout(),
+ documentBarLayout: createLayout(),
+ toolShelfLayout: createLayout(),
+ workingColorsLayout: createLayout(),
+ nodeGraphControlBarLayout: createLayout(),
graphViewOverlayOpen: false,
fadeArtwork: 100,
renderStatus: "Rendering",
@@ -47,49 +47,24 @@ export function createDocumentStore(subscriptions: SubscriptionsRouter) {
});
});
- subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => {
- await tick();
-
- update((state) => {
- patchLayout(state.toolOptionsLayout, data);
- return state;
- });
+ subscriptions.subscribeLayoutUpdate("ToolOptions", (data) => {
+ patchLayout(get(store).toolOptionsLayout, data);
});
- subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => {
- await tick();
-
- update((state) => {
- patchLayout(state.documentBarLayout, data);
- return state;
- });
+ subscriptions.subscribeLayoutUpdate("DocumentBar", (data) => {
+ patchLayout(get(store).documentBarLayout, data);
});
- subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => {
- await tick();
-
- update((state) => {
- patchLayout(state.toolShelfLayout, data);
- return state;
- });
+ subscriptions.subscribeLayoutUpdate("ToolShelf", (data) => {
+ patchLayout(get(store).toolShelfLayout, data);
});
- subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => {
- await tick();
-
- update((state) => {
- patchLayout(state.workingColorsLayout, data);
- return state;
- });
+ subscriptions.subscribeLayoutUpdate("WorkingColors", (data) => {
+ patchLayout(get(store).workingColorsLayout, data);
});
- subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", async (data) => {
- await tick();
-
- update((state) => {
- patchLayout(state.nodeGraphControlBarLayout, data);
- return state;
- });
+ subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", (data) => {
+ patchLayout(get(store).nodeGraphControlBarLayout, data);
});
subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => {
diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts
index acff69036a9..625b08f3b72 100644
--- a/frontend/src/stores/portfolio.ts
+++ b/frontend/src/stores/portfolio.ts
@@ -1,9 +1,9 @@
-import { tick } from "svelte";
import { SvelteMap } from "svelte/reactivity";
-import { writable } from "svelte/store";
+import { get, writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { acceptStringFromFilters, downloadFile, upload } from "/src/utility-functions/files";
+import { createLayout } from "/src/utility-functions/layout-state.svelte";
import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization";
import { patchLayout } from "/src/utility-functions/widgets";
import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
@@ -37,28 +37,20 @@ const store: Writable = import.meta.hot?.data?.store || wri
if (import.meta.hot) import.meta.hot.data.store = store;
const { subscribe, update } = store;
-export const welcomeScreenButtonsLayout = makeLayoutStore("welcomeScreenButtonsLayout");
-export const inspectorLayout = makeLayoutStore("inspectorLayout");
-export const dataPanelLayout = makeLayoutStore("dataPanelLayout");
-export const layersPanelControlBarLeftLayout = makeLayoutStore("layersPanelControlBarLeftLayout");
-export const layersPanelBottomBarLeftLayout = makeLayoutStore("layersPanelBottomBarLeftLayout");
-export const layersPanelBottomBarRightLayout = makeLayoutStore("layersPanelBottomBarRightLayout");
+export const welcomeScreenButtonsLayout = persistedLayout("welcomeScreenButtonsLayout");
+export const inspectorLayout = persistedLayout("inspectorLayout");
+export const dataPanelLayout = persistedLayout("dataPanelLayout");
+export const layersPanelControlBarLeftLayout = persistedLayout("layersPanelControlBarLeftLayout");
+export const layersPanelBottomBarLeftLayout = persistedLayout("layersPanelBottomBarLeftLayout");
+export const layersPanelBottomBarRightLayout = persistedLayout("layersPanelBottomBarRightLayout");
-// Each panel layout has its own dedicated store so a layout update only re-renders that panel's consumers.
+// Each panel layout is its own reactive layout, patched in place so an update only re-renders the widgets it changes.
// Putting them at module scope (not inside the component) lets them survive a Svelte remount during a
// panel-tree restructure, since the backend's diff-based updates aren't re-sent on subscribe.
-function makeLayoutStore(name: string): Writable {
- const persisted = import.meta.hot?.data?.[name];
- const layoutStore: Writable = persisted || writable([]);
- if (import.meta.hot) import.meta.hot.data[name] = layoutStore;
- return layoutStore;
-}
-
-function patchLayoutStore(layoutStore: Writable, data: Parameters[1]) {
- layoutStore.update((layout) => {
- patchLayout(layout, data);
- return layout;
- });
+function persistedLayout(name: string): Layout {
+ const layout: Layout = import.meta.hot?.data?.[name] || createLayout();
+ if (import.meta.hot) import.meta.hot.data[name] = layout;
+ return layout;
}
export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
@@ -132,38 +124,34 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
});
});
- // Each panel layout uses its own store so updates only re-render that panel's consumers
- subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => {
- await tick();
- patchLayoutStore(welcomeScreenButtonsLayout, data);
+ subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", (data) => {
+ patchLayout(welcomeScreenButtonsLayout, data);
});
- subscriptions.subscribeLayoutUpdate("Inspector", async (data) => {
- await tick();
- patchLayoutStore(inspectorLayout, data);
+ subscriptions.subscribeLayoutUpdate("Inspector", (data) => {
+ patchLayout(inspectorLayout, data);
});
- subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => {
- await tick();
- patchLayoutStore(dataPanelLayout, data);
+ subscriptions.subscribeLayoutUpdate("DataPanel", (data) => {
+ patchLayout(dataPanelLayout, data);
});
- subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => {
- await tick();
- patchLayoutStore(layersPanelControlBarLeftLayout, data);
+ subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", (data) => {
+ patchLayout(layersPanelControlBarLeftLayout, data);
});
- subscriptions.subscribeLayoutUpdate("LayersPanelBottomLeftBar", async (data) => {
- await tick();
- patchLayoutStore(layersPanelBottomBarLeftLayout, data);
+ subscriptions.subscribeLayoutUpdate("LayersPanelBottomLeftBar", (data) => {
+ patchLayout(layersPanelBottomBarLeftLayout, data);
});
- subscriptions.subscribeLayoutUpdate("LayersPanelBottomRightBar", async (data) => {
- await tick();
- patchLayoutStore(layersPanelBottomBarRightLayout, data);
+ subscriptions.subscribeLayoutUpdate("LayersPanelBottomRightBar", (data) => {
+ patchLayout(layersPanelBottomBarRightLayout, data);
});
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => {
+ // The backend resends the structure whenever the graph changes, so an unchanged one is skipped instead of rebuilding the Layers panel
+ if (deepEqual(get(store).layerStructure, data.layerStructure)) return;
+
update((state) => {
state.layerStructure = data.layerStructure;
return state;
@@ -183,6 +171,17 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
return { subscribe };
}
+// Whether two values built from plain objects, arrays and primitives are structurally equal
+function deepEqual(first: unknown, second: unknown): boolean {
+ if (Object.is(first, second)) return true;
+ if (typeof first !== "object" || typeof second !== "object" || !first || !second) return false;
+ if (Array.isArray(first) !== Array.isArray(second)) return false;
+
+ const firstEntries = Object.entries(first);
+ if (firstEntries.length !== Object.keys(second).length) return false;
+ return firstEntries.every(([key, value]) => Object.hasOwn(second, key) && deepEqual(value, Reflect.get(second, key)));
+}
+
// Keeps only the given layers' details (by ID string), the only ones the backend keeps current, or none if those kept were for another document
export function retainLayerDetails(layerIds: Set, otherDocument: boolean) {
layersWithDetails = layerIds;
diff --git a/frontend/src/utility-functions/colors.ts b/frontend/src/utility-functions/colors.ts
index 362ec5297c0..37900a4af31 100644
--- a/frontend/src/utility-functions/colors.ts
+++ b/frontend/src/utility-functions/colors.ts
@@ -31,6 +31,27 @@ export function isSRgba8(value: unknown): value is SRGBA8 {
return !!value && typeof value === "object" && "red" in value;
}
+let colorParsingCanvasContext: CanvasRenderingContext2D | undefined;
+
+// The 1x1 canvas context that color parsing reuses
+function colorParsingContext(): CanvasRenderingContext2D | undefined {
+ if (colorParsingCanvasContext) return colorParsingCanvasContext;
+
+ const canvas = document.createElement("canvas");
+ canvas.width = 1;
+ canvas.height = 1;
+ colorParsingCanvasContext = canvas.getContext("2d", { willReadFrequently: true }) || undefined;
+ return colorParsingCanvasContext;
+}
+
+// The theme colors by CSS variable name, read once since reading computed styles forces a style recalculation
+const themeColors = new Map();
+
+function themeColor(variableName: string): SRGBA8 | undefined {
+ if (!themeColors.has(variableName)) themeColors.set(variableName, sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(variableName)));
+ return themeColors.get(variableName);
+}
+
// Parse a CSS color string into an `SRGBA8`. Uses a canvas to delegate parsing to the browser.
export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined {
// Allow single-digit hex value inputs
@@ -40,10 +61,7 @@ export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined {
colorValue = `#${digit}${digit}${digit}`;
}
- const canvas = document.createElement("canvas");
- canvas.width = 1;
- canvas.height = 1;
- const context = canvas.getContext("2d", { willReadFrequently: true });
+ const context = colorParsingContext();
if (!context) return undefined;
context.clearRect(0, 0, 1, 1);
@@ -152,7 +170,7 @@ export function sRgba8ContrastingColor(color: SRGBA8 | undefined): "black" | "wh
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) => sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
+ const [range1, range2] = pair.map(themeColor);
const contrast = (color: SRGBA8 | undefined): number => {
if (!color) return 0;
diff --git a/frontend/src/utility-functions/layout-state.svelte.ts b/frontend/src/utility-functions/layout-state.svelte.ts
new file mode 100644
index 00000000000..2d0ca0ee464
--- /dev/null
+++ b/frontend/src/utility-functions/layout-state.svelte.ts
@@ -0,0 +1,7 @@
+import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
+
+// A layout held in a deeply reactive proxy, so `patchLayout` changing it in place re-runs only the effects that read the changed widgets
+export function createLayout(): Layout {
+ const layout = $state([]);
+ return layout;
+}
diff --git a/frontend/src/utility-functions/widgets.ts b/frontend/src/utility-functions/widgets.ts
index e47726eb9b9..1ad5c8598ad 100644
--- a/frontend/src/utility-functions/widgets.ts
+++ b/frontend/src/utility-functions/widgets.ts
@@ -21,6 +21,7 @@ export function styleText(styles: Record): string | undefined {
}
type UIItem = Layout | LayoutGroup | WidgetInstance[][] | WidgetInstance[] | WidgetInstance;
+
// 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) => {
@@ -32,46 +33,71 @@ export function patchLayout(layout: /* &mut */ Layout, diffs: WidgetDiff[]) {
else if ("widget" in newValue) newContent = newValue.widget;
else throw new Error("DiffUpdate invalid");
- // Find the object where the diff applies to
- const diffObject = update.widgetPath.reduce((targetLayout, index: bigint): UIItem | undefined => {
- const i = Number(index);
-
- if (targetLayout && "Column" in targetLayout) return targetLayout.Column.columnWidgets[i];
- if (targetLayout && "Row" in targetLayout) return targetLayout.Row.rowWidgets[i];
- if (targetLayout && "Table" in targetLayout) return targetLayout.Table.tableWidgets[i];
- if (targetLayout && "NodeSection" in targetLayout) return targetLayout.NodeSection.layout[i];
- if (targetLayout && "widget" in targetLayout && "widgetId" in targetLayout) {
- if ("PopoverButton" in targetLayout.widget && targetLayout.widget.PopoverButton.popoverLayout) {
- return targetLayout.widget.PopoverButton.popoverLayout[i];
- }
- // eslint-disable-next-line no-console
- console.error("Tried to index widget");
- return targetLayout;
- }
+ // An empty path updates the whole layout
+ const path = update.widgetPath.map(Number);
+ const lastIndex = path.pop();
+ if (lastIndex === undefined) {
+ if (isPlainContainer(newContent)) reconcile(layout, newContent);
+ return;
+ }
- return targetLayout?.[i];
- }, layout);
+ // Find the list holding the item the diff applies to
+ const parent = path.reduce((list: UIItem[] | undefined, index) => indexedChildren(list?.[index]), indexedChildren(layout));
// 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) {
+ if (parent?.[lastIndex] === 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.");
+ console.error("In `patchLayout`, the diff's target 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) => Reflect.deleteProperty(diffObject, 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, newContent);
+ reconcileEntry(parent, lastIndex, newContent);
});
}
+
+// The list that an index in a widget path selects from within the given layout item
+function indexedChildren(item: UIItem | undefined): UIItem[] | undefined {
+ if (!item) return undefined;
+ if (Array.isArray(item)) return item;
+ if ("Column" in item) return item.Column.columnWidgets;
+ if ("Row" in item) return item.Row.rowWidgets;
+ if ("Table" in item) return item.Table.tableWidgets;
+ if ("NodeSection" in item) return item.NodeSection.layout;
+ if ("PopoverButton" in item.widget) return item.widget.PopoverButton.popoverLayout;
+ return undefined;
+}
+
+// Whether the value is a plain object or array, the only kinds a layout is built from
+function isPlainContainer(value: unknown): value is object {
+ if (typeof value !== "object" || !value) return false;
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === Array.prototype;
+}
+
+// Sets `container[key]` to `value`, changing the existing value in place where it keeps its shape so a reactive layout only invalidates what changed.
+// A value that changes shape (like a widget changing kind) is replaced whole, so readers never see an object half-changed.
+function reconcileEntry(container: object, key: PropertyKey, value: unknown) {
+ const existing: unknown = Reflect.get(container, key);
+
+ if (isPlainContainer(existing) && isPlainContainer(value) && haveSameShape(existing, value)) {
+ reconcile(existing, value);
+ } else if (!Object.is(existing, value)) {
+ Reflect.set(container, key, value);
+ }
+}
+
+// Changes `target` in place to deeply equal `source`, which has the same keys unless both are arrays
+function reconcile(target: object, source: object) {
+ if (Array.isArray(target) && Array.isArray(source) && target.length > source.length) target.length = source.length;
+ Object.entries(source).forEach(([key, value]) => reconcileEntry(target, key, value));
+}
+
+function haveSameShape(first: object, second: object): boolean {
+ if (Array.isArray(first) || Array.isArray(second)) return Array.isArray(first) && Array.isArray(second);
+
+ const firstKeys = Object.keys(first);
+ return firstKeys.length === Object.keys(second).length && firstKeys.every((key) => Object.hasOwn(second, key));
+}