update pen tool space drag spec

Adds a new scenario to the pen tool specifications defining the behavior for relocating the active vertex with spacebar during a curve drag, including asserting that the cursor remains a crosshair instead of switching to the hand tool.

Made-with: Cursor
This commit is contained in:
Ilya Nikitin 2026-03-27 21:27:45 +05:00
parent 455f04d6f1
commit 0ff5a87ccb
24 changed files with 223 additions and 2926 deletions

2832
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,15 @@ Pressing `P` SHALL activate the Pen tool. Pen-created vectors SHALL use vector-n
- **WHEN** user drags while placing a new point - **WHEN** user drags while placing a new point
- **THEN** non-zero tangents are recorded for bezier curvature - **THEN** non-zero tangents are recorded for bezier curvature
### Requirement: Vertex manipulation during creation
While drawing with the Pen tool, users SHALL be able to relocate the currently placed vertex without exiting the active curve drag.
#### Scenario: Relocate vertex with Space
- **WHEN** user drags to create a curve segment and holds `Space`
- **THEN** the active vertex moves with the cursor
- **AND** the tangent handle pull distance remains locked relative to the new vertex position
- **AND** cursor visually remains a crosshair, not switching to the hand tool
### Requirement: Open and closed path commit behavior ### Requirement: Open and closed path commit behavior
Path close intent SHALL be detected near the first vertex, but final close commit SHALL happen on `mouseUp`, not `mouseDown`. Path close intent SHALL be detected near the first vertex, but final close commit SHALL happen on `mouseUp`, not `mouseDown`.

View file

