Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 24 additions & 25 deletions components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, { x: number; y: number }>
/** visual boxes at gesture start — routed arrows can extend past x/y/w/h */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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<string, { x: number; y: number }> = {}
const sourceBounds: Record<string, Bounds> = {}
Expand All @@ -1844,6 +1841,7 @@ export function Canvas() {
wy,
sourceIds,
sourceGroupId,
sourceGroups: pickGroups(press),
sourcePos,
sourceBounds,
cloneIds: null,
Expand All @@ -1865,6 +1863,7 @@ export function Canvas() {
wy,
base: s.selection,
baseGroupId: s.selectionGroupId,
baseGroups: s.selectionGroups,
softHitId,
}, e)
},
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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([])
Expand Down
2 changes: 1 addition & 1 deletion components/canvas/context-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function ContextRow({

return (
<Panel ref={measure} className="absolute z-20 flex-row items-center gap-3 px-2.5 py-2" style={style}>
{multi && <AlignRow count={selectedNodes.length} />}
<AlignRow />
{multi && (quick.length > 0 || showFill || showHead || showText) && <span className="h-4 w-px bg-border" />}

{quick.map((c) => (
Expand Down
15 changes: 8 additions & 7 deletions components/chrome/align-row.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"

Expand All @@ -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 (
<div className={cn("flex items-center gap-0.5", className)}>
{ALIGN.map(({ edge, label, icon: Icon }) => (
<IconAction key={edge} className="size-ctl-sm" label={label} onClick={() => st().alignSelected(edge)}>
<IconAction key={edge} className="size-ctl-sm" label={toParent ? `${label} in group` : label} onClick={() => st().alignSelected(edge)}>
<Icon className="size-3.5" />
</IconAction>
))}
Expand Down
16 changes: 9 additions & 7 deletions components/chrome/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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") },
Expand Down Expand Up @@ -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<Row[]>(() => {
Expand Down
6 changes: 4 additions & 2 deletions components/chrome/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
Expand Down Expand Up @@ -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") },
Expand Down
8 changes: 5 additions & 3 deletions components/chrome/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

/**
Expand Down Expand Up @@ -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) && (
<StackRow label="Align">
<AlignRow count={selected.length} className="justify-between" />
<SpacingControls nodes={selected} />
<AlignRow className="justify-between" />
{multi && <SpacingControls nodes={selected} />}
</StackRow>
)}

Expand Down
Loading
Loading