diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index bc3e17b..de0cb5b 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -63,7 +63,7 @@ import { exportDoc } from "@/lib/file-io" import { copyAsPngWithNotice } from "@/lib/export-image" import { clampGestureZoom, zoomFloor, MAX_ZOOM, MIN_ZOOM } from "@/lib/canvas/navigate" import { inViewBox, visibleBox } from "@/lib/canvas/cull" -import { groupPickForHit, selectionForPress, stepIntoGroup, type GroupPick } from "@/lib/canvas/groups" +import { groupPickForHit, pickGroups, selectionForPress, stepIntoGroup, type GroupPick } from "@/lib/canvas/groups" import { SpacingOverlay } from "./spacing-overlay" import { ContextRow } from "./context-row" import { CropOverlay, CropStage } from "./crop-overlay" @@ -159,6 +159,7 @@ export type Gesture = sourceIds: string[] /** the nested group those leaves represent, when they are one group */ sourceGroupId: string | null + sourceGroups: string[] /** positions at gesture start, keyed by source id */ sourcePos: Record /** visual boxes at gesture start — routed arrows can extend past x/y/w/h */ @@ -186,6 +187,7 @@ export type Gesture = exceeded: boolean base: string[] baseGroupId: string | null + baseGroups: string[] /** the hollow shape whose middle the press landed in, if any. A press * there reads two ways — a click means "select this shape", a drag * means "marquee, it just happened to start inside" — so the candidate @@ -610,7 +612,7 @@ export function Canvas() { // grazing one member of a group takes the whole group const hits = s.expandSelection(pickInRect(s.nodes, s.order, box, v.zoom)) if (marqueeMode(mods) === "replace") { - s.setSelection(hits) + s.setSelection(hits, null, hits.flatMap((id) => s.nodes[id]?.groupIds?.slice(0, 1) ?? [])) } else { // symmetric difference against the selection we started from, so // sweeping back and forth adds and removes the same things @@ -619,7 +621,7 @@ export function Canvas() { s.setSelection([ ...g.base.filter((id) => !hitSet.has(id)), ...hits.filter((id) => !baseSet.has(id)), - ]) + ], null, [...g.baseGroups, ...hits.flatMap((id) => s.nodes[id]?.groupIds?.slice(0, 1) ?? [])]) } return } @@ -635,14 +637,14 @@ export function Canvas() { // alt engages and disengages drag-a-copy, live, mid-gesture if (mods.alt && !g.cloneIds) { - st().setSelection(g.sourceIds, g.sourceGroupId) + st().setSelection(g.sourceIds, g.sourceGroupId, g.sourceGroups) // put the sources back first so the copies land exactly on them st().updateNodes(Object.fromEntries(g.sourceIds.map((id) => [id, { ...g.sourcePos[id] }]))) const ids = st().cloneSelectionInPlace() if (ids.length === g.sourceIds.length) g.cloneIds = ids } else if (!mods.alt && g.cloneIds) { st().removeNodes(g.cloneIds, { checkpoint: false }) - st().setSelection(g.sourceIds, g.sourceGroupId) + st().setSelection(g.sourceIds, g.sourceGroupId, g.sourceGroups) g.cloneIds = null } @@ -1236,7 +1238,7 @@ export function Canvas() { // a click with no drag inside a bigger selection narrows to what was // clicked — already resolved to a group or a single piece at press time if (g.kind === "move" && !g.exceeded && g.clickSelection) { - s.setSelection(g.clickSelection.ids, g.clickSelection.groupId) + s.setSelection(g.clickSelection.ids, g.clickSelection.groupId, pickGroups(g.clickSelection)) } // the press sat inside a hollow shape and never became a drag: it was a @@ -1247,18 +1249,13 @@ export function Canvas() { const deep = mods.toggle && !!s.nodes[g.softHitId]?.groupIds?.length const picked = deep ? { ids: [g.softHitId], groupId: null } - : groupPickForHit(g.softHitId, s.selection, s.selectionGroupId, s.nodes, s.order) - const hitSet = picked.ids - if (!deep && (mods.shift || mods.toggle)) { - const sel = s.selection - s.setSelection( - hitSet.every((id) => sel.includes(id)) - ? sel.filter((i) => !hitSet.includes(i)) - : [...new Set([...sel, ...hitSet])] - ) - } else { - s.setSelection(hitSet, picked.groupId) - } + : groupPickForHit(g.softHitId, s.selection, s.selectionGroupId, s.nodes, s.order, s.selectionGroups) + const result = selectionForPress( + { ids: s.selection, groupId: s.selectionGroupId, groups: s.selectionGroups }, + picked, mods.shift || (!deep && mods.toggle), deep + ) + const next = result.click ?? result.press + s.setSelection(next.ids, next.groupId, pickGroups(next)) } // an ⌥-drag copy hands ⌘D the distance it travelled, so the next one @@ -1283,7 +1280,7 @@ export function Canvas() { stopAutoPan() if (g.kind === "marquee") { - s.setSelection(g.base, g.baseGroupId) + s.setSelection(g.base, g.baseGroupId, g.baseGroups) } else if ((g.kind === "move" || g.kind === "resize" || g.kind === "rotate" || g.kind === "crop" || g.kind === "endpoint" || g.kind === "route") && g.dirty) { // Escape undoes this drag, not the whole crop — you stay in the mode s.revertToCheckpoint() @@ -1811,17 +1808,17 @@ export function Canvas() { // that isn't a design tool. const deep = mods.toggle && grouped const picked = !hitId - ? { ids: s.selection, groupId: s.selectionGroupId } + ? { ids: s.selection, groupId: s.selectionGroupId, groups: s.selectionGroups } : deep ? { ids: [hitId], groupId: null } - : groupPickForHit(hitId, s.selection, s.selectionGroupId, s.nodes, s.order) + : groupPickForHit(hitId, s.selection, s.selectionGroupId, s.nodes, s.order, s.selectionGroups) const { press, click: clickSelection } = selectionForPress( - { ids: s.selection, groupId: s.selectionGroupId }, picked, + { ids: s.selection, groupId: s.selectionGroupId, groups: s.selectionGroups }, picked, !!hitId && (mods.shift || (!deep && mods.toggle)), deep ) const sel = press.ids const sourceGroupId = press.groupId - s.setSelection(sel, sourceGroupId) + s.setSelection(sel, sourceGroupId, pickGroups(press)) const sourcePos: Record = {} const sourceBounds: Record = {} @@ -1844,6 +1841,7 @@ export function Canvas() { wy, sourceIds, sourceGroupId, + sourceGroups: pickGroups(press), sourcePos, sourceBounds, cloneIds: null, @@ -1865,6 +1863,7 @@ export function Canvas() { wy, base: s.selection, baseGroupId: s.selectionGroupId, + baseGroups: s.selectionGroups, softHitId, }, e) }, @@ -1907,7 +1906,7 @@ export function Canvas() { // group in store is what makes G/A/B reachable as G, then A, then B, // rather than jumping from the outside straight to the leaf. if (n.groupIds?.length && (s.selectionGroupId || s.selection.length !== 1 || s.selection[0] !== hitId)) { - const next = stepIntoGroup(hitId, s.selection, s.selectionGroupId, s.nodes, s.order) + const next = stepIntoGroup(hitId, s.selection, s.selectionGroupId, s.nodes, s.order, s.selectionGroups) s.setSelection(next.ids, next.groupId) return } @@ -1963,7 +1962,7 @@ export function Canvas() { // locked layer is never selected, so the menu targets it directly and // whatever was selected before is left alone if (hitId && !locked && !s.selection.includes(hitId)) { - const picked = groupPickForHit(hitId, s.selection, s.selectionGroupId, s.nodes, s.order) + const picked = groupPickForHit(hitId, s.selection, s.selectionGroupId, s.nodes, s.order, s.selectionGroups) s.setSelection(picked.ids, picked.groupId) } if (!hitId) s.setSelection([]) diff --git a/components/canvas/context-row.tsx b/components/canvas/context-row.tsx index 5e8c99b..9ab0dc5 100644 --- a/components/canvas/context-row.tsx +++ b/components/canvas/context-row.tsx @@ -142,7 +142,7 @@ export function ContextRow({ return ( - {multi && } + {multi && (quick.length > 0 || showFill || showHead || showText) && } {quick.map((c) => ( diff --git a/components/chrome/align-row.tsx b/components/chrome/align-row.tsx index 4d14fbf..3d1a4b2 100644 --- a/components/chrome/align-row.tsx +++ b/components/chrome/align-row.tsx @@ -1,8 +1,7 @@ "use client" // --------------------------------------------------------------------------- -// Align + distribute cluster. Appears once there's more than one thing -// selected — until then there is nothing to align anything to. +// Align selected units to each other, or a single unit to its parent. // --------------------------------------------------------------------------- import { @@ -18,6 +17,7 @@ import { } from "@phosphor-icons/react" import { useSquig } from "@/lib/store" +import { arrangement } from "@/lib/canvas/arrange" import { cn } from "@/lib/utils" import { IconAction } from "@/components/ui/segmented" @@ -32,16 +32,17 @@ const ALIGN: { edge: Edge; label: string; icon: PhosphorIcon }[] = [ { edge: "bottom", label: "Align bottom", icon: AlignBottomSimpleIcon }, ] -export function AlignRow({ count, className }: { count: number; className?: string }) { +export function AlignRow({ className }: { className?: string }) { const st = useSquig.getState - if (count < 2) return null - // evening out gaps needs a gap on both sides of something - const canDistribute = count >= 3 + const canAlign = useSquig((s) => arrangement(s).canAlign) + const canDistribute = useSquig((s) => arrangement(s).canDistribute) + const toParent = useSquig((s) => !!arrangement(s).parent) + if (!canAlign) return null return (
{ALIGN.map(({ edge, label, icon: Icon }) => ( - st().alignSelected(edge)}> + st().alignSelected(edge)}> ))} diff --git a/components/chrome/command-palette.tsx b/components/chrome/command-palette.tsx index 5223038..b010943 100644 --- a/components/chrome/command-palette.tsx +++ b/components/chrome/command-palette.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useSquig } from "@/lib/store" +import { arrangement } from "@/lib/canvas/arrange" import { ALL_DEFS, matches, type ComponentDef } from "@/lib/library/registry" import { searchNodes, type NodeHit } from "@/lib/canvas/find" import { SketchPrims } from "@/components/canvas/sketch" @@ -110,6 +111,7 @@ export function CommandPalette() { } function Palette() { + const canAlign = useSquig((s) => arrangement(s).canAlign) const selection = useSquig((s) => s.selection) const nodes = useSquig((s) => s.nodes) const order = useSquig((s) => s.order) @@ -199,12 +201,12 @@ function Palette() { { id: "flip-v", label: "Flip vertical", hint: kbd("shift+v"), section: "Arrange", keywords: "mirror reverse", icon: FlipVerticalIcon, disabled: !hasSel, run: () => st().flipSelected("y") }, { id: "crop", label: "Crop image", hint: kbd("enter"), section: "Arrange", keywords: "photo picture trim frame mask", icon: CropIcon, disabled: !loneImage, run: () => loneImage && st().setCropping(loneImage) }, { id: "uncrop", label: "Reset crop", section: "Arrange", keywords: "photo picture uncrop restore full", icon: ArrowCounterClockwiseIcon, disabled: !hasCrop, run: () => st().resetCrop() }, - { id: "align-l", label: "Align left", section: "Arrange", icon: CornersOutIcon, disabled: selection.length < 2, run: () => st().alignSelected("left") }, - { id: "align-hc", label: "Align centres horizontally", section: "Arrange", icon: CornersOutIcon, disabled: selection.length < 2, run: () => st().alignSelected("hcenter") }, - { id: "align-r", label: "Align right", section: "Arrange", icon: CornersOutIcon, disabled: selection.length < 2, run: () => st().alignSelected("right") }, - { id: "align-t", label: "Align top", section: "Arrange", icon: CornersOutIcon, disabled: selection.length < 2, run: () => st().alignSelected("top") }, - { id: "align-vc", label: "Align middles vertically", section: "Arrange", icon: CornersOutIcon, disabled: selection.length < 2, run: () => st().alignSelected("vcenter") }, - { id: "align-b", label: "Align bottom", section: "Arrange", icon: CornersOutIcon, disabled: selection.length < 2, run: () => st().alignSelected("bottom") }, + { id: "align-l", label: "Align left", section: "Arrange", icon: CornersOutIcon, disabled: !canAlign, run: () => st().alignSelected("left") }, + { id: "align-hc", label: "Align centres horizontally", section: "Arrange", icon: CornersOutIcon, disabled: !canAlign, run: () => st().alignSelected("hcenter") }, + { id: "align-r", label: "Align right", section: "Arrange", icon: CornersOutIcon, disabled: !canAlign, run: () => st().alignSelected("right") }, + { id: "align-t", label: "Align top", section: "Arrange", icon: CornersOutIcon, disabled: !canAlign, run: () => st().alignSelected("top") }, + { id: "align-vc", label: "Align middles vertically", section: "Arrange", icon: CornersOutIcon, disabled: !canAlign, run: () => st().alignSelected("vcenter") }, + { id: "align-b", label: "Align bottom", section: "Arrange", icon: CornersOutIcon, disabled: !canAlign, run: () => st().alignSelected("bottom") }, { id: "bold", label: "Bold", hint: kbd("mod+b"), section: "Text", icon: TextBIcon, disabled: !hasText, run: () => st().toggleTextStyle("bold") }, { id: "italic", label: "Italic", hint: kbd("mod+i"), section: "Text", icon: TextItalicIcon, disabled: !hasText, run: () => st().toggleTextStyle("italic") }, @@ -241,7 +243,7 @@ function Palette() { run: () => st().openFile(f.id), })), ], - [st, hasSel, hasComponent, hasText, hasGroup, loneImage, hasCrop, lockedCount, canGroup, selection.length, files, docId] + [st, hasSel, hasComponent, hasText, hasGroup, loneImage, hasCrop, lockedCount, canGroup, canAlign, files, docId] ) const rows = useMemo(() => { diff --git a/components/chrome/context-menu.tsx b/components/chrome/context-menu.tsx index 5421644..fc0eca6 100644 --- a/components/chrome/context-menu.tsx +++ b/components/chrome/context-menu.tsx @@ -7,6 +7,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react" import { copySelection, pasteFromSystem } from "@/lib/clipboard" import { useSquig } from "@/lib/store" +import { arrangement } from "@/lib/canvas/arrange" import { screenToWorld } from "@/lib/types" import { hasEditableText } from "@/lib/canvas/edit-target" import { isCropped } from "@/lib/canvas/crop" @@ -70,6 +71,7 @@ export function CanvasContextMenu() { const nodes = useSquig((s) => s.nodes) const order = useSquig((s) => s.order) const selection = useSquig((s) => s.selection) + const canAlign = useSquig((s) => arrangement(s).canAlign) const contextRow = useSquig((s) => s.contextRow) const st = useSquig.getState const ref = useRef(null) @@ -169,10 +171,10 @@ export function CanvasContextMenu() { { separator: true }, { label: "Flip horizontal", hint: kbd("shift+h"), icon: FlipHorizontalIcon, run: () => st().flipSelected("x") }, { label: "Flip vertical", hint: kbd("shift+v"), icon: FlipVerticalIcon, run: () => st().flipSelected("y") }, - ...(selection.length > 1 + ...(canAlign ? ([ { separator: true }, - { label: "Tidy up", icon: SquaresFourIcon, run: () => st().tidySelected() }, + ...(selection.length > 1 ? [{ label: "Tidy up", icon: SquaresFourIcon, run: () => st().tidySelected() }] : []), { label: "Align left", icon: AlignLeftSimpleIcon, run: () => st().alignSelected("left") }, { label: "Align centres", icon: AlignCenterHorizontalSimpleIcon, run: () => st().alignSelected("hcenter") }, { label: "Align right", icon: AlignRightSimpleIcon, run: () => st().alignSelected("right") }, diff --git a/components/chrome/inspector.tsx b/components/chrome/inspector.tsx index 48409d0..ba1f006 100644 --- a/components/chrome/inspector.tsx +++ b/components/chrome/inspector.tsx @@ -17,6 +17,7 @@ import { useEffect } from "react" import { useSquig } from "@/lib/store" +import { arrangement } from "@/lib/canvas/arrange" import type { ArrowNode, ComponentNode, FillTone, ImageNode, InkTone, LineStyle, ShapeNode, SquigNode, StrokeWeight, TextNode } from "@/lib/types" import { normalizeFill, normalizeInk, normalizeStroke } from "@/lib/types" import { isCropped, trueShapePatch } from "@/lib/canvas/crop" @@ -317,6 +318,7 @@ function SelectionEditor({ selected }: { selected: SquigNode[] }) { const st = useSquig.getState const focusedId = useSquig((s) => s.inspectorFocus?.id) const focusedKey = useSquig((s) => s.inspectorFocus?.key) + const canAlign = useSquig((s) => arrangement(s).canAlign) const multi = selected.length > 1 /** @@ -423,10 +425,10 @@ function SelectionEditor({ selected }: { selected: SquigNode[] }) { {/* eight icons don't fit beside a label column, so alignment takes the full width and flipping — which is always available — keeps the row */} - {multi && ( + {(canAlign || multi) && ( - - + + {multi && } )} diff --git a/docs/alignment.md b/docs/alignment.md new file mode 100644 index 0000000..94128c1 --- /dev/null +++ b/docs/alignment.md @@ -0,0 +1,58 @@ +# Alignment and selection hierarchy + +Figma's [alignment documentation](https://help.figma.com/hc/en-us/articles/360039956914-Adjust-alignment-rotation-position-and-dimensions) +describes single-layer alignment to a parent group or frame and multi-layer +alignment relative to the selected layers. Its +[selection documentation](https://help.figma.com/hc/en-us/articles/360040449873-Select-layers-and-objects) +distinguishes selecting a parent from selecting nested objects directly. +Squig follows that distinction for alignment and distribution. + +| Selection | Alignment target | What moves | +| --- | --- | --- | +| Two or more groups | Combined bounds of the selected groups | Each selected group as a unit | +| Individually selected children, including every child | Combined bounds of those children | Each selected child independently | +| A child and an object outside its group | Combined bounds of those objects | Only those selected objects | +| A group and a child from another group | Combined bounds of those two units | The selected group and the selected child | +| One child | Immediate group's bounds before the operation | Only the child | +| One nested group | Immediate parent group's bounds | The subgroup as a unit | +| One top-level object or group | No parent target | Alignment unavailable | + +All six alignment directions use visible bounds, including rotated shapes and +routed connectors. Distribution needs at least three selected units, counts a +selected group once, and preserves each group's internal offsets. A parent and +a subgroup represented in the same selection never move a leaf twice. + +Squig groups have no separate container node or persistent dimensions. Their +bounds come from their contents and can change after a child moves. A rectangle +that merely surrounds another object is not its parent; they must belong to a +group. Locked siblings contribute to a child's parent bounds. Locked layers do +not move, following squig's existing per-layer lock policy. + +## Keeping intent through a flat document + +`selection` remains a list of leaf IDs for rendering and dragging. +`selectionGroups` records explicitly selected group units; unclaimed leaves +remain independent. `selectionGroupId` retains the existing single-group drill-in +context. Group clicks, additive clicks, marquee and Select all carry group intent; +direct API selections of leaf IDs do not infer groups from complete membership. +Undo, redo, duplicate and canceled gestures carry the group units with them. + +The inspector, quick controls, context menu and command palette share the +alignment availability calculation. A single child gets the same six controls, +with accessible labels identifying its group as the target. + +## Related Figma behavior outside this change + +Figma also documents Shift-click alignment of a multi-selection as a temporary +group to its parent frame, with selections across frames using their respective +parents. This change covers ordinary alignment and distribution; it does not add +that modifier behavior, fixed frames, or auto layout. Spacing and tidy-up remain +separate operations with their existing behavior. + +## Verification + +`scripts/test-groups.ts` covers hierarchy, all six parent alignments, mixed +selections, locks, distribution, duplication, and undo. The existing geometry +suite covers visible bounds for rotation and connectors. +`scripts/ui/alignment-browser.mjs` exercises actual group clicks, deep additive +clicks, marquee selection, toolbar visibility, parent alignment and undo. diff --git a/lib/canvas/arrange.ts b/lib/canvas/arrange.ts index c8a4bda..b4525f1 100644 --- a/lib/canvas/arrange.ts +++ b/lib/canvas/arrange.ts @@ -1,14 +1,14 @@ import type { SquigNode } from "../types" -import { unionBounds } from "../selection" +import { unionBounds, type Bounds } from "../selection" import { nodeVisualBounds } from "./line-routing" export type AlignEdge = "left" | "right" | "top" | "bottom" | "hcenter" | "vcenter" /** Arrange the visible boxes, carrying their offset from each stored origin. */ -export function alignNodes(nodes: readonly SquigNode[], edge: AlignEdge): Record> { +export function alignNodes(nodes: readonly SquigNode[], edge: AlignEdge, target?: Bounds): Record> { const boxes = nodes.map(nodeVisualBounds) - const union = unionBounds(boxes) - if (!union || nodes.length < 2) return {} + const union = target ?? unionBounds(boxes) + if (!union || (!target && nodes.length < 2)) return {} return Object.fromEntries(nodes.map((n, i) => { const b = boxes[i] switch (edge) { @@ -38,3 +38,70 @@ export function distributeNodes(nodes: readonly SquigNode[], axis: "x" | "y"): R return [n.id, patch] })) } + +interface ArrangeSelection { + nodes: Record + order: readonly string[] + selection: readonly string[] + selectionGroups: readonly string[] +} + +/** Explicit group picks survive flattening; deep-picked siblings stay separate. */ +export function arrangement(s: ArrangeSelection) { + if (!s.selection.length) return { items: [], parent: null, canAlign: false, canDistribute: false } + const selected = new Set(s.selection) + const all = s.order.map((id) => s.nodes[id]).filter(Boolean) + const requestedGroups = new Set(s.selectionGroups) + const groupMembers = new Map() + for (const n of all) { + if (n.locked) continue + for (const g of n.groupIds ?? []) { + if (!requestedGroups.has(g)) continue + const members = groupMembers.get(g) ?? [] + members.push(n) + groupMembers.set(g, members) + } + } + const groups = new Set([...groupMembers].filter(([, members]) => + members.every((n) => selected.has(n.id)) + ).map(([g]) => g)) + const units = new Map() + for (const n of all) { + if (!selected.has(n.id) || n.locked) continue + const path = n.groupIds ?? [] + const at = path.findIndex((g) => groups.has(g)) + const key = at < 0 ? `node:${n.id}` : `group:${path[at]}` + const unit = units.get(key) ?? { members: [], parent: at < 0 ? path.at(-1) : path[at - 1] } + unit.members.push(n) + units.set(key, unit) + } + const items = [...units.entries()].map(([id, unit]) => { + const bounds = unionBounds(unit.members.map(nodeVisualBounds))! + const box: SquigNode = { id, type: "shape", shape: "rect", seed: 1, fill: "none", ...bounds } + return { ...unit, box } + }) + const parent = items.length === 1 && items[0].parent + ? unionBounds(all.filter((n) => n.groupIds?.includes(items[0].parent!)).map(nodeVisualBounds)) + : null + return { items, parent, canAlign: items.length > 1 || !!parent, canDistribute: items.length >= 3 } +} + +export function arrangeSelection(s: ArrangeSelection, action: AlignEdge | "distribute-x" | "distribute-y") { + const { items, parent } = arrangement(s) + const boxes = items.map((item) => item.box) + const patches = action.startsWith("distribute-") + ? distributeNodes(boxes, action === "distribute-x" ? "x" : "y") + : alignNodes(boxes, action as AlignEdge, parent ?? undefined) + const out: Record> = {} + for (const { box, members } of items) { + const patch = patches[box.id] + if (!patch) continue + for (const n of members) { + out[n.id] = { + ...(patch.x !== undefined ? { x: n.x + patch.x - box.x } : {}), + ...(patch.y !== undefined ? { y: n.y + patch.y - box.y } : {}), + } + } + } + return out +} diff --git a/lib/canvas/groups.ts b/lib/canvas/groups.ts index b9d51c9..f07b973 100644 --- a/lib/canvas/groups.ts +++ b/lib/canvas/groups.ts @@ -12,6 +12,8 @@ import type { SquigNode } from "../types" export interface GroupPick { ids: string[] groupId: string | null + /** Selected group units in a mixed pick; omitted for a single group/leaf. */ + groups?: string[] } /** Defer destructive click changes until release so a press can still move. */ @@ -26,15 +28,17 @@ export function selectionForPress( if (contained) { return { press: current, - click: { ids: current.ids.filter((id) => !picked.ids.includes(id)), groupId: null }, + click: { ids: current.ids.filter((id) => !picked.ids.includes(id)), groupId: null, groups: pickGroups(current) }, } } - return { press: { ids: [...new Set([...current.ids, ...picked.ids])], groupId: null }, click: null } + return { press: { ids: [...new Set([...current.ids, ...picked.ids])], groupId: null, groups: [...new Set([...pickGroups(current), ...pickGroups(picked)])] }, click: null } } if (deep || !contained || current.ids.length === picked.ids.length) return { press: picked, click: null } return { press: current, click: picked } } +export const pickGroups = (pick: GroupPick): string[] => pick.groups ?? (pick.groupId ? [pick.groupId] : []) + const pathOf = (n: SquigNode | undefined): readonly string[] => n?.groupIds ?? [] /** Canonical path spelling for documents and clipboard payloads. */ @@ -80,12 +84,19 @@ export function groupPickForHit( selection: readonly string[], selectedGroupId: string | null, nodes: Record, - order: readonly string[] + order: readonly string[], + selectedGroups: readonly string[] = [] ): GroupPick { const n = nodes[hitId] if (!n || n.locked) return { ids: [], groupId: null } const path = pathOf(n) + const held = path.find((g) => selectedGroups.includes(g)) + if (held) { + const ids = groupMembers(held, nodes, order) + if (ids.every((id) => selection.includes(id))) return { ids, groupId: held } + } + if (selectedGroupId && path.includes(selectedGroupId)) { const ids = groupMembers(selectedGroupId, nodes, order) if (sameSet(ids, selection)) return { ids, groupId: selectedGroupId } @@ -104,14 +115,16 @@ export function stepIntoGroup( selection: readonly string[], selectedGroupId: string | null, nodes: Record, - order: readonly string[] + order: readonly string[], + selectedGroups: readonly string[] = [] ): GroupPick { const n = nodes[hitId] if (!n || n.locked) return { ids: [], groupId: null } const path = pathOf(n) if (!path.length) return { ids: [hitId], groupId: null } - let at = selectedGroupId ? path.indexOf(selectedGroupId) : -1 + const held = selectedGroupId ?? path.find((g) => selectedGroups.includes(g)) + let at = held ? path.indexOf(held) : -1 if (at < 0 && sameSet(groupMembers(path[0], nodes, order), selection)) at = 0 // A leaf reached with Cmd-click is already all the way inside. if (at < 0 && selection.length === 1 && selection[0] === hitId) { diff --git a/lib/store.ts b/lib/store.ts index 899bf5b..d915853 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -26,7 +26,7 @@ import { } from "./canvas/groups" import { normalizeRotation } from "./canvas/rotation" import { spaceNodes, tidyNodes, type SpacingOptions } from "./canvas/spacing" -import { alignNodes, distributeNodes } from "./canvas/arrange" +import { arrangeSelection } from "./canvas/arrange" import { breakApart } from "./library/break-apart" import { applyLook, @@ -62,8 +62,10 @@ interface DocSnapshot { selection: string[] /** the group that selection represents, when it is one nested group */ selectionGroupId: string | null + selectionGroups: string[] /** selection the edit ended with — what redo puts you back to */ selAfter?: string[] + selGroupsAfter?: string[] selGroupAfter?: string | null /** the redo stack this checkpoint displaced, so a cancel can hand it back */ displacedFuture?: DocSnapshot[] @@ -98,6 +100,8 @@ interface SquigState { selection: string[] /** the exact group represented by `selection`; needed once groups nest */ selectionGroupId: string | null + /** Explicit group picks; remaining selected leaves are independent units. */ + selectionGroups: string[] viewport: Viewport tool: Tool shapeKind: ShapeKind @@ -178,7 +182,7 @@ interface SquigState { setPaper: (s: PaperShade) => void setGrid: (on: boolean) => void setViewport: (v: Viewport) => void - setSelection: (ids: string[], groupId?: string | null) => void + setSelection: (ids: string[], groupId?: string | null, groups?: string[]) => void setCommandOpen: (open: boolean) => void setContextMenu: (m: ContextMenuState | null) => void setRenamingFile: (on: boolean) => void @@ -321,12 +325,13 @@ const SAVE_DEBOUNCE_MS = 400 * quietly, by rewriting history under itself. scripts/test-history.ts is there * to say so out loud. */ -function snapshot(s: Pick): DocSnapshot { +function snapshot(s: Pick): DocSnapshot { return { nodes: s.nodes, order: s.order, selection: [...s.selection], selectionGroupId: s.selectionGroupId, + selectionGroups: s.selectionGroups, } } @@ -336,11 +341,12 @@ function snapshot(s: Pick SquigState) { // whatever of the selection survived the other tab's edit, in document order selection: selectable(clean.order.filter((id) => held.has(id)), clean.nodes), selectionGroupId: null, + selectionGroups: [], croppingId: s.croppingId && clean.nodes[s.croppingId] ? s.croppingId : null, past: [], future: [], @@ -770,6 +777,7 @@ export const useSquig = create((set, get) => ({ order: [], selection: [], selectionGroupId: null, + selectionGroups: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select", shapeKind: "rect", @@ -823,7 +831,7 @@ export const useSquig = create((set, get) => ({ // this one writes the selection itself, so it also keeps the locked layers // out of it — a held-down picture has no crop to step into if (n?.type !== "image" || n.locked) return - set({ croppingId: id, editingId: null, selection: [id], selectionGroupId: null }) + set({ croppingId: id, editingId: null, selection: [id], selectionGroupId: null, selectionGroups: [] }) }, resetCrop: (ids) => { @@ -883,7 +891,7 @@ export const useSquig = create((set, get) => ({ // a selection is a set, so store it in one canonical order: document order. // everything downstream (clipboard, duplicate, align, the type summary) then // behaves the same whether it was built by marquee, shift-click or ⌘A - setSelection: (ids, groupId = null) => { + setSelection: (ids, groupId = null, groups = groupId ? [groupId] : []) => { set((s) => { const want = new Set(selectable(ids, s.nodes)) const next = s.order.filter((id) => want.has(id)) @@ -894,9 +902,14 @@ export const useSquig = create((set, get) => ({ members.every((id) => want.has(id)) ? groupId : null + const nextGroups = [...new Set(groups)].filter((g) => { + const members = groupMembers(g, s.nodes, s.order) + return members.length > 0 && members.every((id) => want.has(id)) + }) // bail when nothing actually changed, so a marquee crossing nothing new // doesn't re-render the canvas on every pointermove if ( + nextGroups.length === s.selectionGroups.length && nextGroups.every((g, i) => g === s.selectionGroups[i]) && nextGroup === s.selectionGroupId && next.length === s.selection.length && next.every((id, i) => s.selection[i] === id) @@ -906,7 +919,7 @@ export const useSquig = create((set, get) => ({ // how you leave, and leaving keeps the crop you'd dragged so far. This // path doesn't have to say so; see the note at the foot of this file, // which says it for every path. - return { selection: next, selectionGroupId: nextGroup } + return { selection: next, selectionGroupId: nextGroup, selectionGroups: nextGroups } }) }, setCommandOpen: (open) => @@ -956,6 +969,7 @@ export const useSquig = create((set, get) => ({ order: prev.order, selection: selectable(prev.selection, prev.nodes), selectionGroupId: prev.selectionGroupId, + selectionGroups: prev.selectionGroups, past: past.slice(0, -1), future: prev.displacedFuture ?? get().future, }) @@ -1009,7 +1023,7 @@ export const useSquig = create((set, get) => ({ set({ past, future }) return false } - stampSelAfter(after.past, after.selection, after.selectionGroupId) + stampSelAfter(after.past, after.selection, after.selectionGroupId, after.selectionGroups) scheduleSave(get) return true }, @@ -1020,13 +1034,15 @@ export const useSquig = create((set, get) => ({ if (opts.checkpoint !== false) get().checkpoint() set((s) => { const selection = opts.select !== false ? [id] : s.selection + const selectionGroups = opts.select !== false ? [] : s.selectionGroups const selectionGroupId = opts.select !== false ? null : s.selectionGroupId - stampSelAfter(s.past, selection, selectionGroupId) + stampSelAfter(s.past, selection, selectionGroupId, selectionGroups) return { nodes: settleBinds({ ...s.nodes, [id]: { ...node, id, seed } as SquigNode }), order: [...s.order, id], selection, selectionGroupId, + selectionGroups, } }) scheduleSave(get) @@ -1043,9 +1059,10 @@ export const useSquig = create((set, get) => ({ ids.push(n.id) } const selection = opts.select !== false ? ids : s.selection + const selectionGroups = opts.select !== false ? [] : s.selectionGroups const selectionGroupId = opts.select !== false ? null : s.selectionGroupId - stampSelAfter(s.past, selection, selectionGroupId) - return { nodes: settleBinds(map), order: [...s.order, ...ids], selection, selectionGroupId } + stampSelAfter(s.past, selection, selectionGroupId, selectionGroups) + return { nodes: settleBinds(map), order: [...s.order, ...ids], selection, selectionGroupId, selectionGroups } }) scheduleSave(get) }, @@ -1055,7 +1072,7 @@ export const useSquig = create((set, get) => ({ set((s) => { const cur = s.nodes[id] if (!cur) return s - stampSelAfter(s.past, s.selection, s.selectionGroupId) + stampSelAfter(s.past, s.selection, s.selectionGroupId, s.selectionGroups) return { nodes: settleBinds({ ...s.nodes, [id]: { ...cur, ...patch } as SquigNode }) } }) scheduleSave(get) @@ -1069,7 +1086,7 @@ export const useSquig = create((set, get) => ({ const cur = map[id] if (cur) map[id] = { ...cur, ...patch } as SquigNode } - stampSelAfter(s.past, s.selection, s.selectionGroupId) + stampSelAfter(s.past, s.selection, s.selectionGroupId, s.selectionGroups) // every bound arrow catches up here, which is what lets a box drag, a // nudge, an align and a resize all pull their connectors along without // any of them having to know that bindings exist @@ -1087,6 +1104,10 @@ export const useSquig = create((set, get) => ({ const nodes = pruneDegenerateGroups(map) const order = s.order.filter((i) => !ids.includes(i)) const selection = s.selection.filter((i) => !ids.includes(i)) + const selectionGroups = s.selectionGroups.filter((g) => { + const members = groupMembers(g, nodes, order) + return members.length > 0 && members.every((id) => selection.includes(id)) + }) const groupMembersLeft = s.selectionGroupId ? groupMembers(s.selectionGroupId, nodes, order) : [] @@ -1096,7 +1117,7 @@ export const useSquig = create((set, get) => ({ groupMembersLeft.every((id) => selection.includes(id)) ? s.selectionGroupId : null - stampSelAfter(s.past, selection, selectionGroupId) + stampSelAfter(s.past, selection, selectionGroupId, selectionGroups) return { // an arrow aimed at something that just went away lets go of it and // stays exactly where it was last drawn — see settleBinds @@ -1104,6 +1125,7 @@ export const useSquig = create((set, get) => ({ order, selection, selectionGroupId, + selectionGroups, // editing a node that just went away would wedge the canvas editingId: s.editingId && ids.includes(s.editingId) ? null : s.editingId, croppingId: s.croppingId && ids.includes(s.croppingId) ? null : s.croppingId, @@ -1170,7 +1192,7 @@ export const useSquig = create((set, get) => ({ get().updateNodes(Object.fromEntries(ids.map((id) => [id, { locked: true } as Partial]))) // letting go is part of the edit, and edit() stamps the checkpoint with // the selection the whole thing ended on — so redo lands here too - set({ selection: [], selectionGroupId: null, croppingId: null }) + set({ selection: [], selectionGroupId: null, selectionGroups: [], croppingId: null }) }) get().setNotice( ids.length > 1 ? "locked — right-click one to let that one go" : "locked — right-click it to let it go" @@ -1190,7 +1212,7 @@ export const useSquig = create((set, get) => ({ unlockAll: () => get().unlockNodes(lockedIds(get().nodes, get().order)), duplicateSelected: (offset = 16) => { - const { selection, selectionGroupId, nodes, order, dupTrail } = get() + const { selection, selectionGroupId, selectionGroups, nodes, order, dupTrail } = get() const src = order.filter((id) => selection.includes(id)).map((id) => nodes[id]) if (!src.length) return [] @@ -1204,6 +1226,7 @@ export const useSquig = create((set, get) => ({ order.map((id) => nodes[id]).filter(Boolean) ) const cloneGroupId = selectionGroupId ? groupMap.get(selectionGroupId) ?? null : null + const cloneGroups = selectionGroups.flatMap((g) => groupMap.has(g) ? [groupMap.get(g)!] : []) // through edit(), so the checkpoint remembers that the copies are what this // ended selected — a redo that handed them back unselected would leave the // next ⌘D measuring nothing, and stepping 16px diagonally instead of @@ -1219,6 +1242,7 @@ export const useSquig = create((set, get) => ({ order: orderWithClones(s.order, src, clones), selection: clones.map((c) => c.id), selectionGroupId: cloneGroupId, + selectionGroups: cloneGroups, // where these copies came from, so the next ⌘D can measure the same way dupTrail: { ids: clones.map((c) => c.id), @@ -1266,6 +1290,7 @@ export const useSquig = create((set, get) => ({ const forward: DocSnapshot = { ...snapshot(s), selAfter: prev.selAfter ?? s.selection, + selGroupsAfter: prev.selGroupsAfter ?? s.selectionGroups, selGroupAfter: prev.selGroupAfter !== undefined ? prev.selGroupAfter : s.selectionGroupId, } return { @@ -1277,6 +1302,7 @@ export const useSquig = create((set, get) => ({ // the selection was then, it can't hand back something locked now selection: selectable(prev.selection, prev.nodes), selectionGroupId: prev.selectionGroupId, + selectionGroups: prev.selectionGroups, editingId: null, // unlike the text editor, the crop overlay is a pure read of the node, // so ⌘Z can walk back through a crop without leaving the mode @@ -1295,11 +1321,12 @@ export const useSquig = create((set, get) => ({ const restoredGroup = next.selGroupAfter !== undefined ? next.selGroupAfter : next.selectionGroupId return { future: s.future.slice(0, -1), - past: [...s.past, { ...snapshot(s), selAfter: restored, selGroupAfter: restoredGroup }], + past: [...s.past, { ...snapshot(s), selAfter: restored, selGroupAfter: restoredGroup, selGroupsAfter: next.selGroupsAfter ?? next.selectionGroups }], nodes: next.nodes, order: next.order, selection: selectable(next.order.filter((id) => restored.includes(id)), next.nodes), selectionGroupId: restoredGroup, + selectionGroups: next.selGroupsAfter ?? next.selectionGroups, editingId: null, croppingId: s.croppingId && next.nodes[s.croppingId] ? s.croppingId : null, } @@ -1323,6 +1350,7 @@ export const useSquig = create((set, get) => ({ order: clean.order, selection: [], selectionGroupId: null, + selectionGroups: [], files, contextRow: prefs.contextRow, bigNudge: prefs.bigNudge, @@ -1338,7 +1366,7 @@ export const useSquig = create((set, get) => ({ // clearing a canvas that is already clear is the emptiest edit there is clearCanvas: () => { - get().edit(() => set({ nodes: {}, order: [], selection: [], selectionGroupId: null, editingId: null, croppingId: null })) + get().edit(() => set({ nodes: {}, order: [], selection: [], selectionGroupId: null, selectionGroups: [], editingId: null, croppingId: null })) }, // -- groups --------------------------------------------------------------- @@ -1388,6 +1416,7 @@ export const useSquig = create((set, get) => ({ order: [...before, ...ids, ...after], selection: ids, selectionGroupId: gid, + selectionGroups: [gid], } })) }, @@ -1424,7 +1453,7 @@ export const useSquig = create((set, get) => ({ // what's being dissolved, and leaving one node stamped with a group that // no longer exists would be worse — but it doesn't come out selected const next = pruneDegenerateGroups(map) - return { nodes: next, selection: selectable(freed, next), selectionGroupId: null } + return { nodes: next, selection: selectable(freed, next), selectionGroupId: null, selectionGroups: [] } })) }, @@ -1462,6 +1491,7 @@ export const useSquig = create((set, get) => ({ order: ord, selection: picked.length ? picked : s.selection, selectionGroupId: picked.length ? null : s.selectionGroupId, + selectionGroups: picked.length ? [] : s.selectionGroups, } })) }, @@ -1577,6 +1607,7 @@ export const useSquig = create((set, get) => ({ order, selection: ids, selectionGroupId, + selectionGroups: selectionGroupId ? [selectionGroupId] : [], } }) ) @@ -1655,7 +1686,7 @@ export const useSquig = create((set, get) => ({ }, cloneSelectionInPlace: () => { - const { selection, selectionGroupId, nodes, order } = get() + const { selection, selectionGroupId, selectionGroups, nodes, order } = get() const src = order.filter((id) => selection.includes(id)).map((id) => nodes[id]).filter(Boolean) const { clones, groupMap } = cloneNodes( src, @@ -1666,8 +1697,9 @@ export const useSquig = create((set, get) => ({ if (!clones.length) return [] const ids = clones.map((c) => c.id) const cloneGroupId = selectionGroupId ? groupMap.get(selectionGroupId) ?? null : null + const cloneGroups = selectionGroups.flatMap((g) => groupMap.has(g) ? [groupMap.get(g)!] : []) set((s) => { - stampSelAfter(s.past, ids, cloneGroupId) + stampSelAfter(s.past, ids, cloneGroupId, cloneGroups) return { nodes: settleBinds(pruneDegenerateGroups({ ...s.nodes, @@ -1676,6 +1708,7 @@ export const useSquig = create((set, get) => ({ order: orderWithClones(s.order, src, clones), selection: ids, selectionGroupId: cloneGroupId, + selectionGroups: cloneGroups, } }) scheduleSave(get) @@ -1692,10 +1725,7 @@ export const useSquig = create((set, get) => ({ }, distributeSelected: (axis) => { - const { selection, nodes, order } = get() - const ids = new Set(selection) - const sel = order.filter((id) => ids.has(id)).map((id) => nodes[id]).filter(Boolean) - const patches = distributeNodes(sel, axis === "h" ? "x" : "y") + const patches = arrangeSelection(get(), axis === "h" ? "distribute-x" : "distribute-y") // evening out gaps that are already even is the align case again get().edit(() => get().updateNodes(patches)) }, @@ -1703,13 +1733,17 @@ export const useSquig = create((set, get) => ({ // every one of these builds a selection straight out of `order` rather than // going through setSelection, so each has to remember the locked layers on // its own. "All" means all the ones you can have. - selectAll: () => set((s) => ({ selection: selectable(s.order, s.nodes), selectionGroupId: null })), - selectNone: () => set({ selection: [], selectionGroupId: null, croppingId: null }), + selectAll: () => { + const s = get() + s.setSelection(s.order, null, s.order.flatMap((id) => s.nodes[id]?.groupIds?.slice(0, 1) ?? [])) + }, + selectNone: () => set({ selection: [], selectionGroupId: null, selectionGroups: [], croppingId: null }), invertSelection: () => { set((s) => ({ selection: selectable(s.order.filter((id) => !s.selection.includes(id)), s.nodes), selectionGroupId: null, + selectionGroups: [], })) }, @@ -1734,6 +1768,7 @@ export const useSquig = create((set, get) => ({ nodes ), selectionGroupId: null, + selectionGroups: [], }) }, @@ -1746,7 +1781,7 @@ export const useSquig = create((set, get) => ({ const ring = selectable(order, nodes) if (!ring.length) return if (!selection.length) { - set({ selection: [dir === 1 ? ring[0] : ring[ring.length - 1]], selectionGroupId: null }) + set({ selection: [dir === 1 ? ring[0] : ring[ring.length - 1]], selectionGroupId: null, selectionGroups: [] }) get().revealSelection() return } @@ -1754,7 +1789,7 @@ export const useSquig = create((set, get) => ({ const anchor = selection[selection.length - 1] const i = ring.indexOf(anchor) const next = ring[(((i === -1 ? 0 : i) + dir) % ring.length + ring.length) % ring.length] - set({ selection: [next], selectionGroupId: null }) + set({ selection: [next], selectionGroupId: null, selectionGroups: [] }) // stepping onto something you can't see is the same as not stepping at // all. revealSelection holds still when the layer was already on screen, // so a walk through a screenful of nodes doesn't lurch on every press @@ -1762,11 +1797,7 @@ export const useSquig = create((set, get) => ({ }, alignSelected: (edge) => { - const { selection, nodes } = get() - if (selection.length < 2) return - const sel = selection.map((id) => nodes[id]).filter(Boolean) - if (sel.length < 2) return - const patches = alignNodes(sel, edge) + const patches = arrangeSelection(get(), edge) // a selection already flush against that edge writes its own coordinates // back onto itself, which edit() reads as the nothing it is get().edit(() => get().updateNodes(patches)) @@ -1782,6 +1813,7 @@ export const useSquig = create((set, get) => ({ order: [], selection: [], selectionGroupId: null, + selectionGroups: [], croppingId: null, viewport: { x: 0, y: 0, zoom: 1 }, renamingFile: false, @@ -1812,6 +1844,7 @@ export const useSquig = create((set, get) => ({ order: clean.order, selection: [], selectionGroupId: null, + selectionGroups: [], croppingId: null, viewport: { x: 0, y: 0, zoom: 1 }, renamingFile: false, @@ -1844,6 +1877,7 @@ export const useSquig = create((set, get) => ({ order: [], selection: [], selectionGroupId: null, + selectionGroups: [], croppingId: null, past: [], future: [], @@ -1877,6 +1911,7 @@ export const useSquig = create((set, get) => ({ order: doc.order, selection: [], selectionGroupId: null, + selectionGroups: [], croppingId: null, renamingFile: false, linkOpen: false, diff --git a/scripts/test-groups.ts b/scripts/test-groups.ts index 738333a..c9b687a 100644 --- a/scripts/test-groups.ts +++ b/scripts/test-groups.ts @@ -31,6 +31,7 @@ function reset() { order: [], selection: [], selectionGroupId: null, + selectionGroups: [], clipboard: [], past: [], future: [], @@ -217,4 +218,105 @@ for (const [label, duplicate] of [ check("Shift plus deep-selection adds the leaf", addDeep.press.ids.join() === "a,b,c") } + +// Alignment uses selected hierarchy, not the flattened list used for dragging. +{ + reset() + const a = rect(0, 0), b = rect(60, 40), c = rect(300, 100), d = rect(380, 160) + const g = group([a, b]), h = group([c, d]) + const { press } = selectionForPress( + { ids: [a, b], groupId: g }, { ids: [c, d], groupId: h }, true, false + ) + s().setSelection(press.ids, press.groupId, press.groups) + const before = JSON.stringify(s().nodes) + const depth = s().past.length + s().alignSelected("hcenter") + check("two group picks align their bounding boxes", s().nodes[a].x === 160 && s().nodes[c].x === 150) + check("alignment preserves each group's internal offsets", s().nodes[b].x - s().nodes[a].x === 60 && s().nodes[d].x - s().nodes[c].x === 80) + check("alignment leaves the other axis alone", s().nodes[b].y === 40 && s().nodes[d].y === 160) + check("group alignment is one undo step", s().past.length === depth + 1) + const after = JSON.stringify(s().nodes) + s().undo() + check("undo restores positions and both selected groups", JSON.stringify(s().nodes) === before && s().selectionGroups.join() === [g, h].join()) + s().redo() + check("redo restores positions and both selected groups", JSON.stringify(s().nodes) === after && s().selectionGroups.join() === [g, h].join()) + const alignedDepth = s().past.length + s().alignSelected("hcenter") + check("repeated alignment spends no undo step", s().past.length === alignedDepth) + s().undo() + s().setSelection([a, b]) + s().alignSelected("left") + check("explicitly selecting all children still aligns the children", s().nodes[a].x === 0 && s().nodes[b].x === 0) + check("deep alignment leaves other groups alone", s().nodes[c].x === 300 && s().nodes[d].x === 380) +} + +{ + reset() + const a = rect(0), b = rect(80), c = rect(200), outside = rect(400) + const inner = group([a, b]), outer = group([a, b, c]) + const picked = groupPickForHit(a, [a, b, outside], null, s().nodes, s().order, [inner]) + check("clicking a subgroup in a mixed selection keeps its depth", picked.groupId === inner) + const entered = stepIntoGroup(a, [a, b, outside], null, s().nodes, s().order, [inner]) + check("double-clicking a subgroup in a mixed selection enters that subgroup", entered.ids.join() === a && entered.groupId === null) + s().setSelection([a]) + s().alignSelected("right") + check("a single child aligns inside its immediate parent", s().nodes[a].x === 80 && s().nodes[b].x === 80 && s().nodes[c].x === 200) + s().undo() + s().setSelection([a, b], inner) + s().alignSelected("right") + check("a nested group aligns to its parent as a unit", s().nodes[a].x === 120 && s().nodes[b].x === 200) + s().undo() + s().setSelection([a, outside]) + s().alignSelected("hcenter") + check("a deep child and an outside node align to each other", s().nodes[a].x === 200 && s().nodes[outside].x === 200) + check("mixed alignment leaves unselected siblings alone", s().nodes[b].x === 80 && s().nodes[c].x === 200) + s().undo() + s().setSelection([a, b, c], outer) + const before = JSON.stringify(s().nodes), depth = s().past.length + s().alignSelected("left") + check("a lone top-level group has no alignment target", JSON.stringify(s().nodes) === before && s().past.length === depth) + s().setSelection([a, b, c, outside], null, [outer, inner]) + s().alignSelected("right") + check("overlapping parent and subgroup picks move each leaf once", s().nodes[a].x === 200 && s().nodes[b].x === 280 && s().nodes[c].x === 400) +} + +for (const [edge, x, y] of [ + ["left", 0, 60], ["hcenter", 80, 60], ["right", 160, 60], + ["top", 60, 0], ["vcenter", 60, 85], ["bottom", 60, 170], +] as const) { + reset() + const background = rect(0), child = rect(60, 60) + s().updateNode(background, { w: 200, h: 200, locked: true }) + s().updateNodes({ [background]: { groupIds: ["parent"] }, [child]: { groupIds: ["parent"] } }) + s().setSelection([child]) + s().alignSelected(edge) + check(`single child ${edge} uses parent bounds, including locked members`, s().nodes[child].x === x && s().nodes[child].y === y) + check(`single child ${edge} does not move its container`, s().nodes[background].x === 0 && s().nodes[background].y === 0) +} + +{ + reset() + const ids = [0, 60, 200, 260, 600, 660].map((x) => rect(x)) + const groups = [group(ids.slice(0, 2)), group(ids.slice(2, 4)), group(ids.slice(4, 6))] + s().selectAll() + s().distributeSelected("h") + check("select all distributes three whole groups", s().nodes[ids[2]].x === 300 && s().nodes[ids[3]].x === 360) + check("group distribution keeps the outer units fixed", s().nodes[ids[0]].x === 0 && s().nodes[ids[5]].x === 660) + s().setSelection(ids.slice(0, 4), null, groups.slice(0, 2)) + const before = JSON.stringify(s().nodes) + s().distributeSelected("h") + check("two selected groups cannot distribute their four children", JSON.stringify(s().nodes) === before) + const clones = s().duplicateSelected() + check("duplicating a multi-group selection preserves its units", s().selectionGroups.length === 2 && s().selectionGroups.every((g) => !groups.includes(g))) + s().checkpoint() + s().cloneSelectionInPlace() + const draggedGroups = [...s().selectionGroups] + s().undo() + s().redo() + check("redo of an in-place drag copy restores the copied group units", s().selectionGroups.join() === draggedGroups.join()) + s().undo() + s().alignSelected("left") + check("duplicated groups align without collapsing children", s().nodes[clones[1]].x - s().nodes[clones[0]].x === 60) +} + report("group checks passed") diff --git a/scripts/ui/alignment-browser.mjs b/scripts/ui/alignment-browser.mjs new file mode 100644 index 0000000..b9e1d77 --- /dev/null +++ b/scripts/ui/alignment-browser.mjs @@ -0,0 +1,65 @@ +// pnpm dev, then SQUIG_TEST_URL=http://localhost:3000 node scripts/ui/alignment-browser.mjs +import { chromium, expect } from "@playwright/test" +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) +const errors = [] +page.on("pageerror", (error) => errors.push(error.message)) +try { + await page.goto(process.env.SQUIG_TEST_URL ?? "http://localhost:3000") + await page.waitForFunction(() => !!window.squig) + await page.evaluate(() => { + window.squig.add([ + { id: "a", x: 300, y: 250, w: 160, h: 160, groupIds: ["g"] }, + { id: "b", x: 330, y: 280, w: 40, h: 30, groupIds: ["g"] }, + { id: "c", x: 600, y: 450, w: 200, h: 180, groupIds: ["h"] }, + { id: "d", x: 650, y: 490, w: 40, h: 30, groupIds: ["h"] }, + ].map((n, i) => ({ ...n, type: "shape", shape: "rect", fill: "light", seed: i + 1 }))) + window.squig.select([]) + }) + + const positions = () => page.evaluate(() => Object.fromEntries(Object.entries(window.squig.doc().nodes).map(([id, n]) => [id, [n.x, n.y]]))) + await page.mouse.click(420, 370) + await expect(page.getByRole("button", { name: "Align horizontal centres", exact: true })).toHaveCount(0) + await page.keyboard.down("Shift") + await page.mouse.click(760, 590) + await page.keyboard.up("Shift") + await page.getByRole("button", { name: "Align horizontal centres", exact: true }).first().click() + expect(await positions()).toEqual({ a: [470, 250], b: [500, 280], c: [450, 450], d: [500, 490] }) + await expect(page.getByRole("button", { name: "Distribute needs 3 or more" }).first()).toBeDisabled() + await page.screenshot({ path: "/tmp/squig-align-groups.png" }) + await page.keyboard.press("Meta+z") + expect(await positions()).toEqual({ a: [300, 250], b: [330, 280], c: [600, 450], d: [650, 490] }) + await page.keyboard.down("Meta") + await page.mouse.click(350, 295) + await page.keyboard.up("Meta") + await page.getByRole("button", { name: "Align horizontal centres in group", exact: true }).first().click() + expect((await positions()).b).toEqual([360, 280]) + await page.screenshot({ path: "/tmp/squig-align-child.png" }) + await page.keyboard.press("Meta+z") + // Deep additive selection across groups aligns the leaves, leaving their backgrounds still. + await page.keyboard.down("Meta") + await page.keyboard.down("Shift") + await page.mouse.click(670, 505) + await page.keyboard.up("Shift") + await page.keyboard.up("Meta") + await page.getByRole("button", { name: "Align horizontal centres", exact: true }).first().click() + expect(await positions()).toEqual({ a: [300, 250], b: [490, 280], c: [600, 450], d: [490, 490] }) + await page.keyboard.press("Meta+z") + // Selecting all siblings explicitly must remain different from selecting their group. + await page.evaluate(() => window.squig.select(["a", "b"])) + await page.getByRole("button", { name: "Align left", exact: true }).first().click() + expect((await positions()).b).toEqual([300, 280]) + await page.keyboard.press("Meta+z") + await page.evaluate(() => window.squig.select([])) + await page.mouse.move(260, 200) + await page.mouse.down() + await page.mouse.move(850, 680, { steps: 8 }) + await page.mouse.up() + await page.getByRole("button", { name: "Align right", exact: true }).first().click() + expect(await positions()).toEqual({ a: [640, 250], b: [670, 280], c: [600, 450], d: [650, 490] }) + console.log("Group clicks, deep additive selection, child controls, sibling selection, marquee and undo passed") + +} finally { + await browser.close() +} +expect(errors).toEqual([])