@ -522,8 +522,12 @@ function solveMergedTangents(
const inner = { x: b1 * toRA.x + b2 * toRB.x, y: b1 * toRA.y + b2 * toRB.y } const inner = { x: b1 * toRA.x + b2 * toRB.x, y: b1 * toRA.y + b2 * toRB.y }
const c = const c =
Math.abs(inner.x) > Math.abs(inner.y) Math.abs(inner.x) > Math.abs(inner.y)
? (inner.x !== 0 ? rhs.x / inner.x : 1) ? inner.x !== 0
: (inner.y !== 0 ? rhs.y / inner.y : 1) ? rhs.x / inner.x
: 1
: inner.y !== 0
? rhs.y / inner.y
: 1
return { return {
tangentStart: { x: c * toRA.x, y: c * toRA.y }, tangentStart: { x: c * toRA.x, y: c * toRA.y },
tangentEnd: { x: c * toRB.x, y: c * toRB.y } tangentEnd: { x: c * toRB.x, y: c * toRB.y }

View file

@ -161,7 +161,9 @@ export function createShapeActions(ctx: EditorContext) {
const anchorIndex = isClosing ? 0 : ps.vertices.length - 1 const anchorIndex = isClosing ? 0 : ps.vertices.length - 1
const lastSeg = ps.segments.length > 0 ? ps.segments[ps.segments.length - 1] : undefined const lastSeg = ps.segments.length > 0 ? ps.segments[ps.segments.length - 1] : undefined
const firstSeg = ps.segments.length > 0 ? ps.segments[0] : undefined const firstSeg = ps.segments.length > 0 ? ps.segments[0] : undefined
const opposite = options?.oppositeTangent ?? ps.oppositeDragTangent ?? const opposite =
options?.oppositeTangent ??
ps.oppositeDragTangent ??
(lastSeg ? lastSeg.tangentEnd : { x: -tx, y: -ty }) (lastSeg ? lastSeg.tangentEnd : { x: -tx, y: -ty })
if (options?.constrainToOpposite) { if (options?.constrainToOpposite) {
@ -199,6 +201,16 @@ export function createShapeActions(ctx: EditorContext) {
ctx.requestRepaint() ctx.requestRepaint()
} }
function penSetKnotPosition(x: number, y: number) {
if (!ctx.state.penState) return
const ps = ctx.state.penState
const isClosing = !!ps.pendingClose && ps.vertices.length > 2
const anchorIndex = isClosing ? 0 : ps.vertices.length - 1
ps.vertices[anchorIndex].x = x
ps.vertices[anchorIndex].y = y
ctx.requestRender()
}
function penCommit(closed: boolean) { function penCommit(closed: boolean) {
const ps = ctx.state.penState const ps = ctx.state.penState
if (!ps || ps.vertices.length < 2) { if (!ps || ps.vertices.length < 2) {
@ -350,6 +362,7 @@ export function createShapeActions(ctx: EditorContext) {
penSetDragTangent, penSetDragTangent,
penSetClosingToFirst, penSetClosingToFirst,
penSetPendingClose, penSetPendingClose,
penSetKnotPosition,
penCommit, penCommit,
penCancel, penCancel,
adoptNodesIntoSection, adoptNodesIntoSection,

View file

@ -1,7 +1,7 @@
import { cloneVectorNetwork } from '../scene-graph'
import { defineTool, nodeSummary } from './schema' import { defineTool, nodeSummary } from './schema'
import type { FigmaAPI } from '../figma-api' import type { FigmaAPI } from '../figma-api'
import { cloneVectorNetwork } from '../scene-graph'
import type { SceneNode, VectorNetwork } from '../scene-graph' import type { SceneNode, VectorNetwork } from '../scene-graph'
function getVectorNode( function getVectorNode(

View file

@ -15,6 +15,7 @@ Drücken Sie <kbd>P</kbd>, um das Stiftwerkzeug zu aktivieren.
- **Klicken** setzt einen Eckpunkt (gerades Segment) - **Klicken** setzt einen Eckpunkt (gerades Segment)
- **Klicken + Ziehen** setzt einen Kurvenpunkt mit Bézier-Tangentengriffen - **Klicken + Ziehen** setzt einen Kurvenpunkt mit Bézier-Tangentengriffen
- **<kbd>Space</kbd> gedrückt halten** beim Ziehen (ohne die Maustaste loszulassen), um den Punkt selbst zu verschieben
Klicken Sie mehrere Punkte, um einen Pfad Segment für Segment aufzubauen. Eine Vorschaulinie erstreckt sich vom letzten Punkt zu Ihrem Cursor. Klicken Sie mehrere Punkte, um einen Pfad Segment für Segment aufzubauen. Eine Vorschaulinie erstreckt sich vom letzten Punkt zu Ihrem Cursor.

View file

@ -14,6 +14,7 @@ Pulsa <kbd>P</kbd> para activar la herramienta pluma.
- **Clic** — punto de esquina (segmento recto) - **Clic** — punto de esquina (segmento recto)
- **Clic + arrastrar** — punto de curva con manejadores de tangente Bézier — la dirección y longitud del arrastre controlan la forma de la curva - **Clic + arrastrar** — punto de curva con manejadores de tangente Bézier — la dirección y longitud del arrastre controlan la forma de la curva
- **Mantener <kbd>Space</kbd>** mientras arrastras (sin soltar el botón del ratón) para mover el punto en sí
## Cerrar un trazado ## Cerrar un trazado

View file

@ -10,6 +10,7 @@ description: Tracés vectoriels avec courbes de Bézier in OpenPencil.
## Placer des points ## Placer des points
- **Click** — corner point - **Click** — corner point
- **Click + drag** — curve point with Bézier tangent handles - **Click + drag** — curve point with Bézier tangent handles
- **Hold <kbd>Space</kbd>** while dragging to move the point itself
## Fermer un tracé ## Fermer un tracé
Click the first point to close into a loop. Click the first point to close into a loop.

View file

@ -10,6 +10,7 @@ description: Percorsi vettoriali con curve di Bézier in OpenPencil.
## Posizionare punti ## Posizionare punti
- **Click** — punto angolare (segmento rettilineo) - **Click** — punto angolare (segmento rettilineo)
- **Click + trascina** — punto curvo con maniglie tangenti di Bézier - **Click + trascina** — punto curvo con maniglie tangenti di Bézier
- **Tieni premuto <kbd>Space</kbd>** mentre trascini per spostare il punto stesso
## Chiudere un percorso ## Chiudere un percorso
Clicca sul primo punto del percorso per chiuderlo in un anello. I percorsi chiusi possono essere riempiti. Clicca sul primo punto del percorso per chiuderlo in un anello. I percorsi chiusi possono essere riempiti.

View file

@ -10,6 +10,7 @@ description: Ścieżki wektorowe z krzywymi Béziera in OpenPencil.
## Stawianie punktów ## Stawianie punktów
- **Click** — corner point - **Click** — corner point
- **Click + drag** — curve point with Bézier tangent handles - **Click + drag** — curve point with Bézier tangent handles
- **Hold <kbd>Space</kbd>** while dragging to move the point itself
## Zamykanie ścieżki ## Zamykanie ścieżki
Click the first point to close into a loop. Click the first point to close into a loop.

View file

@ -15,6 +15,7 @@ description: Рисование векторных контуров с крив
- **Клик** — ставит угловую точку (прямолинейный сегмент) - **Клик** — ставит угловую точку (прямолинейный сегмент)
- **Клик + перетаскивание** — ставит точку кривой с касательными ручками Безье: направление и длина перетаскивания определяют форму кривой - **Клик + перетаскивание** — ставит точку кривой с касательными ручками Безье: направление и длина перетаскивания определяют форму кривой
- **Удерживайте <kbd>Space</kbd>** во время перетаскивания (не отпуская кнопку мыши), чтобы переместить саму точку
Кликайте по нескольким точкам, чтобы строить контур сегмент за сегментом. Линия предпросмотра тянется от последней поставленной точки к вашему курсору при перемещении. Кликайте по нескольким точкам, чтобы строить контур сегмент за сегментом. Линия предпросмотра тянется от последней поставленной точки к вашему курсору при перемещении.

View file

@ -14,6 +14,7 @@ Press <kbd>P</kbd> to activate the pen tool.
- **Click** to place a corner point (straight-line segment) - **Click** to place a corner point (straight-line segment)
- **Click + drag** to place a curve point with bezier tangent handles — the drag direction and length control the curve shape - **Click + drag** to place a curve point with bezier tangent handles — the drag direction and length control the curve shape
- **Hold <kbd>Space</kbd>** while dragging (without releasing the mouse button) to move the point itself
Click multiple points to build a path segment by segment. A preview line extends from the last placed point to your cursor as you move. Click multiple points to build a path segment by segment. A preview line extends from the last placed point to your cursor as you move.

View file

@ -74,6 +74,13 @@ export function useCanvasInput(
) { ) {
const drag = ref<DragState | null>(null) const drag = ref<DragState | null>(null)
const cursorOverride = ref<string | null>(null) const cursorOverride = ref<string | null>(null)
const spaceHeld = ref(false)
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (e.code === 'Space') spaceHeld.value = true
})
useEventListener(window, 'keyup', (e: KeyboardEvent) => {
if (e.code === 'Space') spaceHeld.value = false
})
let lastClickTime = 0 let lastClickTime = 0
let lastClickX = 0 let lastClickX = 0
let lastClickY = 0 let lastClickY = 0
@ -385,6 +392,10 @@ export function useCanvasInput(
} }
if (tool === 'PEN') { if (tool === 'PEN') {
// Hide draft segment during drag
editor.state.penCursorX = null
editor.state.penCursorY = null
// In node edit mode with pen tool: click curve to add point, click vertex to remove // In node edit mode with pen tool: click curve to add point, click vertex to remove
const nodeEditState = ( const nodeEditState = (
editor.state as Editor['state'] & { nodeEditState?: NodeEditState | null } editor.state as Editor['state'] & { nodeEditState?: NodeEditState | null }
@ -406,8 +417,14 @@ export function useCanvasInput(
startX: first.x, startX: first.x,
startY: first.y, startY: first.y,
modifierMode: 'default', modifierMode: 'default',
frozenOppositeTangent: null frozenOppositeTangent: null,
spaceDown: false,
spaceStartX: 0,
spaceStartY: 0,
knotStartX: first.x,
knotStartY: first.y
} as DragState } as DragState
cursorOverride.value = 'crosshair'
return return
} }
} }
@ -419,8 +436,14 @@ export function useCanvasInput(
startX: cx, startX: cx,
startY: cy, startY: cy,
modifierMode: 'default', modifierMode: 'default',
frozenOppositeTangent: null frozenOppositeTangent: null,
spaceDown: false,
spaceStartX: 0,
spaceStartY: 0,
knotStartX: cx,
knotStartY: cy
} as DragState } as DragState
cursorOverride.value = 'crosshair'
return return
} }
@ -455,10 +478,12 @@ export function useCanvasInput(
editor.state.penCursorX = cx editor.state.penCursorX = cx
editor.state.penCursorY = cy editor.state.penCursorY = cy
const first = editor.state.penState.vertices[0] if (!drag.value) {
if (editor.state.penState.vertices.length > 2) { const first = editor.state.penState.vertices[0]
const dist = Math.hypot(cx - first.x, cy - first.y) if (editor.state.penState.vertices.length > 2) {
editor.penSetClosingToFirst(dist < PEN_CLOSE_THRESHOLD) const dist = Math.hypot(cx - first.x, cy - first.y)
editor.penSetClosingToFirst(dist < PEN_CLOSE_THRESHOLD)
}
} }
editor.requestRepaint() editor.requestRepaint()
} }
@ -520,58 +545,88 @@ export function useCanvasInput(
} }
if (d.type === 'pen-drag') { if (d.type === 'pen-drag') {
const tx = cx - d.startX const isSpace = spaceHeld.value
const ty = cy - d.startY const penState = editor.state.penState
if (Math.hypot(tx, ty) > 2) {
const penState = editor.state.penState if (!penState) return
const firstSeg = penState?.segments[0] const isClosing = !!penState.pendingClose && penState.vertices.length > 2
const closingOpposite = const anchorIndex = isClosing ? 0 : penState.vertices.length - 1
penState?.pendingClose && firstSeg const anchor = penState.vertices[anchorIndex]
? firstSeg.start === 0
? firstSeg.tangentStart if (isSpace) {
: firstSeg.end === 0 if (!d.spaceDown) {
? firstSeg.tangentEnd d.spaceDown = true
: null d.spaceStartX = cx
: null d.spaceStartY = cy
const mode = e.metaKey || e.ctrlKey ? 'continuous' : e.altKey ? 'independent' : 'default' d.knotStartX = anchor.x
if (mode !== d.modifierMode) { d.knotStartY = anchor.y
if (mode === 'default') {
d.frozenOppositeTangent = null
} else if (!d.frozenOppositeTangent) {
const lastSeg = penState?.segments[penState.segments.length - 1]
d.frozenOppositeTangent = lastSeg
? closingOpposite
? { ...closingOpposite }
: { ...lastSeg.tangentEnd }
: penState?.dragTangent
? { x: -penState.dragTangent.x, y: -penState.dragTangent.y }
: { x: 0, y: 0 }
}
d.modifierMode = mode
} }
if (mode === 'continuous') { const dx = cx - d.spaceStartX
editor.penSetDragTangent(tx, ty, { const dy = cy - d.spaceStartY
keepOpposite: true, editor.penSetKnotPosition?.(d.knotStartX + dx, d.knotStartY + dy)
constrainToOpposite: true, } else {
oppositeTangent: d.frozenOppositeTangent if (d.spaceDown) {
}) d.spaceDown = false
} else if (mode === 'independent') { // Adjust startX/startY so the tangent pull is relative to the new knot position
editor.penSetDragTangent(tx, ty, { const dx = anchor.x - d.knotStartX
keepOpposite: true, const dy = anchor.y - d.knotStartY
oppositeTangent: d.frozenOppositeTangent d.startX += dx
}) d.startY += dy
} else { }
editor.penSetDragTangent(
tx, const tx = cx - d.startX
ty, const ty = cy - d.startY
penState?.pendingClose if (Math.hypot(tx, ty) > 2) {
? { const firstSeg = penState.segments[0]
keepOpposite: true, const closingOpposite =
oppositeTangent: closingOpposite penState.pendingClose && firstSeg
} ? firstSeg.start === 0
: undefined ? firstSeg.tangentStart
) : firstSeg.end === 0
? firstSeg.tangentEnd
: null
: null
const mode = e.metaKey || e.ctrlKey ? 'continuous' : e.altKey ? 'independent' : 'default'
if (mode !== d.modifierMode) {
if (mode === 'default') {
d.frozenOppositeTangent = null
} else if (!d.frozenOppositeTangent) {
const lastSeg = penState.segments[penState.segments.length - 1]
d.frozenOppositeTangent = lastSeg
? closingOpposite
? { ...closingOpposite }
: { ...lastSeg.tangentEnd }
: penState.dragTangent
? { x: -penState.dragTangent.x, y: -penState.dragTangent.y }
: { x: 0, y: 0 }
}
d.modifierMode = mode
}
if (mode === 'continuous') {
editor.penSetDragTangent(tx, ty, {
keepOpposite: true,
constrainToOpposite: true,
oppositeTangent: d.frozenOppositeTangent
})
} else if (mode === 'independent') {
editor.penSetDragTangent(tx, ty, {
keepOpposite: true,
oppositeTangent: d.frozenOppositeTangent
})
} else {
editor.penSetDragTangent(
tx,
ty,
penState.pendingClose
? {
keepOpposite: true,
oppositeTangent: closingOpposite
}
: undefined
)
}
} }
} }
return return

View file

@ -1,7 +1,8 @@
import { cloneVectorNetwork } from '@open-pencil/core'
import { hitTestHandle } from './geometry' import { hitTestHandle } from './geometry'
import type { DragResize, HandlePosition } from './types' import type { DragResize, HandlePosition } from './types'
import { cloneVectorNetwork } from '@open-pencil/core'
import type { Rect, SceneNode } from '@open-pencil/core' import type { Rect, SceneNode } from '@open-pencil/core'
import type { Editor } from '@open-pencil/core/editor' import type { Editor } from '@open-pencil/core/editor'

View file

@ -62,6 +62,11 @@ export interface DragPen {
startY: number startY: number
modifierMode: 'default' | 'continuous' | 'independent' modifierMode: 'default' | 'continuous' | 'independent'
frozenOppositeTangent: Vector | null frozenOppositeTangent: Vector | null
spaceDown: boolean
spaceStartX: number
spaceStartY: number
knotStartX: number
knotStartY: number
} }
export interface DragTextSelect { export interface DragTextSelect {

View file

@ -39,7 +39,9 @@ const { panels } = useI18n()
class="flex items-center gap-1.5 border-b border-border px-3 py-2" class="flex items-center gap-1.5 border-b border-border px-3 py-2"
> >
<span class="text-[11px] text-muted">{{ panels.mixed }}</span> <span class="text-[11px] text-muted">{{ panels.mixed }}</span>
<span class="text-xs font-semibold">{{ panels.layersCount({ count: String(multiCount) }) }}</span> <span class="text-xs font-semibold">{{
panels.layersCount({ count: String(multiCount) })
}}</span>
</div> </div>
<PositionSection /> <PositionSection />
<AppearanceSection /> <AppearanceSection />

View file

@ -4,7 +4,12 @@ import Tip from './ui/Tip.vue'
import HsvColorArea from './HsvColorArea.vue' import HsvColorArea from './HsvColorArea.vue'
import ScrubInput from './ScrubInput.vue' import ScrubInput from './ScrubInput.vue'
import { colorToCSS } from '@open-pencil/core' import { colorToCSS } from '@open-pencil/core'
import { GradientEditorRoot, GradientEditorBar, GradientEditorStop, useI18n } from '@open-pencil/vue' import {
GradientEditorRoot,
GradientEditorBar,
GradientEditorStop,
useI18n
} from '@open-pencil/vue'
import type { Fill } from '@open-pencil/core' import type { Fill } from '@open-pencil/core'

View file

@ -46,9 +46,9 @@ function handlePageDblClick(
<PageListRoot v-slot="{ pages, currentPageId, isDivider, addPage, switchPage, renamePage }"> <PageListRoot v-slot="{ pages, currentPageId, isDivider, addPage, switchPage, renamePage }">
<div data-test-id="pages-panel" class="flex min-h-0 flex-1 flex-col"> <div data-test-id="pages-panel" class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between px-3 py-1.5"> <div class="flex shrink-0 items-center justify-between px-3 py-1.5">
<span data-test-id="pages-header" class="text-[11px] tracking-wider text-muted uppercase" <span data-test-id="pages-header" class="text-[11px] tracking-wider text-muted uppercase">{{
>{{ panels.pages }}</span panels.pages
> }}</span>
<Tip :label="panels.addPage"> <Tip :label="panels.addPage">
<button <button
data-test-id="pages-add" data-test-id="pages-add"

View file

@ -52,7 +52,9 @@ const ctx = useVariablesEditor({
<DialogContent data-test-id="variables-dialog" :class="cls.content"> <DialogContent data-test-id="variables-dialog" :class="cls.content">
<div v-if="!ctx.hasCollections" class="flex flex-1 flex-col"> <div v-if="!ctx.hasCollections" class="flex flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b border-border px-4 py-3"> <div class="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
<DialogTitle class="text-sm font-semibold text-surface">{{ dialogs.localVariables }}</DialogTitle> <DialogTitle class="text-sm font-semibold text-surface">{{
dialogs.localVariables
}}</DialogTitle>
<DialogClose <DialogClose
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface" class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
> >

View file

@ -126,7 +126,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)" @commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
> >
<template #icon> <template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M1 11V4a3 3 0 0 1 3-3h7" /> <path d="M1 11V4a3 3 0 0 1 3-3h7" />
</svg> </svg>
</template> </template>
@ -138,7 +144,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)" @commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
> >
<template #icon> <template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11 11V4a3 3 0 0 0-3-3H1" /> <path d="M11 11V4a3 3 0 0 0-3-3H1" />
</svg> </svg>
</template> </template>
@ -150,7 +162,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)" @commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
> >
<template #icon> <template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M1 1v7a3 3 0 0 0 3 3h7" /> <path d="M1 1v7a3 3 0 0 0 3 3h7" />
</svg> </svg>
</template> </template>
@ -162,7 +180,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)" @commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
> >
<template #icon> <template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11 1v7a3 3 0 0 1-3 3H1" /> <path d="M11 1v7a3 3 0 0 1-3 3H1" />
</svg> </svg>
</template> </template>

View file

@ -32,7 +32,7 @@ type BindingApi = {
unbindVariable: (nodeId: string, index: number) => void unbindVariable: (nodeId: string, index: number) => void
} }
const props = defineProps<{ const { item, index, activeNodeId, bindingApi, visibilityTestId, unbindTestId } = defineProps<{
item: { opacity: number; visible: boolean } item: { opacity: number; visible: boolean }
index: number index: number
activeNodeId?: string | null activeNodeId?: string | null
@ -97,9 +97,9 @@ const { panels, dialogs } = useI18n()
@update:model-value="bindingApi.searchTerm.value = String($event)" @update:model-value="bindingApi.searchTerm.value = String($event)"
/> />
<ComboboxContent class="max-h-48 overflow-y-auto p-1"> <ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted" <ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted">{{
>{{ panels.noVariablesFound }}</ComboboxEmpty panels.noVariablesFound
> }}</ComboboxEmpty>
<ComboboxItem <ComboboxItem
v-for="v in bindingApi.filteredVariables.value" v-for="v in bindingApi.filteredVariables.value"
:key="v.id" :key="v.id"

View file

@ -77,12 +77,12 @@ function onToggleSides(activeNode: SceneNode) {
class="w-[72px]" class="w-[72px]"
:model-value="strokeCtx.currentAlign(activeNode)" :model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions" :options="strokeCtx.alignOptions"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)" @update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode!)"
/> />
<ScrubInput <ScrubInput
v-if="!expandedSides" v-if="!expandedSides"
class="flex-1" class="flex-1"
:model-value="activeNode.strokes[0]?.weight ?? 1" :model-value="activeNode!.strokes[0]?.weight ?? 1"
:min="0" :min="0"
@update:model-value="patch(0, { weight: $event })" @update:model-value="patch(0, { weight: $event })"
> >
@ -105,7 +105,7 @@ function onToggleSides(activeNode: SceneNode) {
data-test-id="stroke-sides-toggle" data-test-id="stroke-sides-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface" class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': expandedSides }" :class="{ '!border-accent !text-accent': expandedSides }"
@click="onToggleSides(activeNode)" @click="onToggleSides(activeNode!)"
> >
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor"> <svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1" /> <rect x="1" y="1" width="5" height="5" rx="1" />
@ -125,12 +125,12 @@ function onToggleSides(activeNode: SceneNode) {
v-for="side in strokeCtx.borderSides" v-for="side in strokeCtx.borderSides"
:key="side" :key="side"
:model-value=" :model-value="
activeNode[ activeNode![
`border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode `border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
] as number ] as number
" "
:min="0" :min="0"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode)" @update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode!)"
> >
<template #icon> <template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke-width="1.5"> <svg class="size-3" viewBox="0 0 12 12" fill="none" stroke-width="1.5">

View file

@ -14,12 +14,6 @@ import { openFileDialog } from './use-menu'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
type NodeEditKeyboardMethods = Partial<{
nodeEditDeleteSelected: () => void
nodeEditBreakAtVertex: () => void
exitNodeEditMode: (commit: boolean) => void
}>
function isEditing(e: Event) { function isEditing(e: Event) {
return e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement return e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
} }
@ -105,7 +99,14 @@ export function useKeyboard() {
useEventListener(window, 'keydown', (e: KeyboardEvent) => { useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (isEditing(e)) return if (isEditing(e)) return
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat && toolBeforeSpace === null) { if (
e.code === 'Space' &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.repeat &&
toolBeforeSpace === null
) {
if (store.state.activeTool !== 'HAND') { if (store.state.activeTool !== 'HAND') {
toolBeforeSpace = store.state.activeTool toolBeforeSpace = store.state.activeTool
store.setTool('HAND') store.setTool('HAND')
@ -245,7 +246,7 @@ export function useKeyboard() {
(store.state.nodeEditState.selectedVertexIndices.size > 0 || (store.state.nodeEditState.selectedVertexIndices.size > 0 ||
store.state.nodeEditState.selectedHandles.size > 0) store.state.nodeEditState.selectedHandles.size > 0)
) { ) {
nodeEditStore.nodeEditDeleteSelected() store.nodeEditDeleteSelected()
return return
} }
runCommand('selection.delete') runCommand('selection.delete')
@ -257,9 +258,9 @@ export function useKeyboard() {
store.state.nodeEditState.selectedHandles.size > 0) store.state.nodeEditState.selectedHandles.size > 0)
) { ) {
if (keys['alt'].value) { if (keys['alt'].value) {
nodeEditStore.nodeEditBreakAtVertex() store.nodeEditBreakAtVertex()
} else { } else {
nodeEditStore.nodeEditDeleteSelected() store.nodeEditDeleteSelected()
} }
return return
} }
@ -267,14 +268,14 @@ export function useKeyboard() {
}) })
whenever(plain('Enter'), () => { whenever(plain('Enter'), () => {
if (store.state.nodeEditState) { if (store.state.nodeEditState) {
nodeEditStore.exitNodeEditMode(true) store.exitNodeEditMode(true)
return return
} }
if (store.state.penState) store.penCommit(false) if (store.state.penState) store.penCommit(false)
}) })
whenever(plain('Escape'), () => { whenever(plain('Escape'), () => {
if (store.state.nodeEditState) { if (store.state.nodeEditState) {
nodeEditStore.exitNodeEditMode(true) store.exitNodeEditMode(true)
return return
} }
if (store.state.penState) { if (store.state.penState) {

View file

@ -218,7 +218,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
function setTool(tool: Tool) { function setTool(tool: Tool) {
// If switching away from PEN while drawing, commit the open path // If switching away from PEN while drawing, commit the open path
if (state.penState && tool !== 'PEN') { // except when switching to HAND (e.g. holding Space to pan)
if (state.penState && tool !== 'PEN' && tool !== 'HAND') {
editor.penCommit(false) editor.penCommit(false)
} }
state.activeTool = tool state.activeTool = tool
@ -789,7 +790,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
if (v > hi) hi = v if (v > hi) hi = v
} }
const target = align === 'min' ? lo : (align === 'max' ? hi : (lo + hi) / 2) const target = align === 'min' ? lo : align === 'max' ? hi : (lo + hi) / 2
for (const i of indices) { for (const i of indices) {
es.vertices[i] = { ...es.vertices[i], [prop]: target } es.vertices[i] = { ...es.vertices[i], [prop]: target }
} }