diff --git a/AGENTS.md b/AGENTS.md index 0eee207873..7a396f2267 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,17 +68,7 @@ Documentation files are located in the `./docs` directory. This directory contains the docs as-is, the deployment of the docs are handled by [apps/docs](./apps/docs). A docusaurus project that syncs the docs content to its directory. When writing docs, the root `./docs` directory is the source of truth. -| directory | name | description | active | -| ------------------------------------- | ------------- | ---------------------------------------------------------------- | ------ | -| [/docs/wg](./docs/wg) | working group | working group documents, architecture documents, todo list, etc | yes | -| [/docs/reference](./docs/reference) | reference | glossary and references (technical documents) | yes | -| [/docs/math](./docs/math) | math | Math reference, used for internal docs referencing | yes | -| [/docs/platform](./docs/platform) | platform | Grida Platform (API/Spec) documents | yes | -| [/docs/editor](./docs/editor) | editor | Grida Editor - User Documentation | yes | -| [/docs/canvas](./docs/canvas) | canvas | Grida Canvas SDK - User Documentation | no | -| [/docs/cli](./docs/cli) | cli | Grida CLI - User Documentation | yes | -| [/docs/together](./docs/together) | together | Contributing, Support, Community, etc | yes | -| [/docs/with-figma](./docs/with-figma) | with-figma | Grida with Figma - Grida <-> Figma compatilibity and user guides | yes | +See [`docs/AGENTS.md`](./docs/AGENTS.md) for the docs contribution scope (we only actively maintain `docs/wg/**` and `docs/reference/**`). ## `/crates/*` diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000..d944cde41a --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,35 @@ +# Docs agent guide (`/docs`) + +This directory is the **source of truth** for documentation content. + +- **Source**: `/docs/**` (edit here) +- **Site build copy**: `/apps/docs/docs/**` (generated/synced during docs site build) +- **Published at**: `https://grida.co/docs` + +## Actively maintained + +We **only actively maintain** the following docs areas: + +- `docs/wg/**` — working group docs (design notes, architecture, proposals, WIP) +- `docs/reference/**` — reference docs (glossary, specs, stable technical references) + +## Everything else + +Other folders under `/docs` are **not actively managed**. + +- Unless you have a specific task, **avoid editing** content outside `docs/wg/**` and `docs/reference/**`. +- Do not edit generated artifacts under `/apps/docs/docs/**`. + +## Structure + +| directory | name | description | active | +| -------------------------------- | ------------- | ---------------------------------------------------------------------- | ------ | +| [/docs/wg](./wg) | working group | working group documents, architecture documents, todo list, etc | yes | +| [/docs/reference](./reference) | reference | glossary and references (technical documents) | yes | +| [/docs/math](./math) | math | Math reference, used for internal docs referencing | yes | +| [/docs/platform](./platform) | platform | Grida Platform (API/Spec) documents | yes | +| [/docs/editor](./editor) | editor | Grida Editor - User Documentation | yes | +| [/docs/canvas](./canvas) | canvas | Grida Canvas SDK - User Documentation | no | +| [/docs/cli](./cli) | cli | Grida CLI - User Documentation | yes | +| [/docs/together](./together) | together | Contributing, Support, Community, etc | yes | +| [/docs/with-figma](./with-figma) | with-figma | Grida with Figma - Grida <-> Figma compatibility and user guides | yes | diff --git a/docs/wg/feat-authoring/parametric-scaling.md b/docs/wg/feat-authoring/parametric-scaling.md index 224f3f9272..0d67a47318 100644 --- a/docs/wg/feat-authoring/parametric-scaling.md +++ b/docs/wg/feat-authoring/parametric-scaling.md @@ -2,9 +2,19 @@ title: Scale tool (K) — parameter-space scaling (A.k.a Apply Scale or K-Scale) --- -| feature id | status | description | PRs | -| -------------------- | -------- | -------------------------------------------- | --- | -| `parametric-scaling` | proposed | Parameter-space scaling operation for Grida. | - | +| feature id | status | description | PRs | +| -------------------- | -------- | -------------------------------------------- | ------------------------------------------------- | +| `parametric-scaling` | proposed | Parameter-space scaling operation for Grida. | [#471](https://github.com/gridaco/grida/pull/471) | + +## Key principles + +- **Visual accuracy over “clean” values**: Scale (K) is an authoring-time operation whose primary goal is that the post-scale render is visually consistent with a uniform similarity transform. As a result, it is normal (and expected) for authored values to become fractional / “dirty” after repeated scaling. + +- **No extra quantization / optimization in the core rewrite**: The core scaling rules should apply the exact factor $s$ to existing numeric values without “cleaning up” the result. Any additional rounding/optimization risks accumulating error over repeated operations or round trips. (Gesture-input quantization may exist for UX stability, but is intentionally out of scope for this specification.) + +- **Round-trip consistency (best-effort)**: Perfect round-trip guarantees are not always possible across multi-step edits, but for simple numeric cases the rewrite should behave consistently within the limits of JavaScript number precision. Example: $1 \\to 0.01x \\to 100x \\to 1$ should return to (approximately) the original value. + +- **Deterministic, minimal rewrite (do not change the nature of properties)**: Scaling MUST NOT reinterpret or “bake” non-numeric authored intent into numeric values. For example, a property that is `auto`, `undefined`, or otherwise non-numeric must remain so. Scale (K) **bakes existing length values**, not “bake-all”. ## Context @@ -55,7 +65,7 @@ Simple resize is insufficient because it changes box geometry but leaves geometr When applied with multiplier $s$: -- **I1. Geometry update**: box geometry parameters MUST be updated as described under “Anchor / origin”. +- **I1. Layout geometry update**: numeric layout geometry fields (e.g. `left/top/right/bottom/width/height`) MUST be scaled by $s$ without reinterpreting author intent (non-numeric values like `"auto"` MUST be preserved). - **I2. Parameter rewrite**: all tracked, geometry-contributing parameters MUST be multiplied by $s$ (or scaled according to their field-level rules). - **I3. Invariants preserved**: unitless ratios, enums, IDs, and content MUST remain unchanged. - **I4. No layout reflow**: the operation MUST NOT attempt to resolve constraints or reflow layout. It only scales stored values. @@ -73,27 +83,19 @@ This specification defines **uniform parameter-space scaling** as the baseline b (If we later support non-uniform scaling $s_x, s_y$, we must define how to map two factors into a single “thickness scale” for strokes/effects; see “Future extensions”.) -### Anchor / origin - -Scaling is performed around an **anchor point** in the selection bounds (e.g. top-left, center, etc.). - -For a node with an absolute box (`left`, `top`, `width`, `height`): +### Layout geometry (coordinate-space; no anchor) -- Compute the anchor point $A$ in parent coordinates. -- Compute the node’s reference point $P$ (typically its top-left corner at (`left`,`top`)). -- Scale the vector $\overrightarrow{AP}$ by $s$: +For parameter-space scaling we treat layout geometry fields as authored numeric values, and scale them **just like other length values**. -$$ -P' = A + (P - A) \cdot s -$$ +For a node with layout fields (`left`, `top`, `right`, `bottom`, `width`, `height`): -- Set `left/top` from $P'$ -- Set `width/height` to `width * s`, `height * s` +- If a field is a **number**, multiply it by $s$. +- If a field is **non-numeric** (e.g. `"auto"`), preserve it as-is (do not bake or resolve it). Notes: -- For nodes using `position: "relative"`, `left/top/right/bottom` are still lengths but their meaning depends on layout context. K-scale should still scale the stored values when present, but should not attempt to reflow layout. -- For container flex layout, K-scale ignores constraints/reflow (matching the intent of a proportional scale tool). +- For nodes using `position: "relative"`, offsets (`left/top/right/bottom`) are still lengths but their meaning depends on layout context. K-scale scales the stored values when present, but does not attempt to reflow layout. +- The editor UI may expose an “origin” control for interactive workflows, but anchor-based geometry rewriting is an implementation detail and is not required for the core parameter-space rewrite. ## Examples diff --git a/editor/components/cursor/cursor-data.ts b/editor/components/cursor/cursor-data.ts index e49cfeaf9c..50e401400b 100644 --- a/editor/components/cursor/cursor-data.ts +++ b/editor/components/cursor/cursor-data.ts @@ -42,6 +42,15 @@ export namespace cursors { css: pngsetcss(_default_png_url, 28, 28), }; + const _ew_resize_png_url = + "/assets/css-cursors-grida/ew-resize-64-x32y32-000000.png"; + const _ns_resize_png_url = + "/assets/css-cursors-grida/ns-resize-64-x32y32-000000.png"; + const _nesw_resize_png_url = + "/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.png"; + const _nwse_resize_png_url = + "/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.png"; + export const default_svg = { data: ( width: number = 32, @@ -77,16 +86,41 @@ export namespace cursors { return `data:image/svg+xml;base64,${btoa(svgData)}`; }; + /** + * Scale tool (K) cursor set — uses designed resize cursors. + * + * Files live under `editor/public/assets/css-cursors-grida/`. + */ + const _ew_resize_scale_png_url = + "/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.png"; + const _ns_resize_scale_png_url = + "/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.png"; + const _nesw_resize_scale_png_url = + "/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.png"; + const _nwse_resize_scale_png_url = + "/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.png"; + + export const resize_handle_scale_cursor_map = { + nw: pngsetcss(_nwse_resize_scale_png_url, 32, 32, "nwse-resize"), + n: pngsetcss(_ns_resize_scale_png_url, 32, 32, "ns-resize"), + ne: pngsetcss(_nesw_resize_scale_png_url, 32, 32, "nesw-resize"), + e: pngsetcss(_ew_resize_scale_png_url, 32, 32, "ew-resize"), + se: pngsetcss(_nwse_resize_scale_png_url, 32, 32, "nwse-resize"), + s: pngsetcss(_ns_resize_scale_png_url, 32, 32, "ns-resize"), + sw: pngsetcss(_nesw_resize_scale_png_url, 32, 32, "nesw-resize"), + w: pngsetcss(_ew_resize_scale_png_url, 32, 32, "ew-resize"), + } as const; + export const resize_handle_cursor_map = { - nw: "nwse-resize", - n: "ns-resize", - ne: "nesw-resize", - e: "ew-resize", - se: "nwse-resize", - s: "ns-resize", - sw: "nesw-resize", - w: "ew-resize", - }; + nw: pngsetcss(_nwse_resize_png_url, 32, 32, "nwse-resize"), + n: pngsetcss(_ns_resize_png_url, 32, 32, "ns-resize"), + ne: pngsetcss(_nesw_resize_png_url, 32, 32, "nesw-resize"), + e: pngsetcss(_ew_resize_png_url, 32, 32, "ew-resize"), + se: pngsetcss(_nwse_resize_png_url, 32, 32, "nwse-resize"), + s: pngsetcss(_ns_resize_png_url, 32, 32, "ns-resize"), + sw: pngsetcss(_nesw_resize_png_url, 32, 32, "nesw-resize"), + w: pngsetcss(_ew_resize_png_url, 32, 32, "ew-resize"), + } as const; function pngsetcss( url: string, diff --git a/editor/grida-canvas-hosted/playground/uxhost-toolbar.tsx b/editor/grida-canvas-hosted/playground/uxhost-toolbar.tsx index 915e7ae490..9757b21d09 100644 --- a/editor/grida-canvas-hosted/playground/uxhost-toolbar.tsx +++ b/editor/grida-canvas-hosted/playground/uxhost-toolbar.tsx @@ -69,6 +69,7 @@ export function PlaygroundToolbar() { options={[ { value: "cursor", label: "Cursor", shortcut: "V" }, { value: "hand", label: "Hand tool", shortcut: "H" }, + { value: "scale", label: "Scale tool", shortcut: "K" }, ]} onValueChange={(v) => { editor.surface.surfaceSetTool( diff --git a/editor/grida-canvas-react-starter-kit/starterkit-icons/upscale.tsx b/editor/grida-canvas-react-starter-kit/starterkit-icons/upscale.tsx new file mode 100644 index 0000000000..2c16a070ac --- /dev/null +++ b/editor/grida-canvas-react-starter-kit/starterkit-icons/upscale.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +export const UpscaleIcon = ({ ...props }: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/editor/grida-canvas-react-starter-kit/starterkit-toolbar/index.tsx b/editor/grida-canvas-react-starter-kit/starterkit-toolbar/index.tsx index 9ab89b94a6..bcc12d0732 100644 --- a/editor/grida-canvas-react-starter-kit/starterkit-toolbar/index.tsx +++ b/editor/grida-canvas-react-starter-kit/starterkit-toolbar/index.tsx @@ -14,13 +14,12 @@ import { StarIcon, } from "@radix-ui/react-icons"; import { BrushIcon, LassoIcon, PenToolIcon, TriangleIcon } from "lucide-react"; +import { UpscaleIcon } from "../starterkit-icons/upscale"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuCheckboxItem, - DropdownMenuItem, DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; @@ -148,6 +147,7 @@ export default function Toolbar() { options={[ { value: "cursor", label: "Cursor", shortcut: "V" }, { value: "hand", label: "Hand tool", shortcut: "H" }, + { value: "scale", label: "Scale tool", shortcut: "K" }, ]} onValueChange={(v) => { editor.surface.surfaceSetTool( @@ -258,7 +258,7 @@ export function ToolsGroup({ {options.map((option) => ( {/*
*/} - + {option.label} {option.shortcut && ( @@ -283,6 +283,8 @@ export function ToolIcon({ switch (type) { case "cursor": return ; + case "scale": + return ; case "hand": return ; case "container": diff --git a/editor/grida-canvas-react-starter-kit/starterkit-toolbar/utils.ts b/editor/grida-canvas-react-starter-kit/starterkit-toolbar/utils.ts index 0e3f0fae9c..f1359ed60d 100644 --- a/editor/grida-canvas-react-starter-kit/starterkit-toolbar/utils.ts +++ b/editor/grida-canvas-react-starter-kit/starterkit-toolbar/utils.ts @@ -2,6 +2,7 @@ import type { editor } from "@/grida-canvas"; export type ToolbarToolType = | "cursor" + | "scale" | "hand" | "rectangle" | "ellipse" @@ -27,6 +28,8 @@ export function toolmode_to_toolbar_value( case "cursor": case "zoom": return "cursor"; + case "scale": + return "scale"; case "hand": return "hand"; case "insert": @@ -56,6 +59,8 @@ export function toolbar_value_to_cursormode( switch (tt) { case "cursor": return { type: "cursor" }; + case "scale": + return { type: "scale" }; case "hand": return { type: "hand" }; case "container": diff --git a/editor/grida-canvas-react/provider.tsx b/editor/grida-canvas-react/provider.tsx index 0a661a5eb7..18680d99f1 100644 --- a/editor/grida-canvas-react/provider.tsx +++ b/editor/grida-canvas-react/provider.tsx @@ -453,6 +453,7 @@ export function useEventTargetCSSCursor() { } switch (tool.type) { case "cursor": + case "scale": return cursors.default_png.css; case "hand": return "grab"; diff --git a/editor/grida-canvas-react/viewport/hotkeys.tsx b/editor/grida-canvas-react/viewport/hotkeys.tsx index e5a1b3354d..007f255cb9 100644 --- a/editor/grida-canvas-react/viewport/hotkeys.tsx +++ b/editor/grida-canvas-react/viewport/hotkeys.tsx @@ -232,6 +232,11 @@ export const keybindings_sheet = [ description: "Select tool", keys: ["v"], }, + { + name: "scale", + description: "Scale tool (parametric scaling)", + keys: ["k"], + }, { name: "lasso", description: "Lasso tool (vector mode)", @@ -897,6 +902,19 @@ export function useEditorHotKeys() { editor.surface.surfaceSetTool({ type: "cursor" }); }); + useHotkeys( + "k", + () => { + editor.surface.surfaceSetTool({ type: "scale" }); + }, + // need below, k might open a ui with autofocus input, below prevents "k" being typed in to the input. + { + preventDefault: true, + enableOnFormTags: false, + enableOnContentEditable: false, + } + ); + useHotkeys("q", () => { if (content_edit_mode?.type === "vector") { editor.surface.surfaceSetTool({ type: "lasso" }); diff --git a/editor/grida-canvas-react/viewport/surface.tsx b/editor/grida-canvas-react/viewport/surface.tsx index 269226ad78..d83ade78b1 100644 --- a/editor/grida-canvas-react/viewport/surface.tsx +++ b/editor/grida-canvas-react/viewport/surface.tsx @@ -611,12 +611,7 @@ function RootFramesBarOverlay() { const rootframe = rootframes[0]; if (!rootframe) return null; return ( - + {" (single mode)"} @@ -652,11 +647,9 @@ function NodeTitleBar({ node, node_id, state, - sideOffset, children, }: React.PropsWithChildren<{ node: grida.program.nodes.Node; - sideOffset?: number; node_id: string; state: "idle" | "hover" | "active"; }>) { @@ -698,10 +691,9 @@ function NodeTitleBar({ -
+
{children}
@@ -1016,7 +1008,8 @@ function SelectionGroupOverlay({ const { style, ids, boundingSurfaceRect, size, distribution } = groupdata; - const enabled = !readonly && tool.type === "cursor"; + const enabled = + !readonly && (tool.type === "cursor" || tool.type === "scale"); const bind = useSurfaceGesture( { @@ -1199,7 +1192,8 @@ function NodeOverlay({ const tool = useToolState(); // enable overlay dragging only when the cursor tool is active and editable - const enabled = !readonly && tool.type === "cursor"; + const enabled = + !readonly && (tool.type === "cursor" || tool.type === "scale"); const bind = useSurfaceGesture( { @@ -1531,6 +1525,7 @@ function LayerOverlayResizeHandle({ onDoubleClick?: () => void; disabled?: boolean; }) { + const tool = useToolState(); const bind = useSurfaceGesture( { onPointerDown: ({ event }) => { @@ -1559,7 +1554,16 @@ function LayerOverlayResizeHandle({ {...bind()} anchor={anchor} zIndex={zIndex} - transform={disabled ? { pointerEvents: "none" } : undefined} + transform={ + tool.type === "scale" + ? { + cursor: cursors.resize_handle_scale_cursor_map[anchor], + ...(disabled ? { pointerEvents: "none" } : undefined), + } + : disabled + ? { pointerEvents: "none" } + : undefined + } /> ); } @@ -1579,6 +1583,7 @@ function LayerOverlayResizeSide({ onDoubleClick?: () => void; disabled?: boolean; }) { + const tool = useToolState(); const bind = useSurfaceGesture( { onPointerDown: ({ event }) => { @@ -1628,7 +1633,10 @@ function LayerOverlayResizeSide({ style={{ position: "absolute", background: "transparent", - cursor: cursors.resize_handle_cursor_map[anchor], + cursor: + tool.type === "scale" + ? cursors.resize_handle_scale_cursor_map[anchor] + : cursors.resize_handle_cursor_map[anchor], touchAction: "none", zIndex, ...positionalStyle, diff --git a/editor/grida-canvas-react/viewport/ui/floating-bar.tsx b/editor/grida-canvas-react/viewport/ui/floating-bar.tsx index 532c3be84d..bf8836c689 100644 --- a/editor/grida-canvas-react/viewport/ui/floating-bar.tsx +++ b/editor/grida-canvas-react/viewport/ui/floating-bar.tsx @@ -5,16 +5,12 @@ import { cn } from "@/components/lib/utils"; interface BarProps { node_id: string; state: "idle" | "hover" | "active"; - side?: "top" | "bottom" | "left" | "right"; - sideOffset?: number; isComponentConsumer?: boolean; } export function FloatingBar({ className, children, - side = "top", - sideOffset = 4, state, isComponentConsumer, ...porps @@ -29,10 +25,7 @@ export function FloatingBar({ style={data?.style} > {/* Title bar positioned above the parent using a percentage transform */} -
+
{children}
diff --git a/editor/grida-canvas-react/viewport/ui/surface-vector-editor.tsx b/editor/grida-canvas-react/viewport/ui/surface-vector-editor.tsx index 64ee646577..2640e4608c 100644 --- a/editor/grida-canvas-react/viewport/ui/surface-vector-editor.tsx +++ b/editor/grida-canvas-react/viewport/ui/surface-vector-editor.tsx @@ -100,7 +100,9 @@ export function SurfaceVectorEditor({ )}
- {tool.type === "cursor" && } + {(tool.type === "cursor" || tool.type === "scale") && ( + + )} {/* Render all segments */} {segments.map((s, i) => { diff --git a/editor/grida-canvas/action.ts b/editor/grida-canvas/action.ts index 701b78cd78..db838913f0 100644 --- a/editor/grida-canvas/action.ts +++ b/editor/grida-canvas/action.ts @@ -47,6 +47,7 @@ export type DocumentAction = | EditorDeleteAction | EditorFlattenAction | EditorA11yDeleteAction + | EditorApplyParametricScaleAction | EditorHierarchyAction | EditorVectorEditorAction | EditorVariableWidthAction @@ -567,6 +568,29 @@ export interface EditorUngroupAction { target: NodeID[] | "selection"; } +export interface EditorApplyParametricScaleAction { + type: "apply-scale"; + /** + * root targets (selection roots) + */ + targets: NodeID[]; + /** + * delta scale factor to apply for this command (e.g. 1.5). + */ + factor: number; + origin: "center" | cmath.CardinalDirection; + include_subtree: boolean; + + /** + * Coordinate space interpretation for layout geometry (`left/top/...`). + * + * - `auto` (default): best-effort UX semantics; may override selection-root `left/top` + * so origin behaves selection-local for root-level nodes (scene direct children). + * - `global`: purely multiply numeric layout fields by factor (developer/math usage). + */ + space?: "auto" | "global"; +} + export type EditorConfigAction = | EditorConfigure_RaycastTargeting | EditorConfigure_Measurement diff --git a/editor/grida-canvas/editor.i.ts b/editor/grida-canvas/editor.i.ts index 568612bc55..70d723a80e 100644 --- a/editor/grida-canvas/editor.i.ts +++ b/editor/grida-canvas/editor.i.ts @@ -672,6 +672,14 @@ export namespace editor.state { | { type: "cursor"; } + | { + /** + * Scale tool (K) — parametric scaling. + * + * Note: this is a tool mode, distinct from the transform gesture type `"scale"`. + */ + type: "scale"; + } | { type: "hand"; } @@ -1799,6 +1807,51 @@ export namespace editor.gesture { readonly initial_snapshot: editor.state.IMinimalDocumentState; readonly initial_rects: cmath.Rectangle[]; readonly direction: cmath.CardinalDirection; + + /** + * Gesture mode. + * - `resize`: regular resize behavior (default) + * - `parametric`: Scale tool (K) — parameter-space scaling + */ + readonly mode?: "resize" | "parametric"; + + /** + * Initial selection bounding rectangle (union of `initial_rects`). + * Used as the reference for parametric scaling. + */ + readonly initial_bounding_rect?: cmath.Rectangle; + + /** + * Affected node ids for parametric scaling (selection + descendants). + */ + readonly affected_ids?: string[]; + + /** + * Initial absolute rectangles (canvas space) cached at gesture start. + * Keyed by node id. + */ + readonly initial_abs_rects_by_id?: Record; + + /** + * For nodes whose parent is outside `affected_ids`, this caches the parent's + * initial absolute rect (canvas space), keyed by node id. + */ + readonly initial_external_parent_abs_rects_by_id?: Record< + string, + cmath.Rectangle + >; + + /** + * Uniform similarity scale factor for the current gesture update. + * + * For Scale tool (K) parametric scaling, this is the canonical scale factor + * derived from the gesture movement and the initial bounds. + * + * This is tracked in **0.01 precision** (quantized) for gesture stability / UI. + * For `editor.commands.applyScale(...)`, the factor is used as-is (developer intent). + * (Unset for non-uniform resize gestures.) + */ + uniform_scale?: number; }; export type GestureInsertAndResize = Omit & { @@ -2706,6 +2759,22 @@ export namespace editor.api { exclude(target: ReadonlyArray): void; groupMask(target: ReadonlyArray): void; + /** + * Apply parameter-space scaling (Scale tool K) as a one-shot command. + * + * This applies a delta factor to the current authored state (not a persistent transform), + * scaling tracked geometry-contributing parameters while preserving visual identity. + */ + applyScale( + target: ReadonlyArray | "selection", + factor: number, + options?: { + origin?: "center" | cmath.CardinalDirection; + include_subtree?: boolean; + space?: "auto" | "global"; + } + ): void; + // vector editor selectVertex( node_id: NodeID, diff --git a/editor/grida-canvas/editor.ts b/editor/grida-canvas/editor.ts index 3ac51b2780..2eff6e6331 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -930,6 +930,31 @@ class EditorDocumentStore }); } + public applyScale( + target: ReadonlyArray | "selection", + factor: number, + options?: { + origin?: "center" | cmath.CardinalDirection; + include_subtree?: boolean; + space?: "auto" | "global"; + } + ) { + const targets = (target === "selection" ? this.state.selection : target) as + | ReadonlyArray + | undefined; + if (!targets || targets.length === 0) return; + if (!Number.isFinite(factor) || factor === 1) return; + + this.dispatch({ + type: "apply-scale", + targets: Array.from(targets), + factor, + origin: options?.origin ?? "center", + include_subtree: options?.include_subtree ?? true, + space: options?.space ?? "auto", + }); + } + // public selectVertex( node_id: editor.NodeID, diff --git a/editor/grida-canvas/reducers/__tests__/apply-scale.roundtrip.test.ts b/editor/grida-canvas/reducers/__tests__/apply-scale.roundtrip.test.ts new file mode 100644 index 0000000000..02b04e8b5f --- /dev/null +++ b/editor/grida-canvas/reducers/__tests__/apply-scale.roundtrip.test.ts @@ -0,0 +1,650 @@ +import reducer, { type ReducerContext } from "../index"; +import type { Action } from "../../action"; +import { editor } from "@/grida-canvas"; +import grida from "@grida/schema"; +import { io } from "@grida/io"; +import * as fs from "fs"; +import * as path from "path"; + +/** + * Fixture support note: + * This test currently targets the Grida schema version specifier `20251209` + * (e.g. `0.0.4-beta+20251209`) and loads all `*-20251209.grida` fixtures. + */ +const FIXTURE_VERSION_SPECIFIER = "20251209"; + +function deepClone(v: T): T { + // structuredClone is available in modern runtimes, but keep a fallback for Jest. + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return structuredClone(v); + } catch { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(JSON.stringify(v)) as T; + } +} + +function approxEqual(a: unknown, b: unknown, eps = 1e-9): boolean { + if (typeof a === "number" && typeof b === "number") { + if (Number.isNaN(a) && Number.isNaN(b)) return true; + if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b; + return Math.abs(a - b) <= eps; + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!approxEqual(a[i], b[i], eps)) return false; + } + return true; + } + if ( + a && + b && + typeof a === "object" && + typeof b === "object" && + !Array.isArray(a) && + !Array.isArray(b) + ) { + const ak = Object.keys(a as Record).sort(); + const bk = Object.keys(b as Record).sort(); + if (ak.length !== bk.length) return false; + for (let i = 0; i < ak.length; i++) { + if (ak[i] !== bk[i]) return false; + } + for (const k of ak) { + const av = (a as any)[k]; + const bv = (b as any)[k]; + if (!approxEqual(av, bv, eps)) return false; + } + return true; + } + return a === b; +} + +function firstMismatch( + a: unknown, + b: unknown, + eps = 1e-9, + path: string[] = [] +): { path: string; a: unknown; b: unknown } | null { + if (typeof a === "number" && typeof b === "number") { + if (Number.isNaN(a) && Number.isNaN(b)) return null; + if (!Number.isFinite(a) || !Number.isFinite(b)) { + return a === b ? null : { path: path.join("."), a, b }; + } + return Math.abs(a - b) <= eps ? null : { path: path.join("."), a, b }; + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return { path: path.join("."), a, b }; + for (let i = 0; i < a.length; i++) { + const m = firstMismatch(a[i], b[i], eps, [...path, String(i)]); + if (m) return m; + } + return null; + } + if ( + a && + b && + typeof a === "object" && + typeof b === "object" && + !Array.isArray(a) && + !Array.isArray(b) + ) { + const ak = Object.keys(a as Record).sort(); + const bk = Object.keys(b as Record).sort(); + if (ak.length !== bk.length) return { path: path.join("."), a, b }; + for (let i = 0; i < ak.length; i++) { + if (ak[i] !== bk[i]) return { path: path.join("."), a, b }; + } + for (const k of ak) { + const m = firstMismatch((a as any)[k], (b as any)[k], eps, [...path, k]); + if (m) return m; + } + return null; + } + return a === b ? null : { path: path.join("."), a, b }; +} + +// Minimal geometry stub that derives absolute rects from authored box geometry. +// This is intentionally simple: all test nodes use position: "absolute" and +// numeric left/top/width/height so geometry is deterministic. +function createGeometryStub( + getState: () => editor.state.IEditorState +): editor.api.IDocumentGeometryQuery | any { + function parentMapFromLinks( + links: Record + ): Record { + const map: Record = {}; + for (const [parent, children] of Object.entries(links)) { + for (const child of children) { + map[child] = parent; + } + } + return map; + } + + function getLocalRect( + node: any + ): { x: number; y: number; width: number; height: number } | null { + if (!node) return null; + if (node.position !== "absolute") return null; + if (typeof node.left !== "number") return null; + if (typeof node.top !== "number") return null; + + // Many real-world text nodes are authored with `width/height: "auto"`. + // The real editor geometry provider measures the rendered box; for tests + // we use a deterministic linear approximation so scale round-trips can be + // exercised without DOM measurement. + if (node.type === "text" && typeof node.font_size === "number") { + const text = typeof node.text === "string" ? node.text : ""; + const w = + typeof node.width === "number" + ? node.width + : Math.max(1, text.length) * node.font_size * 0.6; + const h = + typeof node.height === "number" ? node.height : node.font_size * 1.2; + return { x: node.left, y: node.top, width: w, height: h }; + } + + if (typeof node.width !== "number") return null; + if (typeof node.height !== "number") return null; + return { + x: node.left, + y: node.top, + width: node.width, + height: node.height, + }; + } + + function getAbsRect(node_id: string) { + const state = getState(); + const node = (state.document.nodes as any)[node_id]; + const local = getLocalRect(node); + if (!local) return null; + + const parents = parentMapFromLinks(state.document.links as any); + let x = local.x; + let y = local.y; + let p = parents[node_id]; + + while (p && p !== state.scene_id) { + const pn = (state.document.nodes as any)[p]; + const pl = getLocalRect(pn); + if (pl) { + x += pl.x; + y += pl.y; + } + p = parents[p]; + } + + return { x, y, width: local.width, height: local.height }; + } + + return { + getNodeIdsFromPoint: () => [], + getNodeIdsFromPointerEvent: () => [], + getNodeIdsFromEnvelope: () => [], + getNodeAbsoluteBoundingRect: (id: string) => getAbsRect(id), + getNodeAbsoluteRotation: () => 0, + } satisfies editor.api.IDocumentGeometryQuery; +} + +function createContext( + getState: () => editor.state.IEditorState +): ReducerContext { + return { + geometry: createGeometryStub(getState), + vector: undefined, + viewport: { width: 1000, height: 1000 }, + backend: "dom", + paint_constraints: { fill: "fill", stroke: "stroke" }, + idgen: grida.id.noop.generator, + }; +} + +function dispatch( + state: editor.state.IEditorState, + action: Action, + context: ReducerContext +): editor.state.IEditorState { + const [next] = reducer(state, action, context); + return next; +} + +function listFixturePathsByVersionSpecifier( + versionSpecifier: string +): string[] { + // Keep this scoped to fixtures/test-grida (see fixtures/test-grida/README.md) + // to avoid crawling huge fixture trees (fonts/images/etc). + const dir = path.resolve(__dirname, "../../../../fixtures/test-grida"); + const suffix = `-${versionSpecifier}.grida`; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith(suffix)) + .map((e) => path.join(dir, e.name)) + .sort((a, b) => a.localeCompare(b)); +} + +function loadFixtureDocument(fixturePath: string): { + scene_id: string; + document: grida.program.document.Document; +} { + const buf = fs.readFileSync(fixturePath); + const unpacked = io.archive.unpack(new Uint8Array(buf)); + const model = unpacked.document; // JSONDocumentFileModel + const scene_id = + model.document.entry_scene_id ?? model.document.scenes_ref?.[0]; + if (!scene_id) throw new Error("fixture document has no entry_scene_id"); + return { + scene_id, + document: model.document, + }; +} + +function initEditorStateFromFixture(args: { + scene_id: string; + document: grida.program.document.Document; +}): editor.state.IEditorState { + let state = editor.state.init({ + editable: true, + debug: false, + document: args.document, + templates: {}, + }); + state = dispatch( + state, + { type: "load", scene: args.scene_id } as any, + createContext(() => state) + ); + return state; +} + +function hasNumericAbsoluteBox(node: any): boolean { + return ( + node?.position === "absolute" && + typeof node.left === "number" && + typeof node.top === "number" && + typeof node.width === "number" && + typeof node.height === "number" + ); +} + +function getDescendants( + links: Record, + root_id: string +): string[] { + const out: string[] = []; + const stack = [...(links[root_id] ?? [])]; + while (stack.length) { + const id = stack.pop()!; + out.push(id); + const children = links[id]; + if (children?.length) stack.push(...children); + } + return out; +} + +function getRootContainerIds(doc: grida.program.document.Document): string[] { + const nodes = doc.nodes as Record; + const links = doc.links as Record; + const scene_id = doc.entry_scene_id ?? doc.scenes_ref?.[0]; + if (!scene_id) throw new Error("fixture document has no entry scene id"); + const rootChildren = links[scene_id] ?? []; + return rootChildren.filter((id) => nodes[id]?.type === "container"); +} + +function pickTextAndVectorTargetsFromFixture( + doc: grida.program.document.Document +): { + text_id: string | null; + vector_id: string | null; +} { + const nodes = doc.nodes as Record; + const scene_id = doc.entry_scene_id ?? doc.scenes_ref[0]; + if (!scene_id) throw new Error("fixture document has no entry scene id"); + + const entries = Object.entries(nodes).filter(([id]) => id !== scene_id); + + const text_id = + entries.find( + ([, n]) => + n.type === "text" && + n.position === "absolute" && + typeof n.left === "number" && + typeof n.top === "number" && + typeof n.font_size === "number" + )?.[0] ?? null; + const vector_id = + entries.find( + ([, n]) => + n.type === "vector" && hasNumericAbsoluteBox(n) && n.vector_network + )?.[0] ?? null; + + return { text_id, vector_id }; +} + +function isScaleTrackableNode(node: any): boolean { + if (!node) return false; + if (node.type === "text") { + return ( + node.position === "absolute" && + typeof node.left === "number" && + typeof node.top === "number" && + typeof node.font_size === "number" + ); + } + if (!hasNumericAbsoluteBox(node)) return false; + return node.type === "container" || node.type === "vector"; +} + +function getTrackableSubtreeNodeIds(args: { + doc: grida.program.document.Document; + root_id: string; +}): string[] { + const nodes = args.doc.nodes as Record; + const links = args.doc.links as Record; + const descendants = getDescendants(links, args.root_id); + return [args.root_id, ...descendants].filter((id) => + isScaleTrackableNode(nodes[id]) + ); +} + +function applyScaleOnce( + state: editor.state.IEditorState, + context: ReducerContext, + args: { + targets: string[]; + factor: number; + origin: "center"; + include_subtree: boolean; + } +) { + return dispatch( + state, + { + type: "apply-scale", + targets: args.targets, + factor: args.factor, + origin: args.origin, + include_subtree: args.include_subtree, + }, + context + ); +} + +describe("apply-scale round-trip (accuracy)", () => { + const fixturePaths = listFixturePathsByVersionSpecifier( + FIXTURE_VERSION_SPECIFIER + ); + + if (!fixturePaths.length) { + throw new Error( + `No fixtures found matching *-${FIXTURE_VERSION_SPECIFIER}.grida under fixtures/test-grida` + ); + } + + describe.each(fixturePaths.map((p) => [path.basename(p), p] as const))( + "fixture: %s", + (_fixtureName, fixturePath) => { + const { scene_id, document } = loadFixtureDocument(fixturePath); + const { text_id, vector_id } = + pickTextAndVectorTargetsFromFixture(document); + const root_container_ids = getRootContainerIds(document); + + if (!root_container_ids.length) { + throw new Error( + `fixture ${path.basename(fixturePath)} has no root containers` + ); + } + + const itIf = (cond: unknown) => (cond ? it : it.skip); + + itIf(text_id)( + "text node round-trips for 0.01x then 100x (epsilon on numbers)", + () => { + const tid = text_id!; + let state = initEditorStateFromFixture({ scene_id, document }); + const initial = deepClone(state.document.nodes[tid]); + + const ctx = createContext(() => state); + state = applyScaleOnce(state, ctx, { + targets: [tid], + factor: 0.01, + origin: "center", + include_subtree: false, + }); + state = applyScaleOnce(state, ctx, { + targets: [tid], + factor: 100, + origin: "center", + include_subtree: false, + }); + + const actual = state.document.nodes[tid]; + if (!approxEqual(actual, initial)) { + const m = firstMismatch(actual, initial); + throw new Error( + `[${path.basename(fixturePath)}] text round-trip mismatch at ${ + m?.path ?? "" + }: ${JSON.stringify(m?.a)} !== ${JSON.stringify(m?.b)}` + ); + } + } + ); + + itIf(vector_id)( + "vector node round-trips for 0.01x then 100x (epsilon on numbers)", + () => { + const vid = vector_id!; + let state = initEditorStateFromFixture({ scene_id, document }); + const initial = deepClone(state.document.nodes[vid]); + + const ctx = createContext(() => state); + state = applyScaleOnce(state, ctx, { + targets: [vid], + factor: 0.01, + origin: "center", + include_subtree: false, + }); + state = applyScaleOnce(state, ctx, { + targets: [vid], + factor: 100, + origin: "center", + include_subtree: false, + }); + + expect(approxEqual(state.document.nodes[vid], initial)).toBe(true); + } + ); + + describe.each(root_container_ids.map((id) => [id] as const))( + "root container: %s", + (root_container_id) => { + it("subtree round-trips for 0.01x then 100x (include_subtree=true)", () => { + const nodes = document.nodes as Record; + const rootNode = nodes[root_container_id]; + if (!hasNumericAbsoluteBox(rootNode)) { + throw new Error( + `[${path.basename(fixturePath)}] root container ${root_container_id} is not a numeric absolute box` + ); + } + + const tracked_ids = getTrackableSubtreeNodeIds({ + doc: document, + root_id: root_container_id, + }); + if (!tracked_ids.length) { + throw new Error( + `[${path.basename(fixturePath)}] root container ${root_container_id} produced 0 trackable ids` + ); + } + + let state = initEditorStateFromFixture({ scene_id, document }); + + const initial: Record = {}; + for (const id of tracked_ids) { + initial[id] = deepClone(state.document.nodes[id]); + } + + const ctx = createContext(() => state); + state = applyScaleOnce(state, ctx, { + targets: [root_container_id], + factor: 0.01, + origin: "center", + include_subtree: true, + }); + state = applyScaleOnce(state, ctx, { + targets: [root_container_id], + factor: 100, + origin: "center", + include_subtree: true, + }); + + const final: Record = {}; + for (const id of tracked_ids) { + final[id] = state.document.nodes[id]; + } + + expect(approxEqual(final, initial)).toBe(true); + }); + } + ); + } + ); +}); + +it("origin semantics: auto overrides root left/top but global does not", () => { + const scene_id = "scene1"; + const doc: grida.program.document.Document = { + scenes_ref: [scene_id], + entry_scene_id: scene_id, + links: { [scene_id]: ["rect1"] }, + bitmaps: {}, + images: {}, + properties: {}, + nodes: { + [scene_id]: { + type: "scene", + id: scene_id, + name: "Scene", + active: true, + locked: false, + constraints: { children: "multiple" }, + guides: [], + edges: [], + background_color: null, + }, + rect1: { + id: "rect1", + type: "rectangle", + name: "Rect", + active: true, + locked: false, + position: "absolute", + left: 10, + top: 20, + width: 100, + height: 50, + rotation: 0, + opacity: 1, + z_index: 0, + stroke_width: 0, + stroke_cap: "butt", + stroke_join: "miter", + fill_paints: [], + stroke_paints: [], + }, + }, + }; + + let state = editor.state.init({ + editable: true, + debug: false, + document: doc, + templates: {}, + }); + state = dispatch( + state, + { type: "load", scene: scene_id } as any, + createContext(() => state) + ); + + const ctx = createContext(() => state); + + const state_auto = dispatch( + state, + { + type: "apply-scale", + targets: ["rect1"], + factor: 2, + origin: "center", + include_subtree: false, + space: "auto", + } as any, + ctx + ); + + const state_global = dispatch( + state, + { + type: "apply-scale", + targets: ["rect1"], + factor: 2, + origin: "center", + include_subtree: false, + space: "global", + } as any, + ctx + ); + + const a: any = state_auto.document.nodes.rect1; + const g: any = state_global.document.nodes.rect1; + + // both scale sizes + expect(a.width).toBe(200); + expect(g.width).toBe(200); + + // but only `auto` keeps the center fixed by shifting left/top + expect(a.left).toBe(-40); // center at x=60, new half-width=100 => 60-100=-40 + expect(a.top).toBe(-5); // center at y=45, new half-height=50 => 45-50=-5 + + // `global` simply multiplies coordinates + expect(g.left).toBe(20); + expect(g.top).toBe(40); +}); + +it.skip("UB/TODO: origin semantics for depth=2 selection root (scene -> container -> node)", () => { + /** + * ## Scenario (un-studied / undefined behavior) + * + * We currently implement `space: "auto"` origin semantics by overriding `left/top` + * only for selection roots that are **direct children of the scene**. + * + * This test documents the missing case: + * + * - Scene + * - Container A (absolute, numeric box) + * - Rect B (absolute, numeric left/top/width/height) + * + * User selects **Rect B** (selection root at depth=2) and applies parametric scale + * with `origin: "center"` in `space: "auto"`. + * + * ### What needs to be defined / handled + * + * For depth>1 roots, "selection-local" origin is ambiguous because: + * - `left/top` are in the **parent local coordinate space** (Container A), + * - but our origin is derived from **selection bounds** (which are typically in + * scene/global space in the editor UX), + * - and the parent may be in layout contexts (flex/grid/auto) where writing `left/top` + * could be incorrect or meaningless. + * + * A correct implementation likely needs an explicit rule, e.g.: + * - compute origin in the same space as the node's authored `left/top` (parent-local), + * - or only apply the override when the parent is scene (current behavior), + * - or introduce a more complete "auto" layout strategy for non-scene parents. + * + * Until that is specified, we intentionally do **not** assert behavior here. + */ + // TODO: once semantics are decided, construct a minimal document for: + // scene -> container -> rect, then assert whether `auto` should shift rect's left/top. +}); diff --git a/editor/grida-canvas/reducers/__tests__/history.test.ts b/editor/grida-canvas/reducers/__tests__/history.test.ts index e610553c07..876d269bc4 100644 --- a/editor/grida-canvas/reducers/__tests__/history.test.ts +++ b/editor/grida-canvas/reducers/__tests__/history.test.ts @@ -1,6 +1,3 @@ -jest.mock("@grida/vn", () => ({}), { virtual: true }); -jest.mock("svg-pathdata", () => ({}), { virtual: true }); - import reducer, { type ReducerContext } from "../index"; import { DocumentHistoryManager } from "../../history-manager"; import { editor } from "@/grida-canvas"; diff --git a/editor/grida-canvas/reducers/__tests__/image-paint-clipboard.test.ts b/editor/grida-canvas/reducers/__tests__/image-paint-clipboard.test.ts index e9e238ba82..32776bf3ab 100644 --- a/editor/grida-canvas/reducers/__tests__/image-paint-clipboard.test.ts +++ b/editor/grida-canvas/reducers/__tests__/image-paint-clipboard.test.ts @@ -1,19 +1,6 @@ import documentReducer from "../document.reducer"; import grida from "@grida/schema"; -jest.mock("@grida/vn", () => { - class VectorNetworkEditor { - constructor(_net: any) {} - } - return { - __esModule: true, - default: { VectorNetworkEditor }, - VectorNetworkEditor, - }; -}); - -jest.mock("svg-pathdata", () => ({}), { virtual: true }); - jest.mock("../surface.reducer", () => ({ __esModule: true, default: jest.fn(), diff --git a/editor/grida-canvas/reducers/__tests__/vector-cut.test.ts b/editor/grida-canvas/reducers/__tests__/vector-cut.test.ts index 357562f34d..2c94691c37 100644 --- a/editor/grida-canvas/reducers/__tests__/vector-cut.test.ts +++ b/editor/grida-canvas/reducers/__tests__/vector-cut.test.ts @@ -1,7 +1,5 @@ import documentReducer from "../document.reducer"; -jest.mock("svg-pathdata", () => ({}), { virtual: true }); - jest.mock("../surface.reducer", () => ({ __esModule: true, default: jest.fn(), diff --git a/editor/grida-canvas/reducers/__tests__/vector-self-remove.test.ts b/editor/grida-canvas/reducers/__tests__/vector-self-remove.test.ts index 007ed8f01a..59fa137228 100644 --- a/editor/grida-canvas/reducers/__tests__/vector-self-remove.test.ts +++ b/editor/grida-canvas/reducers/__tests__/vector-self-remove.test.ts @@ -1,11 +1,3 @@ -jest.mock("@/grida-canvas", () => ({ - editor: { config: {} }, -})); - -jest.mock("@grida/cmath", () => ({}), { virtual: true }); -jest.mock("@grida/schema", () => ({}), { virtual: true }); -jest.mock("svg-pathdata", () => ({}), { virtual: true }); - jest.mock("../methods", () => ({ self_optimizeVectorNetwork: jest.fn(), self_try_remove_node: jest.fn((draft: any, id: string) => { @@ -14,10 +6,6 @@ jest.mock("../methods", () => ({ self_revert_tool: jest.fn(), })); -jest.mock("../tools/gesture", () => ({ - getInitialCurveGesture: jest.fn(), -})); - import surfaceReducer from "../surface.reducer"; describe("surface reducer - vector self remove", () => { diff --git a/editor/grida-canvas/reducers/document.reducer.ts b/editor/grida-canvas/reducers/document.reducer.ts index 5716f1c238..314d6df512 100644 --- a/editor/grida-canvas/reducers/document.reducer.ts +++ b/editor/grida-canvas/reducers/document.reducer.ts @@ -44,6 +44,7 @@ import { getVectorSelectionStartPoint, self_nudge_transform, } from "./methods"; +import { self_apply_scale_by_factor } from "./methods/scale"; import { getPackedSubtreeBoundingRect, getViewportAwareDelta, @@ -1569,6 +1570,18 @@ export default function documentReducer( }); break; } + case "apply-scale": { + const { targets, factor, origin, include_subtree, space } = action; + return updateState(state, (draft) => { + self_apply_scale_by_factor(draft, context, { + targets, + factor, + origin, + include_subtree, + space, + }); + }); + } // case "select-vertex": case "delete-vertex": diff --git a/editor/grida-canvas/reducers/event-target.reducer.ts b/editor/grida-canvas/reducers/event-target.reducer.ts index a122bbb58a..63b95c2e98 100644 --- a/editor/grida-canvas/reducers/event-target.reducer.ts +++ b/editor/grida-canvas/reducers/event-target.reducer.ts @@ -96,6 +96,7 @@ function __self_evt_on_click( draft.hits = node_ids_from_point; switch (draft.tool.type) { case "cursor": + case "scale": case "hand": // ignore break; @@ -206,6 +207,35 @@ function __self_evt_on_double_click(draft: editor.state.IEditorState) { // } +function __self_pointer_down_selection_like_cursor( + draft: editor.state.IEditorState, + shiftKey: boolean +) { + const { hovered_node_id } = self_updateSurfaceHoverState(draft); + + if (draft.content_edit_mode?.type === "vector") { + if (!shiftKey && draft.content_edit_mode.snapped_vertex_idx === null) { + // clear the selection for vector content edit mode + self_clearSelection(draft); + } + return; + } + + if (shiftKey) { + if (hovered_node_id) { + self_selectNode(draft, "toggle", hovered_node_id); + } else { + // do nothing (when shift key is pressed) + } + } else { + if (hovered_node_id) { + self_selectNode(draft, "reset", hovered_node_id); + } else { + self_clearSelection(draft); + } + } +} + function __self_evt_on_pointer_down( draft: editor.state.IEditorState, action: EditorEventTarget_PointerDown, @@ -218,30 +248,12 @@ function __self_evt_on_pointer_down( switch (draft.tool.type) { case "cursor": { - const { hovered_node_id } = self_updateSurfaceHoverState(draft); - - if (draft.content_edit_mode?.type === "vector") { - if (!shiftKey && draft.content_edit_mode.snapped_vertex_idx === null) { - // clear the selection for vector content edit mode - self_clearSelection(draft); - } - break; - } - - if (shiftKey) { - if (hovered_node_id) { - self_selectNode(draft, "toggle", hovered_node_id); - } else { - // do nothing (when shift key is pressed) - } - } else { - if (hovered_node_id) { - self_selectNode(draft, "reset", hovered_node_id); - } else { - self_clearSelection(draft); - } - } - + __self_pointer_down_selection_like_cursor(draft, shiftKey); + break; + } + case "scale": { + // Scale tool behaves like cursor for selection interactions. + __self_pointer_down_selection_like_cursor(draft, shiftKey); break; } case "insert": { @@ -278,6 +290,47 @@ function __self_evt_on_pointer_up(draft: editor.state.IEditorState) { draft.gesture = { type: "idle" }; } +function __self_drag_start_selection_like_cursor( + draft: editor.state.IEditorState, + shiftKey: boolean, + context: ReducerContext +) { + // when vector content edit mode is active, dragging should marquee select + if (draft.content_edit_mode?.type === "vector") { + draft.marquee = { + a: draft.pointer.position, + b: draft.pointer.position, + additive: shiftKey, + }; + return; + } + + // TODO: improve logic + if (shiftKey) { + if (draft.hovered_node_id) { + __self_start_gesture_translate(draft, context); + } else { + // marquee selection + draft.marquee = { + a: draft.pointer.position, + b: draft.pointer.position, + additive: shiftKey, + }; + } + } else { + if (draft.selection.length === 0) { + // marquee selection + draft.marquee = { + a: draft.pointer.position, + b: draft.pointer.position, + additive: shiftKey, + }; + } else { + __self_start_gesture_translate(draft, context); + } + } +} + function __self_evt_on_drag_start( draft: editor.state.IEditorState, action: EditorEventTarget_DragStart, @@ -298,39 +351,12 @@ function __self_evt_on_drag_start( switch (draft.tool.type) { case "cursor": { - // when vector content edit mode is active, dragging should marquee select - if (draft.content_edit_mode?.type === "vector") { - draft.marquee = { - a: draft.pointer.position, - b: draft.pointer.position, - additive: shiftKey, - }; - } else { - // TODO: improve logic - if (shiftKey) { - if (draft.hovered_node_id) { - __self_start_gesture_translate(draft, context); - } else { - // marquee selection - draft.marquee = { - a: draft.pointer.position, - b: draft.pointer.position, - additive: shiftKey, - }; - } - } else { - if (draft.selection.length === 0) { - // marquee selection - draft.marquee = { - a: draft.pointer.position, - b: draft.pointer.position, - additive: shiftKey, - }; - } else { - __self_start_gesture_translate(draft, context); - } - } - } + __self_drag_start_selection_like_cursor(draft, shiftKey, context); + break; + } + case "scale": { + // Scale tool behaves like cursor for selection drag interactions. + __self_drag_start_selection_like_cursor(draft, shiftKey, context); break; } case "zoom": { @@ -417,6 +443,17 @@ function __self_evt_on_drag_start( } } +function __self_drag_end_marquee_select( + draft: editor.state.IEditorState, + node_ids_from_area: string[] | undefined, + shiftKey: boolean +) { + if (draft.content_edit_mode?.type !== "vector" && node_ids_from_area) { + const target_node_ids = getMarqueeSelection(draft, node_ids_from_area); + self_selectNode(draft, shiftKey ? "toggle" : "reset", ...target_node_ids); + } +} + function __self_evt_on_drag_end( draft: editor.state.IEditorState, action: EditorEventTarget_DragEnd, @@ -460,20 +497,17 @@ function __self_evt_on_drag_end( break; } case "cursor": { - if (draft.content_edit_mode?.type !== "vector" && node_ids_from_area) { - const target_node_ids = getMarqueeSelection(draft, node_ids_from_area); - - self_selectNode( - draft, - shiftKey ? "toggle" : "reset", - ...target_node_ids - ); - } - + __self_drag_end_marquee_select(draft, node_ids_from_area, shiftKey); // cancel to default self_select_tool(draft, { type: "cursor" }, context); break; } + case "scale": { + __self_drag_end_marquee_select(draft, node_ids_from_area, shiftKey); + // keep scale tool active + self_select_tool(draft, { type: "scale" }, context); + break; + } case "insert": default: // cancel to default diff --git a/editor/grida-canvas/reducers/methods/index.ts b/editor/grida-canvas/reducers/methods/index.ts index 416a805f01..c1a5553d8e 100644 --- a/editor/grida-canvas/reducers/methods/index.ts +++ b/editor/grida-canvas/reducers/methods/index.ts @@ -7,3 +7,4 @@ export * from "./duplicate"; export * from "./vector"; export * from "./flatten"; export * from "./tool"; +export * from "./scale"; diff --git a/editor/grida-canvas/reducers/methods/scale.ts b/editor/grida-canvas/reducers/methods/scale.ts new file mode 100644 index 0000000000..5ac98bb5e8 --- /dev/null +++ b/editor/grida-canvas/reducers/methods/scale.ts @@ -0,0 +1,759 @@ +import type { Draft } from "immer"; +import assert from "assert"; +import cmath from "@grida/cmath"; +import grida from "@grida/schema"; +import vn from "@grida/vn"; +import { editor } from "@/grida-canvas"; +import { dq } from "@/grida-canvas/query"; +import type { ReducerContext } from ".."; +import schema from "../schema"; +import updateNodeTransform from "../node-transform.reducer"; +import { getSnapTargets, threshold } from "../tools/snap"; +import { snapObjectsResize } from "../tools/snap-resize"; + +/** + * Scale gesture orchestration. + * + * This file intentionally owns *all* scale-related orchestration: + * - regular resize (transform-space scaling) + * - Scale tool (K) parametric scaling (parameter-space scaling) + * + * Pure math + property rewrite rules live under `schema.parametric_scale` and are unit-tested. + * + * Spec: https://grida.co/docs/wg/feat-authoring/parametric-scaling + */ + +function deepClone(value: T): T { + // Prefer structuredClone (fast + preserves non-JSON primitives), + // but keep a JSON fallback for environments where it may be unavailable. + try { + return structuredClone(value); + } catch { + return JSON.parse(JSON.stringify(value)); + } +} + +export function self_start_gesture_scale( + draft: Draft, + { + selection, + direction, + context, + }: { + selection: string[]; + direction: cmath.CardinalDirection; + context: ReducerContext; + } +) { + if (selection.length === 0) return; + + const rects = selection.map( + (node_id) => context.geometry.getNodeAbsoluteBoundingRect(node_id)! + ); + + const is_parametric_scale = draft.tool.type === "scale"; + const initial_bounding_rect = cmath.rect.union(rects); + + let affected_ids: string[] | undefined = undefined; + let initial_abs_rects_by_id: Record | undefined = + undefined; + let initial_external_parent_abs_rects_by_id: + | Record + | undefined = undefined; + + if (is_parametric_scale) { + const all = new Set(); + for (const root_id of selection) { + all.add(root_id); + dq.getChildren(draft.document_ctx, root_id, true).forEach((id) => + all.add(id) + ); + } + + affected_ids = Array.from(all); + const affected_set = new Set(affected_ids); + + initial_abs_rects_by_id = {}; + for (const id of affected_ids) { + const r = context.geometry.getNodeAbsoluteBoundingRect(id); + if (r) initial_abs_rects_by_id[id] = r; + } + + initial_external_parent_abs_rects_by_id = {}; + for (const id of affected_ids) { + const parent_id = dq.getParentId(draft.document_ctx, id); + if (!parent_id) continue; + if (affected_set.has(parent_id)) continue; + const pr = context.geometry.getNodeAbsoluteBoundingRect(parent_id); + if (pr) initial_external_parent_abs_rects_by_id[id] = pr; + } + } + + draft.gesture = { + type: "scale", + initial_snapshot: editor.state.snapshot(draft), + initial_rects: rects, + movement: cmath.vector2.zero, + first: cmath.vector2.zero, + last: cmath.vector2.zero, + selection: selection, + direction: direction, + mode: is_parametric_scale ? "parametric" : "resize", + initial_bounding_rect: is_parametric_scale + ? initial_bounding_rect + : undefined, + affected_ids: is_parametric_scale ? affected_ids : undefined, + initial_abs_rects_by_id: is_parametric_scale + ? initial_abs_rects_by_id + : undefined, + initial_external_parent_abs_rects_by_id: is_parametric_scale + ? initial_external_parent_abs_rects_by_id + : undefined, + }; + + // For resize (transform scale), we “lock” variable sizes into numeric sizes so the gesture is stable. + // For parametric scale (K), we MUST NOT bake non-numeric authored values (e.g. width/height: "auto"). + if (is_parametric_scale) return; + + let i = 0; + for (const node_id of selection) { + const node = dq.__getNodeById(draft, node_id); + const rect = rects[i++]; + + if (!rect) continue; + + const n = node as grida.program.nodes.i.ICSSDimension; + + // needs width + if ( + direction === "e" || + direction === "w" || + direction === "ne" || + direction === "se" || + direction === "nw" || + direction === "sw" + ) { + if (typeof n.width !== "number") { + n.width = + node.type === "text" + ? Math.ceil(rect.width) + : cmath.quantize(rect.width, 1); + } + } + + // needs height + if ( + direction === "n" || + direction === "s" || + direction === "ne" || + direction === "nw" || + direction === "se" || + direction === "sw" + ) { + if (typeof n.height !== "number") { + if (node.type === "line") { + n.height = 0; + } else { + n.height = + node.type === "text" + ? Math.ceil(rect.height) + : cmath.quantize(rect.height, 1); + } + } + } + } +} + +export function self_update_gesture_scale( + draft: Draft, + context: ReducerContext +) { + assert( + draft.gesture.type === "scale" || + draft.gesture.type === "insert-and-resize", + "Gesture type must be scale or insert-and-resize" + ); + + // Scale tool (K): parametric scaling (parameter-space scaling) + if ( + draft.gesture.type === "scale" && + draft.tool.type === "scale" && + draft.gesture.mode === "parametric" + ) { + return self_update_gesture_parametric_scale(draft, context); + } + + return self_update_gesture_resize_scale(draft, context); +} + +function self_update_gesture_resize_scale( + draft: Draft, + context: ReducerContext +) { + const gesture = draft.gesture as + | editor.gesture.GestureScale + | editor.gesture.GestureInsertAndResize; + + assert(draft.scene_id, "scene_id is not set"); + const scene = draft.document.nodes[ + draft.scene_id + ] as grida.program.nodes.SceneNode; + + const { transform_with_center_origin, transform_with_preserve_aspect_ratio } = + draft.gesture_modifiers; + + const { + selection, + direction, + initial_snapshot, + movement: rawMovement, + initial_rects, + } = gesture; + + const initial_bounding_rectangle = cmath.rect.union(initial_rects); + + const origin = + transform_with_center_origin === "on" + ? cmath.rect.getCenter(initial_bounding_rectangle) + : cmath.rect.getCardinalPoint( + initial_bounding_rectangle, + cmath.compass.invertDirection(direction) + ); + + // #region snap (reuse same behavior as resize) + const should_snap = + draft.gesture_modifiers.scale_with_force_disable_snap !== "on"; + + let adjusted_raw_movement = rawMovement; + + if (should_snap) { + const snap_target_node_ids = getSnapTargets(selection, { + document_ctx: draft.document_ctx, + document: draft.document, + }); + + const snap_target_node_rects = snap_target_node_ids + .map((node_id: string) => { + const r = context.geometry.getNodeAbsoluteBoundingRect(node_id); + if (!r) { + reportError(`Node ${node_id} does not have a bounding rect`); + } + return r; + }) + .filter((r): r is cmath.Rectangle => r !== null && r !== undefined); + + const { adjusted_movement, snapping } = snapObjectsResize( + initial_rects, + { + objects: snap_target_node_rects, + guides: draft.ruler === "on" ? scene.guides : undefined, + }, + direction, + origin, + rawMovement, + threshold( + editor.config.DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR, + draft.transform + ), + { + enabled: should_snap, + preserveAspectRatio: transform_with_preserve_aspect_ratio === "on", + centerOrigin: transform_with_center_origin === "on", + } + ); + + adjusted_raw_movement = adjusted_movement; + draft.surface_snapping = snapping; + } else { + draft.surface_snapping = undefined; + } + // #endregion + + const movement = cmath.vector2.multiply( + cmath.compass.cardinal_direction_vector[direction], + adjusted_raw_movement, + transform_with_center_origin === "on" ? [2, 2] : [1, 1] + ); + + let i = 0; + for (const node_id of selection) { + const node = draft.document.nodes[node_id] as grida.program.nodes.Node; + const initial_node = initial_snapshot.document.nodes[ + node_id + ] as grida.program.nodes.Node; + const initial_rect = initial_rects[i++]; + + const parent_id = dq.getParentId(draft.document_ctx, node_id); + const parent_node = parent_id ? dq.__getNodeById(draft, parent_id) : null; + const is_scene_parent = parent_node?.type === "scene"; + + // TODO: scaling for bitmap node is not supported yet. + const is_scalable = initial_node.type !== "bitmap"; + if (!is_scalable) continue; + + if (!parent_id || is_scene_parent) { + updateNodeTransform(node as any, { + type: "scale", + rect: initial_rect, + origin: origin, + movement, + preserveAspectRatio: transform_with_preserve_aspect_ratio === "on", + }); + } else { + const parent_rect = + context.geometry.getNodeAbsoluteBoundingRect(parent_id)!; + assert( + parent_rect, + "Parent rect must be defined : " + parent_id + "/" + node_id + ); + + const relative_position = cmath.vector2.sub( + [initial_rect.x, initial_rect.y], + [parent_rect.x, parent_rect.y] + ); + + const relative_rect: cmath.Rectangle = { + x: relative_position[0], + y: relative_position[1], + width: initial_rect.width, + height: initial_rect.height, + }; + + const relative_origin = cmath.vector2.sub(origin, [ + parent_rect.x, + parent_rect.y, + ]); + + updateNodeTransform(node as any, { + type: "scale", + rect: relative_rect, + origin: relative_origin, + movement, + preserveAspectRatio: transform_with_preserve_aspect_ratio === "on", + }); + } + + if (initial_node.type === "vector") { + const vector_node = node as grida.program.nodes.VectorNode; + const initial_dimensions: cmath.Rectangle = { + x: 0, + y: 0, + width: initial_rect.width, + height: initial_rect.height, + }; + + const final_dimensions: cmath.Rectangle = { + x: 0, + y: 0, + width: vector_node.width ?? 0, + height: vector_node.height ?? 0, + }; + + let scale: cmath.Vector2; + if (initial_dimensions.width === 0 && initial_dimensions.height === 0) { + scale = [1, 1]; + } else if (initial_dimensions.width === 0) { + const factor = + initial_dimensions.height !== 0 + ? final_dimensions.height / initial_dimensions.height + : 1; + scale = [factor, factor]; + } else if (initial_dimensions.height === 0) { + const factor = + initial_dimensions.width !== 0 + ? final_dimensions.width / initial_dimensions.width + : 1; + scale = [factor, factor]; + } else { + scale = cmath.rect.getScaleFactors( + initial_dimensions, + final_dimensions + ); + } + + const vne = new vn.VectorNetworkEditor( + (initial_node as grida.program.nodes.VectorNode).vector_network + ); + vne.scale(scale); + ( + draft.document.nodes[node_id] as grida.program.nodes.VectorNode + ).vector_network = vne.value; + } + } +} +function dominantAxisByMovement(m: cmath.Vector2): "x" | "y" { + // Reuse cmath's dominance logic (ties resolve to "y"). + const locked = cmath.ext.movement.axisLockedByDominance([m[0], m[1]]); + return locked[0] === null ? "y" : "x"; +} + +type AutoSpaceRoot = { + id: string; + initialRect: cmath.Rectangle; + hasLeft: boolean; + hasTop: boolean; +}; + +function resolveScaleOriginPoint( + bounds: cmath.Rectangle, + origin: "center" | cmath.CardinalDirection +): cmath.Vector2 { + return origin === "center" + ? cmath.rect.getCenter(bounds) + : cmath.rect.getCardinalPoint(bounds, origin); +} + +function toRecord(value: unknown): Record | null { + if (value && typeof value === "object") + return value as Record; + return null; +} + +function collectAutoSpaceRootsFromGesture(args: { + draft: Draft; + selection: string[]; + initial_rects: cmath.Rectangle[]; + initial_snapshot: ReturnType; +}): AutoSpaceRoot[] { + const initial_rect_by_root_id: Record = {}; + for (let i = 0; i < args.selection.length; i++) { + const id = args.selection[i]; + const r = args.initial_rects[i]; + if (r) initial_rect_by_root_id[id] = r; + } + + const roots: AutoSpaceRoot[] = []; + for (const root_id of args.selection) { + const parent_id = dq.getParentId(args.draft.document_ctx, root_id); + if (parent_id !== args.draft.scene_id) continue; + + const initial_node = args.initial_snapshot.document.nodes[root_id] as + | grida.program.nodes.Node + | undefined; + if (!initial_node || initial_node.type === "scene") continue; + + const o = toRecord(initial_node); + if (!o) continue; + if (o["position"] !== "absolute") continue; + if (typeof o["width"] !== "number" || typeof o["height"] !== "number") + continue; + + const initialRect = initial_rect_by_root_id[root_id]; + if (!initialRect) continue; + + roots.push({ + id: root_id, + initialRect, + hasLeft: typeof o["left"] === "number", + hasTop: typeof o["top"] === "number", + }); + } + + return roots; +} + +function collectAutoSpaceRootsForCommand(args: { + draft: Draft; + context: ReducerContext; + targets: string[]; +}): AutoSpaceRoot[] { + const roots: AutoSpaceRoot[] = []; + + for (const root_id of args.targets) { + const parent_id = dq.getParentId(args.draft.document_ctx, root_id); + if (parent_id !== args.draft.scene_id) continue; + + const node = args.draft.document.nodes[root_id] as + | grida.program.nodes.Node + | undefined; + if (!node || node.type === "scene") continue; + + const o = toRecord(node); + if (!o) continue; + if (o["position"] !== "absolute") continue; + if (typeof o["width"] !== "number" || typeof o["height"] !== "number") + continue; + + const rect = + args.context.geometry.getNodeAbsoluteBoundingRect(root_id) ?? + (typeof o["left"] === "number" && typeof o["top"] === "number" + ? { + x: o["left"], + y: o["top"], + width: o["width"], + height: o["height"], + } + : null); + + if (!rect) continue; + + roots.push({ + id: root_id, + initialRect: rect, + hasLeft: typeof o["left"] === "number", + hasTop: typeof o["top"] === "number", + }); + } + + return roots; +} + +function applyAutoSpaceRootLeftTopOverride(args: { + draft: Draft; + roots: ReadonlyArray; + origin: cmath.Vector2; + factor: number; +}) { + for (const root of args.roots) { + if (!root.hasLeft && !root.hasTop) continue; + + const scaled = schema.parametric_scale.scale_rect_about_anchor( + root.initialRect, + args.origin, + args.factor + ); + + const node = args.draft.document.nodes[root.id] as + | grida.program.nodes.Node + | undefined; + if (!node || node.type === "scene") continue; + + const o = toRecord(node); + if (!o) continue; + + if (root.hasLeft) { + // selection-root override (only if authored as numeric) + o["left"] = scaled.x; + } + if (root.hasTop) { + // selection-root override (only if authored as numeric) + o["top"] = scaled.y; + } + } +} + +function self_update_gesture_parametric_scale( + draft: Draft, + context: ReducerContext +) { + assert(draft.gesture.type === "scale"); + if (draft.gesture.mode !== "parametric") return; + assert(draft.scene_id, "scene_id is not set"); + + const scene = draft.document.nodes[ + draft.scene_id + ] as grida.program.nodes.SceneNode; + + const { + selection, + direction, + initial_snapshot, + initial_rects, + movement: rawMovement, + initial_bounding_rect: _initial_bounding_rect, + affected_ids, + } = draft.gesture; + + assert(affected_ids, "parametric scale requires affected_ids"); + + const initial_bounding_rect = + _initial_bounding_rect ?? cmath.rect.union(initial_rects); + + const { transform_with_center_origin } = draft.gesture_modifiers; + + const origin = + transform_with_center_origin === "on" + ? cmath.rect.getCenter(initial_bounding_rect) + : cmath.rect.getCardinalPoint( + initial_bounding_rect, + cmath.compass.invertDirection(direction) + ); + + // #region snap (reuse same behavior as resize) + const should_snap = + draft.gesture_modifiers.scale_with_force_disable_snap !== "on"; + + let adjusted_raw_movement = rawMovement; + + if (should_snap) { + const snap_target_node_ids = getSnapTargets(selection, { + document_ctx: draft.document_ctx, + document: draft.document, + }); + + const snap_target_node_rects = snap_target_node_ids + .map((node_id: string) => { + const r = context.geometry.getNodeAbsoluteBoundingRect(node_id); + if (!r) reportError(`Node ${node_id} does not have a bounding rect`); + return r; + }) + .filter((r): r is cmath.Rectangle => r !== null && r !== undefined); + + const { adjusted_movement, snapping } = snapObjectsResize( + initial_rects, + { + objects: snap_target_node_rects, + guides: draft.ruler === "on" ? scene.guides : undefined, + }, + direction, + origin, + rawMovement, + threshold( + editor.config.DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR, + draft.transform + ), + { + enabled: should_snap, + preserveAspectRatio: true, + centerOrigin: transform_with_center_origin === "on", + } + ); + + adjusted_raw_movement = adjusted_movement; + draft.surface_snapping = snapping; + } else { + draft.surface_snapping = undefined; + } + // #endregion + + const direction_vector = cmath.compass.cardinal_direction_vector[direction]; + const center_multiplier: cmath.Vector2 = + transform_with_center_origin === "on" ? [2, 2] : [1, 1]; + + // Scale tool (K) prioritizes visual consistency (uniform similarity scale). + // To avoid "snapping/quantizing both axes" jitter, we only use the dominant + // movement axis to derive the uniform scale factor, then apply it uniformly. + const unadjusted = cmath.vector2.multiply( + direction_vector, + rawMovement, + center_multiplier + ); + const dominant_axis = dominantAxisByMovement(unadjusted); + + const movement = cmath.vector2.multiply( + direction_vector, + adjusted_raw_movement, + center_multiplier + ); + + const movement_for_factor: cmath.Vector2 = + dominant_axis === "x" ? [movement[0], 0] : [0, movement[1]]; + + const s = schema.parametric_scale._uniform_scale_factor( + initial_bounding_rect, + movement_for_factor, + 0.01 + ); + + // Expose canonical uniform scale factor on gesture state (used by UI). + draft.gesture.uniform_scale = s; + + // Reset affected nodes to the initial snapshot (prevents accumulation). + for (const id of affected_ids) { + const initial = initial_snapshot.document.nodes[id] as + | grida.program.nodes.Node + | undefined; + if (initial) { + draft.document.nodes[id] = deepClone(initial); + } + } + + for (const id of affected_ids) { + const node = draft.document.nodes[id] as + | grida.program.nodes.Node + | undefined; + if (!node) continue; + if (node.type === "scene") continue; + + schema.parametric_scale.apply_node(node, s); + } + + // `auto` origin semantics for selection roots (scene-direct only): + // after applying raw numeric scaling, override selection-root `left/top` so the + // anchor behaves selection-local (resize-like) without forcing layout resolution. + const auto_roots = collectAutoSpaceRootsFromGesture({ + draft, + selection, + initial_rects: initial_rects, + initial_snapshot, + }); + if (auto_roots.length) { + applyAutoSpaceRootLeftTopOverride({ + draft, + roots: auto_roots, + origin, + factor: s, + }); + } +} + +/** + * Apply parameter-space scaling as a one-shot command (used by the properties panel). + * + * This is a document rewrite (no persistent transform matrix): + * - geometry is recalculated from current authored geometry + * - geometry-contributing properties are rewritten via `schema.parametric_scale.apply_node` + */ +export function self_apply_scale_by_factor( + draft: Draft, + context: ReducerContext, + opts: { + targets: string[]; + factor: number; + origin: "center" | cmath.CardinalDirection; + include_subtree: boolean; + space?: "auto" | "global"; + } +) { + assert(draft.scene_id, "scene_id is not set"); + const s = schema.parametric_scale._clamp_scale(opts.factor); + if (s === 1) return; + + const space = opts.space ?? "auto"; + const targets = opts.targets; + if (!targets.length) return; + + const auto_roots = + space === "auto" + ? collectAutoSpaceRootsForCommand({ draft, context, targets }) + : []; + + const auto_bounds = + space === "auto" && auto_roots.length + ? cmath.rect.union(auto_roots.map((r) => r.initialRect)) + : null; + const auto_origin = + space === "auto" && auto_bounds + ? resolveScaleOriginPoint(auto_bounds, opts.origin) + : null; + + const affected = new Set(); + for (const root_id of targets) { + affected.add(root_id); + if (opts.include_subtree) { + dq.getChildren(draft.document_ctx, root_id, true).forEach((id) => + affected.add(id) + ); + } + } + const affected_ids = Array.from(affected); + + for (const id of affected_ids) { + const node = draft.document.nodes[id] as + | grida.program.nodes.Node + | undefined; + if (!node) continue; + if (node.type === "scene") continue; + + schema.parametric_scale.apply_node(node, s); + } + + if (space === "auto" && auto_origin && auto_roots.length) { + applyAutoSpaceRootLeftTopOverride({ + draft, + roots: auto_roots, + origin: auto_origin, + factor: s, + }); + } +} diff --git a/editor/grida-canvas/reducers/methods/tool.ts b/editor/grida-canvas/reducers/methods/tool.ts index 95c6763b55..dd147234a8 100644 --- a/editor/grida-canvas/reducers/methods/tool.ts +++ b/editor/grida-canvas/reducers/methods/tool.ts @@ -8,12 +8,16 @@ import grida from "@grida/schema"; const VECTOR_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = [ "cursor", + "scale", "hand", "bend", "path", "lasso", ]; -const TEXT_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = ["cursor"]; +const TEXT_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = [ + "cursor", + "scale", +]; const BITMAP_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = [ "brush", "eraser", @@ -21,6 +25,7 @@ const BITMAP_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = [ ]; const NO_CONTENT_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = [ "cursor", + "scale", "hand", "zoom", "insert", @@ -29,7 +34,7 @@ const NO_CONTENT_EDIT_MODE_VALID_TOOL_MODES: editor.state.ToolModeType[] = [ ]; // when reverting a tool while no content edit mode is active, path is invalid const NO_CONTENT_EDIT_MODE_VALID_REVERT_TOOL_MODES: editor.state.ToolModeType[] = - ["cursor", "hand", "zoom", "insert", "draw"]; + ["cursor", "scale", "hand", "zoom", "insert", "draw"]; function validToolsForContentEditMode( mode: editor.state.ContentEditModeState["type"] | undefined, diff --git a/editor/grida-canvas/reducers/methods/transform.ts b/editor/grida-canvas/reducers/methods/transform.ts index 511f2c7849..78e69451e6 100644 --- a/editor/grida-canvas/reducers/methods/transform.ts +++ b/editor/grida-canvas/reducers/methods/transform.ts @@ -19,6 +19,7 @@ import vn from "@grida/vn"; import tree from "@grida/tree"; import { EDITOR_GRAPH_POLICY } from "@/grida-canvas/policy"; import type { ReducerContext } from ".."; +import { self_update_gesture_scale } from "./scale"; /** * Determines if a node type allows hierarchy changes during translation. @@ -485,207 +486,7 @@ function __self_update_gesture_transform_scale( draft: Draft, context: ReducerContext ) { - assert( - draft.gesture.type === "scale" || - draft.gesture.type === "insert-and-resize", - "Gesture type must be scale or insert-and-resize" - ); - assert(draft.scene_id, "scene_id is not set"); - const scene = draft.document.nodes[ - draft.scene_id - ] as grida.program.nodes.SceneNode; - const { transform_with_center_origin, transform_with_preserve_aspect_ratio } = - draft.gesture_modifiers; - - const { - selection, - direction, - initial_snapshot, - movement: rawMovement, - initial_rects, - } = draft.gesture; - - const initial_bounding_rectangle = cmath.rect.union(initial_rects); - - // get the origin point based on handle - const origin = - transform_with_center_origin === "on" - ? cmath.rect.getCenter(initial_bounding_rectangle) - : cmath.rect.getCardinalPoint( - initial_bounding_rectangle, - // maps the resize handle (direction) to the transform origin point (inverse) - cmath.compass.invertDirection(direction) - ); - - // #region [snap] - const should_snap = - draft.gesture_modifiers.scale_with_force_disable_snap !== "on"; - - let adjusted_raw_movement = rawMovement; - - if (should_snap) { - const snap_target_node_ids = getSnapTargets(selection, { - document_ctx: draft.document_ctx, - document: draft.document, - }); - - const snap_target_node_rects = snap_target_node_ids - .map((node_id: string) => { - const r = context.geometry.getNodeAbsoluteBoundingRect(node_id); - if (!r) { - reportError(`Node ${node_id} does not have a bounding rect`); - } - return r; - }) - .filter((r): r is cmath.Rectangle => r !== null && r !== undefined); - - const { adjusted_movement, snapping } = snapObjectsResize( - initial_rects, - { - objects: snap_target_node_rects, - guides: draft.ruler === "on" ? scene.guides : undefined, - }, - direction, - origin, - rawMovement, - threshold( - editor.config.DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR, - draft.transform - ), - { - enabled: should_snap, - preserveAspectRatio: transform_with_preserve_aspect_ratio === "on", - centerOrigin: transform_with_center_origin === "on", - } - ); - - adjusted_raw_movement = adjusted_movement; - draft.surface_snapping = snapping; - } else { - draft.surface_snapping = undefined; - } - // #endregion - - // inverse the delta based on handle - const movement = cmath.vector2.multiply( - cmath.compass.cardinal_direction_vector[direction], - adjusted_raw_movement, - transform_with_center_origin === "on" ? [2, 2] : [1, 1] - ); - - let i = 0; - for (const node_id of selection) { - const node = draft.document.nodes[node_id]; - const initial_node = initial_snapshot.document.nodes[node_id]; - const initial_rect = initial_rects[i++]; - - const parent_id = dq.getParentId(draft.document_ctx, node_id); - const parent_node = parent_id ? dq.__getNodeById(draft, parent_id) : null; - const is_scene_parent = parent_node?.type === "scene"; - - // TODO: scaling for bitmap node is not supported yet. - const is_scalable = initial_node.type !== "bitmap"; - if (!is_scalable) continue; - - if (!parent_id || is_scene_parent) { - // Scene child or orphan - use absolute positioning - updateNodeTransform(node, { - type: "scale", - rect: initial_rect, - origin: origin, - movement, - preserveAspectRatio: transform_with_preserve_aspect_ratio === "on", - }); - } else { - // Nested node with non-scene parent - use relative positioning - const parent_rect = - context.geometry.getNodeAbsoluteBoundingRect(parent_id)!; - - assert( - parent_rect, - "Parent rect must be defined : " + parent_id + "/" + node_id - ); - - // the r position is relative to the canvas, we need to convert it to the node's local position - const relative_position = cmath.vector2.sub( - [initial_rect.x, initial_rect.y], - [parent_rect.x, parent_rect.y] - ); - const relative_rect: cmath.Rectangle = { - x: relative_position[0], - y: relative_position[1], - width: initial_rect.width, - height: initial_rect.height, - }; - - const relative_origin = cmath.vector2.sub(origin, [ - parent_rect.x, - parent_rect.y, - ]); - - updateNodeTransform(node, { - type: "scale", - rect: relative_rect, - origin: relative_origin, - movement, - preserveAspectRatio: transform_with_preserve_aspect_ratio === "on", - }); - } - - if (initial_node.type === "vector") { - // Get the final bounding box after transform (already respects aspect ratio) - // Vector network is in local coordinates, so we only need to compare dimensions - const vector_node = node as grida.program.nodes.VectorNode; - const initial_dimensions: cmath.Rectangle = { - x: 0, - y: 0, - width: initial_rect.width, - height: initial_rect.height, - }; - - const final_dimensions: cmath.Rectangle = { - x: 0, - y: 0, - width: vector_node.width ?? 0, - height: vector_node.height ?? 0, - }; - - // Calculate scale factors from initial to final dimensions - // This ensures vector network matches the bounding box transformation exactly - // Handle edge case: if initial dimensions are zero, preserve aspect ratio using non-zero dimension - let scale: cmath.Vector2; - if (initial_dimensions.width === 0 && initial_dimensions.height === 0) { - // Both dimensions are zero - no scaling - scale = [1, 1]; - } else if (initial_dimensions.width === 0) { - // Width is zero - scale uniformly based on height - const factor = - initial_dimensions.height !== 0 - ? final_dimensions.height / initial_dimensions.height - : 1; - scale = [factor, factor]; - } else if (initial_dimensions.height === 0) { - // Height is zero - scale uniformly based on width - const factor = - initial_dimensions.width !== 0 - ? final_dimensions.width / initial_dimensions.width - : 1; - scale = [factor, factor]; - } else { - // Normal case: calculate scale factors from rect dimensions - scale = cmath.rect.getScaleFactors( - initial_dimensions, - final_dimensions - ); - } - - const vne = new vn.VectorNetworkEditor(initial_node.vector_network); - vne.scale(scale); - ( - draft.document.nodes[node_id] as grida.program.nodes.VectorNode - ).vector_network = vne.value; - } - } + return self_update_gesture_scale(draft, context); } function __self_update_gesture_transform_rotate( diff --git a/editor/grida-canvas/reducers/schema/__tests__/schema.parametric-scale.test.ts b/editor/grida-canvas/reducers/schema/__tests__/schema.parametric-scale.test.ts new file mode 100644 index 0000000000..7e9b8bd9be --- /dev/null +++ b/editor/grida-canvas/reducers/schema/__tests__/schema.parametric-scale.test.ts @@ -0,0 +1,134 @@ +import cmath from "@grida/cmath"; + +import { schema } from "../schema"; + +describe("parametric_scale.scale_rect_about_anchor", () => { + it("matches spec anchor formula", () => { + const rect: cmath.Rectangle = { x: 10, y: 20, width: 100, height: 50 }; + const anchor: cmath.Vector2 = [0, 0]; + const s = 2; + + const out = schema.parametric_scale.scale_rect_about_anchor( + rect, + anchor, + s + ); + + expect(out).toEqual({ + x: 20, + y: 40, + width: 200, + height: 100, + }); + }); +}); + +describe("parametric_scale._fe_blur", () => { + it("scales radii for progressive blur but keeps normalized coords", () => { + const initial: any = { + type: "filter-blur", + active: true, + blur: { + type: "progressive-blur", + x1: -1, + y1: -0.5, + x2: 1, + y2: 0.5, + radius: 3, + radius2: 7, + }, + }; + + const out = schema.parametric_scale._fe_blur(initial, 2) as any; + + expect(out.blur.x1).toBe(-1); + expect(out.blur.y1).toBe(-0.5); + expect(out.blur.x2).toBe(1); + expect(out.blur.y2).toBe(0.5); + expect(out.blur.radius).toBe(6); + expect(out.blur.radius2).toBe(14); + }); +}); + +describe("parametric_scale._fe_shadow", () => { + it("scales dx/dy/blur/spread but preserves non-length fields", () => { + const initial: any = { + type: "shadow", + active: true, + inset: false, + color: { r: 1, g: 0, b: 0, a: 1 }, + dx: 1, + dy: -2, + blur: 3, + spread: 4, + }; + + const out = schema.parametric_scale._fe_shadow(initial, 3) as any; + + expect(out.type).toBe("shadow"); + expect(out.active).toBe(true); + expect(out.inset).toBe(false); + expect(out.color).toEqual(initial.color); + + expect(out.dx).toBe(3); + expect(out.dy).toBe(-6); + expect(out.blur).toBe(9); + expect(out.spread).toBe(12); + }); +}); + +describe("parametric_scale._fe_noise", () => { + it("scales noise_size but preserves density/seed", () => { + const initial: any = { + type: "noise", + active: true, + noise_size: 5, + density: 0.3, + seed: 42, + }; + + const out = schema.parametric_scale._fe_noise(initial, 0.5) as any; + + expect(out.noise_size).toBe(2.5); + expect(out.density).toBe(0.3); + expect(out.seed).toBe(42); + }); +}); + +describe("parametric_scale._stroke_width_profile", () => { + it("scales stop.r but preserves stop.u", () => { + const initial: any = { + stops: [ + { u: 0, r: 1 }, + { u: 0.5, r: 2 }, + ], + }; + + const out = schema.parametric_scale._stroke_width_profile( + initial, + 4 + ) as any; + + expect(out.stops).toEqual([ + { u: 0, r: 4 }, + { u: 0.5, r: 8 }, + ]); + }); +}); + +describe("parametric_scale.apply_node", () => { + it("scales stroke widths and dash arrays", () => { + const node: any = { + type: "rectangle", + stroke_width: 2, + stroke_dash_array: [1, 2, 3], + corner_radius: 4, + }; + + schema.parametric_scale.apply_node(node, 2); + + expect(node.stroke_width).toBe(4); + expect(node.stroke_dash_array).toEqual([2, 4, 6]); + expect(node.corner_radius).toBe(8); + }); +}); diff --git a/editor/grida-canvas/reducers/schema/index.ts b/editor/grida-canvas/reducers/schema/index.ts new file mode 100644 index 0000000000..1f9e0692f4 --- /dev/null +++ b/editor/grida-canvas/reducers/schema/index.ts @@ -0,0 +1 @@ +export { schema as default } from "./schema"; diff --git a/editor/grida-canvas/reducers/schema/schema.ts b/editor/grida-canvas/reducers/schema/schema.ts new file mode 100644 index 0000000000..6fcb256e99 --- /dev/null +++ b/editor/grida-canvas/reducers/schema/schema.ts @@ -0,0 +1,311 @@ +import type cg from "@grida/cg"; +import cmath from "@grida/cmath"; +import type grida from "@grida/schema"; +import vn from "@grida/vn"; + +/** + * Grida well-known schema specs and constraints. + */ +export namespace schema {} + +/** + * Parameter-space scaling helpers for Scale tool (K). + * + * This module contains the pure(ish) scaling rules used by the parametric scaling + * pipeline, such as: + * - calculating uniform similarity scale factors from gesture deltas + * - scaling rectangles around an anchor point + * - scaling geometry-contributing properties (stroke widths, radii, font sizes, effects, vector networks, etc.) + * + * Spec: https://grida.co/docs/wg/feat-authoring/parametric-scaling + */ +export namespace schema.parametric_scale { + type NodeScaleProps = Partial< + grida.program.nodes.i.ICornerRadius & + grida.program.nodes.i.IRectangularCornerRadius & + grida.program.nodes.i.IPositioning & + grida.program.nodes.i.ICSSDimension & + grida.program.nodes.i.IPadding & + grida.program.nodes.i.IFlexContainer & + grida.program.nodes.i.IStroke & + grida.program.nodes.i.IRectangularStrokeWidth & + grida.program.nodes.i.IEffects + >; + + export function _clamp_scale(s: number) { + if (!Number.isFinite(s)) return 1; + return Math.max(0.01, s); + } + + /** + * Calculates a uniform similarity scale factor from a movement delta. + * + * - Always returns a clamped scale \(s \ge 0.01\) + * - If `q` is provided, the return value is quantized to `q` precision + * + * Intended usage: + * - **Interactive gestures / UI**: pass `q = 0.01` to keep stable `0.00x` precision. + * - **Programmatic commands / API** (e.g. `applyScale(factor)`): use the factor as-is + * (developer intent) and do not route it through this helper. + */ + export function _uniform_scale_factor( + initial_bounds: cmath.Rectangle, + movement: cmath.Vector2, + q?: number + ) { + const w = initial_bounds.width; + const h = initial_bounds.height; + + if (w === 0 && h === 0) return 1; + + const dominantAxis = + Math.abs(movement[0]) > Math.abs(movement[1]) ? "x" : "y"; + + let s: number; + if (dominantAxis === "x") { + if (w === 0) { + s = h !== 0 ? (h + movement[1]) / h : 1; + } else { + s = (w + movement[0]) / w; + } + } else { + if (h === 0) { + s = w !== 0 ? (w + movement[0]) / w : 1; + } else { + s = (h + movement[1]) / h; + } + } + + const clamped = _clamp_scale(s); + if (typeof q === "number" && Number.isFinite(q) && q > 0) { + return cmath.quantize(clamped, q); + } + return clamped; + } + + export function scale_rect_about_anchor( + rect: cmath.Rectangle, + anchor: cmath.Vector2, + s: number + ): cmath.Rectangle { + return { + x: anchor[0] + (rect.x - anchor[0]) * s, + y: anchor[1] + (rect.y - anchor[1]) * s, + width: rect.width * s, + height: rect.height * s, + }; + } + + function scale_number( + v: number | null | undefined, + s: number + ): number | undefined { + return typeof v === "number" ? v * s : undefined; + } + + function scale_number_in_place( + obj: T, + key: keyof T, + s: number + ) { + if (typeof obj[key] === "number") { + obj[key] = (obj[key] * s) as T[typeof key]; + } + } + + function scale_number_array_in_place( + obj: T, + key: keyof T, + s: number + ) { + const v = obj[key]; + if (Array.isArray(v)) { + obj[key] = v.map((n) => + typeof n === "number" ? n * s : n + ) as T[typeof key]; + } + } + + export function _stroke_width_profile( + profile: cg.VariableWidthProfile, + s: number + ): cg.VariableWidthProfile { + return { + ...profile, + stops: profile.stops.map((stop) => ({ + ...stop, + r: typeof stop.r === "number" ? stop.r * s : stop.r, + })), + }; + } + + export function _fe_shadow(initial: cg.FeShadow, s: number): cg.FeShadow { + return { + ...initial, + dx: (initial.dx ?? 0) * s, + dy: (initial.dy ?? 0) * s, + blur: (initial.blur ?? 0) * s, + spread: (initial.spread ?? 0) * s, + }; + } + + export function _fe_blur(initial: cg.FeLayerBlur, s: number): cg.FeLayerBlur { + const blur = initial.blur; + switch (blur.type) { + case "blur": + return { + ...initial, + blur: { + ...blur, + radius: blur.radius * s, + }, + }; + case "progressive-blur": + return { + ...initial, + blur: { + ...blur, + // x1/y1/x2/y2 remain unchanged (normalized) + radius: blur.radius * s, + radius2: blur.radius2 * s, + }, + }; + } + } + + export function _fe_backdrop_blur( + initial: cg.FeBackdropBlur, + s: number + ): cg.FeBackdropBlur { + const blur = initial.blur; + switch (blur.type) { + case "blur": + return { + ...initial, + blur: { + ...blur, + radius: blur.radius * s, + }, + }; + case "progressive-blur": + return { + ...initial, + blur: { + ...blur, + radius: blur.radius * s, + radius2: blur.radius2 * s, + }, + }; + } + } + + export function _fe_liquid_glass( + initial: cg.FeLiquidGlass, + s: number + ): cg.FeLiquidGlass { + return { + ...initial, + depth: (initial.depth ?? 0) * s, + radius: (initial.radius ?? 0) * s, + }; + } + + export function _fe_noise(initial: cg.FeNoise, s: number): cg.FeNoise { + return { + ...initial, + noise_size: (initial.noise_size ?? 0) * s, + }; + } + + export function _vector_network( + initial: vn.VectorNetwork, + s: number + ): vn.VectorNetwork { + const vne = new vn.VectorNetworkEditor(initial); + vne.scale([s, s]); + return vne.value; + } + + export function apply_node(node: grida.program.nodes.Node, s: number) { + const n = node as NodeScaleProps; + + // Layout-ish lengths (treat as regular numeric fields; do not bake non-numeric values) + scale_number_in_place(n, "left", s); + scale_number_in_place(n, "top", s); + scale_number_in_place(n, "right", s); + scale_number_in_place(n, "bottom", s); + scale_number_in_place(n, "width", s); + scale_number_in_place(n, "height", s); + + // General geometry-ish lengths + scale_number_in_place(n, "corner_radius", s); + scale_number_in_place(n, "rectangular_corner_radius_top_left", s); + scale_number_in_place(n, "rectangular_corner_radius_top_right", s); + scale_number_in_place(n, "rectangular_corner_radius_bottom_left", s); + scale_number_in_place(n, "rectangular_corner_radius_bottom_right", s); + + // Padding (number or per-side) + const padding = n.padding; + if (typeof padding === "number") { + n.padding = padding * s; + } else if (padding && typeof padding === "object") { + n.padding = { + ...padding, + padding_top: + scale_number(padding.padding_top, s) ?? padding.padding_top, + padding_right: + scale_number(padding.padding_right, s) ?? padding.padding_right, + padding_bottom: + scale_number(padding.padding_bottom, s) ?? padding.padding_bottom, + padding_left: + scale_number(padding.padding_left, s) ?? padding.padding_left, + }; + } + + scale_number_in_place(n, "main_axis_gap", s); + scale_number_in_place(n, "cross_axis_gap", s); + + // Stroke + scale_number_in_place(n, "stroke_width", s); + scale_number_in_place(n, "rectangular_stroke_width_top", s); + scale_number_in_place(n, "rectangular_stroke_width_right", s); + scale_number_in_place(n, "rectangular_stroke_width_bottom", s); + scale_number_in_place(n, "rectangular_stroke_width_left", s); + scale_number_array_in_place(n, "stroke_dash_array", s); + + const swp = n.stroke_width_profile; + if (swp) { + n.stroke_width_profile = _stroke_width_profile(swp, s); + } + + // Text + if (node.type === "text") { + scale_number_in_place(node, "font_size", s); + } + + // Effects + if (n.fe_shadows) { + n.fe_shadows = n.fe_shadows.map((sh) => _fe_shadow(sh, s)); + } + if (n.fe_blur) { + n.fe_blur = _fe_blur(n.fe_blur, s); + } + if (n.fe_backdrop_blur) { + n.fe_backdrop_blur = _fe_backdrop_blur(n.fe_backdrop_blur, s); + } + if (n.fe_liquid_glass) { + n.fe_liquid_glass = _fe_liquid_glass(n.fe_liquid_glass, s); + } + if (n.fe_noises) { + n.fe_noises = n.fe_noises.map((fx) => _fe_noise(fx, s)); + } + + // Vector geometry + if (node.type === "vector") { + node.vector_network = _vector_network(node.vector_network, s); + } + + // NOTE: `svgpath.paths` scaling is intentionally not implemented here yet. + // The `svgpath` node type is deprecated and rarely used in production. + } +} diff --git a/editor/grida-canvas/reducers/surface.reducer.ts b/editor/grida-canvas/reducers/surface.reducer.ts index 5d84542e21..0e92b5b697 100644 --- a/editor/grida-canvas/reducers/surface.reducer.ts +++ b/editor/grida-canvas/reducers/surface.reducer.ts @@ -18,6 +18,7 @@ import { self_try_remove_node, self_select_tool, self_revert_tool, + self_start_gesture_scale, } from "./methods"; import type { BitmapEditorBrush } from "@grida/bitmap"; import type { ReducerContext } from "."; @@ -529,7 +530,7 @@ function __self_start_gesture( draft.content_edit_mode = undefined; draft.hovered_node_id = null; - __self_start_gesture_scale(draft, { + self_start_gesture_scale(draft, { selection: selection, direction: direction, context, @@ -876,87 +877,6 @@ function __self_start_gesture( } } -function __self_start_gesture_scale( - draft: Draft, - { - selection, - direction, - context, - }: { - selection: string[]; - direction: cmath.CardinalDirection; - context: ReducerContext; - } -) { - if (selection.length === 0) return; - const rects = selection.map( - (node_id) => context.geometry.getNodeAbsoluteBoundingRect(node_id)! - ); - - draft.gesture = { - type: "scale", - initial_snapshot: editor.state.snapshot(draft), - initial_rects: rects, - movement: cmath.vector2.zero, - first: cmath.vector2.zero, - last: cmath.vector2.zero, - selection: selection, - direction: direction, - }; - - let i = 0; - for (const node_id of selection) { - const node = dq.__getNodeById(draft, node_id); - const rect = rects[i++]; - - // once the node's measurement mode is set to fixed (from drag start), we may safely cast the width / height sa fixed number - // need to assign a fixed size if width or height is a variable length - const _node = node as grida.program.nodes.i.ICSSDimension; - - // needs to set width - if ( - direction === "e" || - direction === "w" || - direction === "ne" || - direction === "se" || - direction === "nw" || - direction === "sw" - ) { - if (typeof _node.width !== "number") { - // For text nodes, use ceil to ensure we don't cut off content - if (node.type === "text") { - _node.width = Math.ceil(rect.width); - } else { - _node.width = cmath.quantize(rect.width, 1); - } - } - } - - // needs to set height - if ( - direction === "n" || - direction === "s" || - direction === "ne" || - direction === "nw" || - direction === "se" || - direction === "sw" - ) { - if (typeof _node.height !== "number") { - if (node.type === "line") { - _node.height = 0; - } else { - // For text nodes, use ceil to ensure we don't cut off content - if (node.type === "text") { - _node.height = Math.ceil(rect.height); - } else { - _node.height = cmath.quantize(rect.height, 1); - } - } - } - } - } -} - function __self_start_gesture_rotate( draft: Draft, { diff --git a/editor/jest.config.js b/editor/jest.config.js index 3bfed5d3a6..3f29d6fb4b 100644 --- a/editor/jest.config.js +++ b/editor/jest.config.js @@ -11,7 +11,7 @@ const config = { coverageProvider: "v8", testEnvironment: "node", // Add more setup options before each test is run - // setupFilesAfterEnv: ['/jest.setup.ts'], + setupFilesAfterEnv: ["/jest.setup.ts"], moduleNameMapper: { // ... "^@/(.*)$": "/$1", diff --git a/editor/jest.setup.ts b/editor/jest.setup.ts new file mode 100644 index 0000000000..e8b1d1bbfc --- /dev/null +++ b/editor/jest.setup.ts @@ -0,0 +1,56 @@ +// Jest runs in a CJS runtime here, but `svg-pathdata` is ESM-only. +// Mock it so packages that import it (e.g. `@grida/vn`) can still be loaded in tests. +jest.mock("svg-pathdata", () => { + const SVGCommand = {} as any; + + function encodeSVGPath(commands: any[]): string { + // Minimal encoder used for unit tests that don't care about exact SVG formatting. + let result = ""; + for (const cmd of commands) { + switch (cmd.type) { + case 1: // MOVE_TO + result += `M${cmd.x} ${cmd.y}`; + break; + case 2: // LINE_TO + result += `L${cmd.x} ${cmd.y}`; + break; + case 3: // CURVE_TO + result += `C${cmd.x1} ${cmd.y1} ${cmd.x2} ${cmd.y2} ${cmd.x} ${cmd.y}`; + break; + case 4: // CLOSE_PATH + result += "Z"; + break; + } + } + return result; + } + + class SVGPathData { + static MOVE_TO = 1; + static LINE_TO = 2; + static CURVE_TO = 3; + static CLOSE_PATH = 4; + + // `@grida/vn` uses these too + static HORIZ_LINE_TO = 5; + static VERT_LINE_TO = 6; + static QUAD_TO = 7; + static SMOOTH_QUAD_TO = 8; + static ARC = 9; + + commands: any[] = []; + + constructor(_d?: string) {} + + toAbs() { + return this; + } + } + + return { + __esModule: true, + SVGCommand, + encodeSVGPath, + SVGPathData, + }; +}); diff --git a/editor/public/assets/css-cursors-grida/README.md b/editor/public/assets/css-cursors-grida/README.md index 2af0ba0274..428a1264ed 100644 --- a/editor/public/assets/css-cursors-grida/README.md +++ b/editor/public/assets/css-cursors-grida/README.md @@ -1,32 +1,42 @@ # Grida Custom Cursors -License: Public Domain +License: Public Domain Original Author: Grida -Naming: +## Naming -## [`name`]-[`size`]-[x`X`y`Y`]-[`fill`].[`ext`] +`[name]-[size]-x[X]y[Y]-[fill].[ext]` -- `name` - the name of the cursor -- `size` - the size of the cursor (always square) -- `xXyY` - the x and y hot spot of the cursor -- `fill` - the fill color of the cursor in hex format (without #) -- `ext` - the extension of the cursor +- **name**: cursor name +- **size**: cursor image size (currently always square) +- **xXyY**: hotspot coordinates in the _image_ (x, y) +- **fill**: fill color in hex (without `#`) +- **ext**: file extension -## Examples +## Cursors -- `default-64-x28y28-000000.png` +| name | size | hotspot (image) | fill | png | svg | +| ------------------- | ---: | --------------: | -------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `default` | `64` | `x28 y28` | `000000` | [`default-64-x28y28-000000.png`](./default-64-x28y28-000000.png) | [`default-64-x28y28-000000.svg`](./default-64-x28y28-000000.svg) | +| `ew-resize` | `64` | `x32 y32` | `000000` | [`ew-resize-64-x32y32-000000.png`](./ew-resize-64-x32y32-000000.png) | [`ew-resize-64-x32y32-000000.svg`](./ew-resize-64-x32y32-000000.svg) | +| `ew-resize-scale` | `64` | `x32 y32` | `000000` | [`ew-resize-scale-64-x32y32-000000.png`](./ew-resize-scale-64-x32y32-000000.png) | [`ew-resize-scale-64-x32y32-000000.svg`](./ew-resize-scale-64-x32y32-000000.svg) | +| `ns-resize` | `64` | `x32 y32` | `000000` | [`ns-resize-64-x32y32-000000.png`](./ns-resize-64-x32y32-000000.png) | [`ns-resize-64-x32y32-000000.svg`](./ns-resize-64-x32y32-000000.svg) | +| `ns-resize-scale` | `64` | `x32 y32` | `000000` | [`ns-resize-scale-64-x32y32-000000.png`](./ns-resize-scale-64-x32y32-000000.png) | [`ns-resize-scale-64-x32y32-000000.svg`](./ns-resize-scale-64-x32y32-000000.svg) | +| `nesw-resize` | `64` | `x32 y32` | `000000` | [`nesw-resize-64-x32y32-000000.png`](./nesw-resize-64-x32y32-000000.png) | [`nesw-resize-64-x32y32-000000.svg`](./nesw-resize-64-x32y32-000000.svg) | +| `nesw-resize-scale` | `64` | `x32 y32` | `000000` | [`nesw-resize-scale-64-x32y32-000000.png`](./nesw-resize-scale-64-x32y32-000000.png) | [`nesw-resize-scale-64-x32y32-000000.svg`](./nesw-resize-scale-64-x32y32-000000.svg) | +| `nwse-resize` | `64` | `x32 y32` | `000000` | [`nwse-resize-64-x32y32-000000.png`](./nwse-resize-64-x32y32-000000.png) | [`nwse-resize-64-x32y32-000000.svg`](./nwse-resize-64-x32y32-000000.svg) | +| `nwse-resize-scale` | `64` | `x32 y32` | `000000` | [`nwse-resize-scale-64-x32y32-000000.png`](./nwse-resize-scale-64-x32y32-000000.png) | [`nwse-resize-scale-64-x32y32-000000.svg`](./nwse-resize-scale-64-x32y32-000000.svg) | ## Usage +When using `url-set()` (1x/2x), the hotspot coordinates must match the _rendered_ cursor size. If you provide a `64px` cursor image as `2x`, the CSS hotspot is `(hotspot_in_image / 2)`. + ```css cursor: url-set( - url("https://grida.co/assets/css-cursors-grida/default-64-x28y28-000000.png") - 2x, - url("https://grida.co/assets/css-cursors-grida/default-64-x28y28-000000.png") - 1x + url("/assets/css-cursors-grida/default-64-x28y28-000000.png") 2x, + url("/assets/css-cursors-grida/default-64-x28y28-000000.png") 1x ) - /* (28/2) = (14) */ 14 14, + /* (28/2) = 14 */ 14 14, default; ``` diff --git a/editor/public/assets/css-cursors-grida/ew-resize-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/ew-resize-64-x32y32-000000.png new file mode 100644 index 0000000000..5acd3872c2 Binary files /dev/null and b/editor/public/assets/css-cursors-grida/ew-resize-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/ew-resize-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/ew-resize-64-x32y32-000000.svg new file mode 100644 index 0000000000..66d8eb28fe --- /dev/null +++ b/editor/public/assets/css-cursors-grida/ew-resize-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.png new file mode 100644 index 0000000000..3db39dce1c Binary files /dev/null and b/editor/public/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.svg new file mode 100644 index 0000000000..9a097e528e --- /dev/null +++ b/editor/public/assets/css-cursors-grida/ew-resize-scale-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.png new file mode 100644 index 0000000000..45c08ffaac Binary files /dev/null and b/editor/public/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.svg new file mode 100644 index 0000000000..2142d3a5a1 --- /dev/null +++ b/editor/public/assets/css-cursors-grida/nesw-resize-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.png new file mode 100644 index 0000000000..2c36272480 Binary files /dev/null and b/editor/public/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.svg new file mode 100644 index 0000000000..c370de2492 --- /dev/null +++ b/editor/public/assets/css-cursors-grida/nesw-resize-scale-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/ns-resize-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/ns-resize-64-x32y32-000000.png new file mode 100644 index 0000000000..24a16b33ea Binary files /dev/null and b/editor/public/assets/css-cursors-grida/ns-resize-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/ns-resize-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/ns-resize-64-x32y32-000000.svg new file mode 100644 index 0000000000..15de873a7c --- /dev/null +++ b/editor/public/assets/css-cursors-grida/ns-resize-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.png new file mode 100644 index 0000000000..9e735d41a1 Binary files /dev/null and b/editor/public/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.svg new file mode 100644 index 0000000000..68386efc88 --- /dev/null +++ b/editor/public/assets/css-cursors-grida/ns-resize-scale-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.png new file mode 100644 index 0000000000..3d9206cb68 Binary files /dev/null and b/editor/public/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.svg new file mode 100644 index 0000000000..61499e370f --- /dev/null +++ b/editor/public/assets/css-cursors-grida/nwse-resize-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.png b/editor/public/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.png new file mode 100644 index 0000000000..382de5e3b2 Binary files /dev/null and b/editor/public/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.png differ diff --git a/editor/public/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.svg b/editor/public/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.svg new file mode 100644 index 0000000000..85bf942405 --- /dev/null +++ b/editor/public/assets/css-cursors-grida/nwse-resize-scale-64-x32y32-000000.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/scaffolds/sidecontrol/chunks/scale-tool.tsx b/editor/scaffolds/sidecontrol/chunks/scale-tool.tsx new file mode 100644 index 0000000000..861fc89747 --- /dev/null +++ b/editor/scaffolds/sidecontrol/chunks/scale-tool.tsx @@ -0,0 +1,252 @@ +"use client"; + +import * as React from "react"; +import type { Editor } from "@/grida-canvas/editor"; +import cmath from "@grida/cmath"; +import { + SidebarMenuSectionContent, + SidebarSection, + SidebarSectionHeaderActions, + SidebarSectionHeaderItem, + SidebarSectionHeaderLabel, +} from "@/components/sidebar"; +import { Button } from "@/components/ui-editor/button"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { PropertyLine, PropertyLineLabel } from "../ui"; +import InputPropertyNumber from "../ui/number"; +import { ScaleFactorControl } from "../controls/scale-factor"; +import { Alignment9Control } from "../controls/alignment9"; +import { useEditorState } from "@/grida-canvas-react"; + +/** + * Manages the Scale tool's *session-scale* value. + * + * Why this exists: + * - The Scale tool (K) can be driven by **two sources**: + * 1) direct UI edits (typing a scale value) + * 2) an interactive canvas gesture (drag scale handles) + * - During a gesture, the reducer tracks a `gesture.uniform_scale` that is + * relative to the gesture's own start (1 → ...). + * - The panel's `sessionScale` is an **ephemeral, per-panel baseline** that can + * already be != 1 before a new gesture begins (e.g. user typed 2x, then drags). + * + * Contract: + * - On gesture start, capture the current `sessionScale` as baseline. + * - While gesture is active, keep `sessionScale = baseline * uniform_scale`. + * - When the panel is re-initialized (selection/tool changes), reset gesture baseline. + */ +function useScaleToolSessionScale({ + editor, + visible, + selection_key, + sessionScale, + setSessionScale, +}: { + editor: Editor; + visible: boolean; + selection_key: string; + sessionScale: number; + setSessionScale: (v: number) => void; +}) { + const isParametricScaling = useEditorState(editor, (state) => { + const g = state.gesture; + return g.type === "scale" && g.mode === "parametric"; + }); + + const uniformScale = useEditorState(editor, (state) => { + const g = state.gesture; + if (g.type !== "scale" || g.mode !== "parametric") return null; + return g.uniform_scale ?? 1; + }); + + const session_scale_at_gesture_start = React.useRef(1); + const was_parametric_scaling = React.useRef(false); + + React.useEffect(() => { + // Capture the session scale baseline when a new drag gesture begins. + if (isParametricScaling && !was_parametric_scaling.current) { + session_scale_at_gesture_start.current = sessionScale; + } + was_parametric_scaling.current = isParametricScaling; + }, [isParametricScaling, sessionScale]); + + React.useEffect(() => { + if (!isParametricScaling) return; + if (uniformScale === null) return; + if (!Number.isFinite(uniformScale)) return; + + const next = session_scale_at_gesture_start.current * uniformScale; + setSessionScale(next); + }, [isParametricScaling, uniformScale, setSessionScale]); + + React.useEffect(() => { + // If the panel is re-initialized, also reset the gesture baseline. + session_scale_at_gesture_start.current = 1; + was_parametric_scaling.current = false; + }, [visible, selection_key]); +} + +export function ScaleToolSection({ + visible, + selection, + editor, +}: { + visible: boolean; + selection: string[]; + editor: Editor; +}) { + const selection_key = selection.join(","); + const [baseRect, setBaseRect] = React.useState(null); + const [sessionScale, setSessionScale] = React.useState(1); + const [sessionOrigin, setSessionOrigin] = + React.useState("center"); + + // UX: + // The Scale tool panel auto-focuses the scale factor input when the user enters the tool (K). + // We should auto-exit the tool only if the user commits *directly* from that initial focus + // state (e.g. K -> type -> Enter). If the user interacted elsewhere (the auto-focused input + // ever blurred), we do NOT auto-exit on commit. + const didAutofocusScaleFactorEverBlur = React.useRef(false); + + useScaleToolSessionScale({ + editor, + visible, + selection_key, + sessionScale, + setSessionScale, + }); + + React.useEffect(() => { + if (!visible) return; + didAutofocusScaleFactorEverBlur.current = false; + }, [visible]); + + React.useEffect(() => { + if (!visible) return; + if (!selection.length) return; + + const rects = selection + .map((id) => editor.geometryProvider.getNodeAbsoluteBoundingRect(id)) + .filter((r): r is cmath.Rectangle => !!r); + if (!rects.length) return; + + setBaseRect(cmath.rect.union(rects)); + setSessionScale(1); + setSessionOrigin("center"); + }, [visible, selection_key, editor]); + + const baseWidth = baseRect?.width ?? 0; + const baseHeight = baseRect?.height ?? 0; + + const width = baseWidth * sessionScale; + const height = baseHeight * sessionScale; + + const applyNewScale = React.useCallback( + (next: number) => { + if (!visible) return; + if (!selection.length) return; + if (!Number.isFinite(next)) return; + + const nextScale = Math.max(0.01, next); + const factorToApply = nextScale / sessionScale; + + // apply delta factor to the authored state (anchor from selection alignment) + if (Number.isFinite(factorToApply) && factorToApply !== 1) { + editor.commands.applyScale(selection, factorToApply, { + origin: cmath.compass.fromAlignment9(sessionOrigin), + include_subtree: true, + }); + + // Auto-exit only if the user committed directly from the initial autofocus. + if (!didAutofocusScaleFactorEverBlur.current) { + editor.surface.surfaceSetTool( + { type: "cursor" }, + "scale-tool/applyScale" + ); + } + } + + // keep panel-internal scale as display state (ephemeral session memory) + setSessionScale(nextScale); + }, + [editor, selection, sessionOrigin, sessionScale, visible] + ); + + if (!visible || selection.length === 0) return null; + + return ( + + + Scale + + + + + + + Size +
+
+ { + if (!baseWidth) return; + applyNewScale(v / baseWidth); + }} + icon={ + W + } + /> +
+
+ { + if (!baseHeight) return; + applyNewScale(v / baseHeight); + }} + icon={ + H + } + /> +
+
+
+ + Scale +
+
+ { + didAutofocusScaleFactorEverBlur.current = true; + }} + /> +
+ +
+
+
+
+ ); +} diff --git a/editor/scaffolds/sidecontrol/controls/alignment9.tsx b/editor/scaffolds/sidecontrol/controls/alignment9.tsx new file mode 100644 index 0000000000..dc89e6c05c --- /dev/null +++ b/editor/scaffolds/sidecontrol/controls/alignment9.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { cn } from "@/components/lib/utils"; +import grida from "@grida/schema"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type cmath from "@grida/cmath"; +type TMixed = typeof grida.mixed | T; + +const ALIGNMENTS: Array<{ + key: cmath.Alignment9; + label: string; +}> = [ + { key: "top-left", label: "Top left" }, + { key: "top-center", label: "Top" }, + { key: "top-right", label: "Top right" }, + { key: "center-left", label: "Left" }, + { key: "center", label: "Center" }, + { key: "center-right", label: "Right" }, + { key: "bottom-left", label: "Bottom left" }, + { key: "bottom-center", label: "Bottom" }, + { key: "bottom-right", label: "Bottom right" }, +]; + +export function Alignment9Control({ + value, + onValueChange, + className, +}: { + value?: TMixed; + onValueChange?: (value: cmath.Alignment9) => void; + className?: string; +}) { + const isMixed = value === grida.mixed; + const selected = !isMixed ? value : undefined; + const selectedIndex = + selected != null ? ALIGNMENTS.findIndex((o) => o.key === selected) : -1; + const rovingTabIndex = selectedIndex >= 0 ? selectedIndex : 0; + + return ( +
+ {ALIGNMENTS.map((o, index) => { + const isSelected = selected === o.key; + return ( + + + + + {o.label} + + ); + })} +
+ ); +} diff --git a/editor/scaffolds/sidecontrol/controls/fe.tsx b/editor/scaffolds/sidecontrol/controls/fe.tsx index da4c35807a..4dfa9bed0e 100644 --- a/editor/scaffolds/sidecontrol/controls/fe.tsx +++ b/editor/scaffolds/sidecontrol/controls/fe.tsx @@ -534,6 +534,7 @@ function FeShadowProperties({ Color onValueChange?.({ ...value, color: v })} /> diff --git a/editor/scaffolds/sidecontrol/controls/font-size.tsx b/editor/scaffolds/sidecontrol/controls/font-size.tsx index 6c350bea5c..6a2d996633 100644 --- a/editor/scaffolds/sidecontrol/controls/font-size.tsx +++ b/editor/scaffolds/sidecontrol/controls/font-size.tsx @@ -45,7 +45,7 @@ export function FontSizeControl({ }} > - diff --git a/editor/scaffolds/sidecontrol/controls/scale-factor.tsx b/editor/scaffolds/sidecontrol/controls/scale-factor.tsx new file mode 100644 index 0000000000..3ca58183c4 --- /dev/null +++ b/editor/scaffolds/sidecontrol/controls/scale-factor.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/components/lib/utils"; +import { + Select, + SelectContent, + SelectItem, +} from "@/components/ui-editor/select"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import InputPropertyNumber from "../ui/number"; + +const DEFAULT_SCALE_PRESETS = [0.25, 0.5, 1, 2, 3, 4, 5, 10] as const; + +export function ScaleFactorControl({ + value, + onValueCommit, + presets = DEFAULT_SCALE_PRESETS, + autoFocus, + onInputBlur, +}: { + value: number; + onValueCommit: (value: number) => void; + presets?: ReadonlyArray; + autoFocus?: boolean; + onInputBlur?: React.FocusEventHandler; +}) { + const hasPreset = presets.some((p) => Math.abs(p - value) < 1e-9); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/editor/scaffolds/sidecontrol/sidecontrol-node-selection.tsx b/editor/scaffolds/sidecontrol/sidecontrol-node-selection.tsx index fabf082f41..95cc89df0c 100644 --- a/editor/scaffolds/sidecontrol/sidecontrol-node-selection.tsx +++ b/editor/scaffolds/sidecontrol/sidecontrol-node-selection.tsx @@ -72,6 +72,7 @@ import { useSelectionState, useBackendState, useContentEditModeMinimalState, + useToolState, } from "@/grida-canvas-react/provider"; import { Checkbox } from "@/components/ui/checkbox"; import { Toggle } from "@/components/ui/toggle"; @@ -99,6 +100,7 @@ import { FeControl } from "./controls/fe"; import InputPropertyNumber from "./ui/number"; import { ArcPropertiesControl } from "./controls/arc-properties"; import { ModeVectorEditModeProperties } from "./chunks/mode-vector"; +import { ScaleToolSection } from "./chunks/scale-tool"; import { SectionFills } from "./chunks/section-fills"; import { SectionStrokes } from "./chunks/section-strokes"; import { OpsControl } from "./controls/ext-ops"; @@ -206,8 +208,10 @@ export function Selection({ (state) => state.document.properties ); const cem = useContentEditModeMinimalState(); + const tool = useToolState(); const is_vector_edit_mode = cem?.type === "vector"; + const is_scale_tool = tool.type === "scale"; const selection_length = selection.length; const is_empty = selection_length === 0 && !is_vector_edit_mode; @@ -221,17 +225,27 @@ export function Selection({ properties: documentProperties, }} > -
- {is_vector_edit_mode ? ( - + {is_scale_tool ? ( + ) : ( <> - {selection_length === 0 && empty && empty} - {selection_length === 1 && ( - - )} - {selection_length > 1 && ( - + {is_vector_edit_mode ? ( + + ) : ( + <> +
+ {selection_length === 0 && empty && empty} + {selection_length === 1 && ( + + )} + {selection_length > 1 && ( + + )} + )} )} @@ -498,19 +512,23 @@ function ModeMixedNodeProperties({ ; /** Step size for increment/decrement operations */ step?: number; + /** Optional suffix to append to the displayed value (e.g., "%", "px", "x") */ + suffix?: string; + /** Optional scale factor for display (e.g., 100 for percentages: 0.01 -> 1%) */ + scale?: number; /** Whether to automatically select all text when the input is focused */ autoSelect?: boolean; /** Minimum allowed value */ @@ -129,6 +133,8 @@ export default function InputPropertyNumber({ onValueChange, onValueCommit, step = 1, + suffix, + scale, autoSelect = true, min, max, @@ -160,6 +166,8 @@ export default function InputPropertyNumber({ onValueChange, onValueCommit, commitOnBlur, + suffix, + scale, }); // Track focus state for data-focus attribute diff --git a/fixtures/test-grida/README.md b/fixtures/test-grida/README.md new file mode 100644 index 0000000000..99c3a8fd73 --- /dev/null +++ b/fixtures/test-grida/README.md @@ -0,0 +1,18 @@ +## Test `.grida` fixtures + +This directory contains **meaningful** `.grida` files used for **testing**. + +### Naming convention + +- **Prefix**: `d[n]` is a simple counter (`d1`, `d2`, `d3`, ...). +- **Schema version specifier**: we encode the schema version **build metadata date** as `yyyymmdd`. + - Example: schema version `0.0.4-beta+20251209` → version specifier `20251209` + - **Note**: this `yyyymmdd` is **not** the authoring date of the file. + +### Support expectations (important) + +- The Grida schema evolves rapidly; **tests should prefer replacing fixtures** with current ones rather than migrating old fixtures forever. +- Some fixtures here may be **legacy** and can become **permanently unsupported**. They are kept for **historical context** and **current-version regression testing only**. +- **Do not use these files in production**, and **do not assume** every file in this folder will load in the latest version. + +> Current Version: `0.0.4-beta+20251209` (last updated: 2025-12-15) diff --git a/fixtures/test-grida/d1-20251209.grida b/fixtures/test-grida/d1-20251209.grida new file mode 100644 index 0000000000..2a08294189 Binary files /dev/null and b/fixtures/test-grida/d1-20251209.grida differ diff --git a/packages/grida-canvas-vn/__mocks__/svg-pathdata.ts b/packages/grida-canvas-vn/__mocks__/svg-pathdata.ts deleted file mode 100644 index c89b9a04c5..0000000000 --- a/packages/grida-canvas-vn/__mocks__/svg-pathdata.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const SVGCommand = {} as any; -export function encodeSVGPath(commands: any[]): string { - // Simple implementation for testing - let result = ""; - for (const cmd of commands) { - switch (cmd.type) { - case 1: // MOVE_TO - result += `M${cmd.x} ${cmd.y} `; - break; - case 2: // LINE_TO - result += `L${cmd.x} ${cmd.y} `; - break; - case 3: // CURVE_TO - result += `C${cmd.x1} ${cmd.y1} ${cmd.x2} ${cmd.y2} ${cmd.x} ${cmd.y} `; - break; - case 4: // CLOSE_PATH - result += "Z "; - break; - } - } - return result.trim(); -} -export class SVGPathData { - static MOVE_TO = 1; - static LINE_TO = 2; - static CURVE_TO = 3; - static CLOSE_PATH = 4; - - constructor(d?: string) {} - toAbs() { - return this; - } - commands: any[] = []; -} diff --git a/packages/grida-canvas-vn/__tests__/vn.test.ts b/packages/grida-canvas-vn/__tests__/vn.test.ts index 8fb2a00603..9ba8868a0c 100644 --- a/packages/grida-canvas-vn/__tests__/vn.test.ts +++ b/packages/grida-canvas-vn/__tests__/vn.test.ts @@ -532,11 +532,8 @@ describe("getLoopPathData", () => { // The path should start with M (move to), have L (line to) commands, and end with Z (close path) expect(pathData).toMatch( - /^M\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+Z$/ + /^M\d+\.?\d*\s*\d+\.?\d*(?:\s*L\d+\.?\d*\s*\d+\.?\d*){3}\s*[zZ]$/ ); - - // Verify the actual path data format - expect(pathData).toBe("M10 0 L5 10 L0 0 L10 0 Z"); }); it("should generate path data for a rectangle loop", () => { @@ -554,7 +551,7 @@ describe("getLoopPathData", () => { // Should generate a closed rectangular path expect(pathData).toMatch( - /^M\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+L\d+\.?\d*\s+\d+\.?\d*\s+Z$/ + /^M\d+\.?\d*\s*\d+\.?\d*(?:\s*L\d+\.?\d*\s*\d+\.?\d*){4}\s*[zZ]$/ ); }); @@ -593,6 +590,6 @@ describe("getLoopPathData", () => { // Verify the actual path data format includes a curve expect(pathData).toContain("C"); - expect(pathData).toContain("Z"); + expect(pathData).toMatch(/[zZ]/); }); }); diff --git a/packages/grida-canvas-vn/jest.config.ts b/packages/grida-canvas-vn/jest.config.ts new file mode 100644 index 0000000000..68a96fbe31 --- /dev/null +++ b/packages/grida-canvas-vn/jest.config.ts @@ -0,0 +1,17 @@ +import type { Config } from "jest"; + +const config: Config = { + // Needed because `svg-pathdata` is ESM-only ("type": "module") + preset: "ts-jest/presets/default-esm", + testEnvironment: "node", + testMatch: ["**/*.test.ts"], + collectCoverageFrom: [ + "**/*.ts", + "!**/*.d.ts", + "!**/node_modules/**", + "!dist/**", + ], + extensionsToTreatAsEsm: [".ts"], +}; + +export default config; diff --git a/packages/grida-canvas-vn/package.json b/packages/grida-canvas-vn/package.json index e2bb450ed4..7d4c90afcb 100644 --- a/packages/grida-canvas-vn/package.json +++ b/packages/grida-canvas-vn/package.json @@ -1,19 +1,29 @@ { "name": "@grida/vn", "description": "Vector Network Spec", + "version": "0.0.0", "private": true, "scripts": { - "test": "jest" + "dev": "tsup index.ts --format cjs,esm --dts --watch", + "build": "tsup index.ts --format cjs,esm --dts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "NODE_OPTIONS=--experimental-vm-modules jest" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } }, "dependencies": { "@grida/cmath": "workspace:*", "svg-pathdata": "^7.2.0" - }, - "jest": { - "preset": "ts-jest", - "moduleNameMapper": { - "^@grida/cmath$": "/../grida-cmath/index.ts", - "^svg-pathdata$": "/__mocks__/svg-pathdata.ts" - } } } diff --git a/packages/grida-canvas-vn/tsconfig.json b/packages/grida-canvas-vn/tsconfig.json index 2f98042715..e14059d6b3 100644 --- a/packages/grida-canvas-vn/tsconfig.json +++ b/packages/grida-canvas-vn/tsconfig.json @@ -1,5 +1,10 @@ { "compilerOptions": { - "esModuleInterop": true - } + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2020", + "strict": true + }, + "exclude": ["dist", "node_modules"] } diff --git a/packages/grida-canvas-vn/vn.ts b/packages/grida-canvas-vn/vn.ts index 106ff4411f..cf4e170ece 100644 --- a/packages/grida-canvas-vn/vn.ts +++ b/packages/grida-canvas-vn/vn.ts @@ -2308,12 +2308,12 @@ export namespace vn { const end: Vector2 = [command.x, command.y]; const cubicControl1: Vector2 = [ - lastPoint[0] + ((2 / 3) * (control[0] - lastPoint[0])), - lastPoint[1] + ((2 / 3) * (control[1] - lastPoint[1])), + lastPoint[0] + (2 / 3) * (control[0] - lastPoint[0]), + lastPoint[1] + (2 / 3) * (control[1] - lastPoint[1]), ]; const cubicControl2: Vector2 = [ - end[0] + ((2 / 3) * (control[0] - end[0])), - end[1] + ((2 / 3) * (control[1] - end[1])), + end[0] + (2 / 3) * (control[0] - end[0]), + end[1] + (2 / 3) * (control[1] - end[1]), ]; const ta: Vector2 = [ @@ -2346,12 +2346,12 @@ export namespace vn { : [lastPoint[0], lastPoint[1]]; const cubicControl1: Vector2 = [ - lastPoint[0] + ((2 / 3) * (control[0] - lastPoint[0])), - lastPoint[1] + ((2 / 3) * (control[1] - lastPoint[1])), + lastPoint[0] + (2 / 3) * (control[0] - lastPoint[0]), + lastPoint[1] + (2 / 3) * (control[1] - lastPoint[1]), ]; const cubicControl2: Vector2 = [ - end[0] + ((2 / 3) * (control[0] - end[0])), - end[1] + ((2 / 3) * (control[1] - end[1])), + end[0] + (2 / 3) * (control[0] - end[0]), + end[1] + (2 / 3) * (control[1] - end[1]), ]; const ta: Vector2 = [ @@ -2375,7 +2375,8 @@ export namespace vn { const { rX, rY, xRot, lArcFlag, sweepFlag, x, y } = command; if (lastPoint) { - const [x1, y1] = lastPoint; + let currentPoint: Vector2 = lastPoint; + const [x1, y1] = currentPoint; // Convert arc to cubic Bézier curves const bezierCurves = cmath.bezier.a2c( @@ -2407,16 +2408,18 @@ export namespace vn { previousIndex, endIndex, [ - controlPoint1[0] - lastPoint[0], - controlPoint1[1] - lastPoint[1], + controlPoint1[0] - currentPoint[0], + controlPoint1[1] - currentPoint[1], ], // ta (relative to start) [controlPoint2[0] - endPoint[0], controlPoint2[1] - endPoint[1]] // tb (relative to end) ); // Update lastPoint and previousIndex for the next curve - lastPoint = endPoint; + currentPoint = endPoint; previousIndex = endIndex; } + + lastPoint = currentPoint; } lastQuadraticControl = null; break; diff --git a/packages/grida-cmath/index.ts b/packages/grida-cmath/index.ts index d280f4b836..bcdfb783f1 100644 --- a/packages/grida-cmath/index.ts +++ b/packages/grida-cmath/index.ts @@ -173,6 +173,17 @@ namespace cmath { export type IntercardinalDirection = "ne" | "se" | "sw" | "nw"; + export type Alignment9 = + | "bottom-center" + | "bottom-left" + | "bottom-right" + | "center" + | "center-left" + | "center-right" + | "top-center" + | "top-left" + | "top-right"; + /** * Quantizes a value to the nearest multiple of a specified step. * @@ -1149,6 +1160,37 @@ namespace cmath { return __inverted_cardinal_directions[direction]; } + /** + * Converts a 9-point alignment to a cardinal direction. + * + * @param alignment - The 9-point alignment to convert. + * @returns The corresponding cardinal direction, or "center" if the alignment is "center". + */ + export function fromAlignment9( + alignment: Alignment9 + ): CardinalDirection | "center" { + switch (alignment) { + case "center": + return "center"; + case "top-left": + return "nw"; + case "top-center": + return "n"; + case "top-right": + return "ne"; + case "center-left": + return "w"; + case "center-right": + return "e"; + case "bottom-left": + return "sw"; + case "bottom-center": + return "s"; + case "bottom-right": + return "se"; + } + } + /** * Converts a strictly orthogonal cardinal direction (n, e, s, w) to the corresponding * rectangle side (top, right, bottom, left). diff --git a/packages/grida-number-input/src/n.test.ts b/packages/grida-number-input/src/n.test.ts index 209bc27af5..7becb968b3 100644 --- a/packages/grida-number-input/src/n.test.ts +++ b/packages/grida-number-input/src/n.test.ts @@ -415,6 +415,15 @@ describe("n.formatValueWithSuffix", () => { n.formatValueWithSuffix(1.234, "%", undefined, 0.1, "number", 1) ).toBe("1.2%"); }); + + test("step precision wins over precision clamp (0.25 with step=0.01)", () => { + // Editor case: + // `ScaleFactorControl` uses step=0.01 and `InputPropertyNumber` passes precision=1. + // The step (2 decimals) must win, so 0.25 displays as 0.25 (not 0.3). + expect( + n.formatValueWithSuffix(0.25, "x", undefined, 0.01, "number", 1) + ).toBe("0.25x"); + }); }); describe("n.parseValueWithScaling", () => { diff --git a/packages/grida-number-input/src/n.ts b/packages/grida-number-input/src/n.ts index 4b36dd81ff..d2509308d6 100644 --- a/packages/grida-number-input/src/n.ts +++ b/packages/grida-number-input/src/n.ts @@ -105,8 +105,13 @@ namespace n { type: NumberType, precision: number = 1 ): string { - // Apply precision first - const precisionValue = applyPrecision(value, precision); + // IMPORTANT: + // `precision` is a display/rounding clamp, but it must NEVER reduce precision + // below what `step` requires. Otherwise values like 0.25 with step=0.01 would + // be rounded to 0.3 before formatting, which is incorrect for step-based UIs. + const stepDecimals = countDecimalPlaces(step); + const effectivePrecision = Math.max(stepDecimals, precision); + const precisionValue = applyPrecision(value, effectivePrecision); switch (type) { case "integer": @@ -115,7 +120,6 @@ namespace n { case "number": // For number type, preserve natural precision when step allows it - const stepDecimals = countDecimalPlaces(step); if (stepDecimals === 0) { // For integer steps, preserve the decimal places if they exist naturally return precisionValue.toString();