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
- **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
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 c =
Math.abs(inner.x) > Math.abs(inner.y)
? (inner.x !== 0 ? rhs.x / inner.x : 1)
: (inner.y !== 0 ? rhs.y / inner.y : 1)
? inner.x !== 0
? rhs.x / inner.x
: 1
: inner.y !== 0
? rhs.y / inner.y
: 1
return {
tangentStart: { x: c * toRA.x, y: c * toRA.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 lastSeg = ps.segments.length > 0 ? ps.segments[ps.segments.length - 1] : 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 })
if (options?.constrainToOpposite) {
@ -199,6 +201,16 @@ export function createShapeActions(ctx: EditorContext) {
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) {
const ps = ctx.state.penState
if (!ps || ps.vertices.length < 2) {
@ -350,6 +362,7 @@ export function createShapeActions(ctx: EditorContext) {
penSetDragTangent,
penSetClosingToFirst,
penSetPendingClose,
penSetKnotPosition,
penCommit,
penCancel,
adoptNodesIntoSection,

View file

@ -1,7 +1,7 @@
import { cloneVectorNetwork } from '../scene-graph'
import { defineTool, nodeSummary } from './schema'
import type { FigmaAPI } from '../figma-api'
import { cloneVectorNetwork } from '../scene-graph'
import type { SceneNode, VectorNetwork } from '../scene-graph'
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 + 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.

View file

@ -14,6 +14,7 @@ Pulsa <kbd>P</kbd> para activar la herramienta pluma.
- **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
- **Mantener <kbd>Space</kbd>** mientras arrastras (sin soltar el botón del ratón) para mover el punto en sí
## Cerrar un trazado

View file

@ -10,6 +10,7 @@ description: Tracés vectoriels avec courbes de Bézier in OpenPencil.
## Placer des points
- **Click** — corner point
- **Click + drag** — curve point with Bézier tangent handles
- **Hold <kbd>Space</kbd>** while dragging to move the point itself
## Fermer un tracé
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
- **Click** — punto angolare (segmento rettilineo)
- **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
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
- **Click** — corner point
- **Click + drag** — curve point with Bézier tangent handles
- **Hold <kbd>Space</kbd>** while dragging to move the point itself
## Zamykanie ścieżki
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 + 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.

View file

@ -74,6 +74,13 @@ export function useCanvasInput(
) {
const drag = ref<DragState | 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 lastClickX = 0
let lastClickY = 0
@ -385,6 +392,10 @@ export function useCanvasInput(
}
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
const nodeEditState = (
editor.state as Editor['state'] & { nodeEditState?: NodeEditState | null }
@ -406,8 +417,14 @@ export function useCanvasInput(
startX: first.x,
startY: first.y,
modifierMode: 'default',
frozenOppositeTangent: null
frozenOppositeTangent: null,
spaceDown: false,
spaceStartX: 0,
spaceStartY: 0,
knotStartX: first.x,
knotStartY: first.y
} as DragState
cursorOverride.value = 'crosshair'
return
}
}
@ -419,8 +436,14 @@ export function useCanvasInput(
startX: cx,
startY: cy,
modifierMode: 'default',
frozenOppositeTangent: null
frozenOppositeTangent: null,
spaceDown: false,
spaceStartX: 0,
spaceStartY: 0,
knotStartX: cx,
knotStartY: cy
} as DragState
cursorOverride.value = 'crosshair'
return
}
@ -455,11 +478,13 @@ export function useCanvasInput(
editor.state.penCursorX = cx
editor.state.penCursorY = cy
if (!drag.value) {
const first = editor.state.penState.vertices[0]
if (editor.state.penState.vertices.length > 2) {
const dist = Math.hypot(cx - first.x, cy - first.y)
editor.penSetClosingToFirst(dist < PEN_CLOSE_THRESHOLD)
}
}
editor.requestRepaint()
}
@ -520,13 +545,42 @@ export function useCanvasInput(
}
if (d.type === 'pen-drag') {
const isSpace = spaceHeld.value
const penState = editor.state.penState
if (!penState) return
const isClosing = !!penState.pendingClose && penState.vertices.length > 2
const anchorIndex = isClosing ? 0 : penState.vertices.length - 1
const anchor = penState.vertices[anchorIndex]
if (isSpace) {
if (!d.spaceDown) {
d.spaceDown = true
d.spaceStartX = cx
d.spaceStartY = cy
d.knotStartX = anchor.x
d.knotStartY = anchor.y
}
const dx = cx - d.spaceStartX
const dy = cy - d.spaceStartY
editor.penSetKnotPosition?.(d.knotStartX + dx, d.knotStartY + dy)
} else {
if (d.spaceDown) {
d.spaceDown = false
// Adjust startX/startY so the tangent pull is relative to the new knot position
const dx = anchor.x - d.knotStartX
const dy = anchor.y - d.knotStartY
d.startX += dx
d.startY += dy
}
const tx = cx - d.startX
const ty = cy - d.startY
if (Math.hypot(tx, ty) > 2) {
const penState = editor.state.penState
const firstSeg = penState?.segments[0]
const firstSeg = penState.segments[0]
const closingOpposite =
penState?.pendingClose && firstSeg
penState.pendingClose && firstSeg
? firstSeg.start === 0
? firstSeg.tangentStart
: firstSeg.end === 0
@ -538,12 +592,12 @@ export function useCanvasInput(
if (mode === 'default') {
d.frozenOppositeTangent = null
} else if (!d.frozenOppositeTangent) {
const lastSeg = penState?.segments[penState.segments.length - 1]
const lastSeg = penState.segments[penState.segments.length - 1]
d.frozenOppositeTangent = lastSeg
? closingOpposite
? { ...closingOpposite }
: { ...lastSeg.tangentEnd }
: penState?.dragTangent
: penState.dragTangent
? { x: -penState.dragTangent.x, y: -penState.dragTangent.y }
: { x: 0, y: 0 }
}
@ -565,7 +619,7 @@ export function useCanvasInput(
editor.penSetDragTangent(
tx,
ty,
penState?.pendingClose
penState.pendingClose
? {
keepOpposite: true,
oppositeTangent: closingOpposite
@ -574,6 +628,7 @@ export function useCanvasInput(
)
}
}
}
return
}

View file

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

View file

@ -62,6 +62,11 @@ export interface DragPen {
startY: number
modifierMode: 'default' | 'continuous' | 'independent'
frozenOppositeTangent: Vector | null
spaceDown: boolean
spaceStartX: number
spaceStartY: number
knotStartX: number
knotStartY: number
}
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"
>
<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>
<PositionSection />
<AppearanceSection />

View file

@ -4,7 +4,12 @@ import Tip from './ui/Tip.vue'
import HsvColorArea from './HsvColorArea.vue'
import ScrubInput from './ScrubInput.vue'
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'

View file

@ -46,9 +46,9 @@ function handlePageDblClick(
<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 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"
>{{ panels.pages }}</span
>
<span data-test-id="pages-header" class="text-[11px] tracking-wider text-muted uppercase">{{
panels.pages
}}</span>
<Tip :label="panels.addPage">
<button
data-test-id="pages-add"

View file

@ -52,7 +52,9 @@ const ctx = useVariablesEditor({
<DialogContent data-test-id="variables-dialog" :class="cls.content">
<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">
<DialogTitle class="text-sm font-semibold text-surface">{{ dialogs.localVariables }}</DialogTitle>
<DialogTitle class="text-sm font-semibold text-surface">{{
dialogs.localVariables
}}</DialogTitle>
<DialogClose
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)"
>
<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" />
</svg>
</template>
@ -138,7 +144,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
>
<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" />
</svg>
</template>
@ -150,7 +162,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
>
<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" />
</svg>
</template>
@ -162,7 +180,13 @@ function onToggleCorners() {
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
>
<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" />
</svg>
</template>

View file

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

View file

@ -77,12 +77,12 @@ function onToggleSides(activeNode: SceneNode) {
class="w-[72px]"
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode!)"
/>
<ScrubInput
v-if="!expandedSides"
class="flex-1"
:model-value="activeNode.strokes[0]?.weight ?? 1"
:model-value="activeNode!.strokes[0]?.weight ?? 1"
:min="0"
@update:model-value="patch(0, { weight: $event })"
>
@ -105,7 +105,7 @@ function onToggleSides(activeNode: SceneNode) {
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="{ '!border-accent !text-accent': expandedSides }"
@click="onToggleSides(activeNode)"
@click="onToggleSides(activeNode!)"
>
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
<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"
:key="side"
:model-value="
activeNode[
activeNode![
`border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
] as number
"
:min="0"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode)"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode!)"
>
<template #icon>
<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'
type NodeEditKeyboardMethods = Partial<{
nodeEditDeleteSelected: () => void
nodeEditBreakAtVertex: () => void
exitNodeEditMode: (commit: boolean) => void
}>
function isEditing(e: Event) {
return e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
}
@ -105,7 +99,14 @@ export function useKeyboard() {
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
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') {
toolBeforeSpace = store.state.activeTool
store.setTool('HAND')
@ -245,7 +246,7 @@ export function useKeyboard() {
(store.state.nodeEditState.selectedVertexIndices.size > 0 ||
store.state.nodeEditState.selectedHandles.size > 0)
) {
nodeEditStore.nodeEditDeleteSelected()
store.nodeEditDeleteSelected()
return
}
runCommand('selection.delete')
@ -257,9 +258,9 @@ export function useKeyboard() {
store.state.nodeEditState.selectedHandles.size > 0)
) {
if (keys['alt'].value) {
nodeEditStore.nodeEditBreakAtVertex()
store.nodeEditBreakAtVertex()
} else {
nodeEditStore.nodeEditDeleteSelected()
store.nodeEditDeleteSelected()
}
return
}
@ -267,14 +268,14 @@ export function useKeyboard() {
})
whenever(plain('Enter'), () => {
if (store.state.nodeEditState) {
nodeEditStore.exitNodeEditMode(true)
store.exitNodeEditMode(true)
return
}
if (store.state.penState) store.penCommit(false)
})
whenever(plain('Escape'), () => {
if (store.state.nodeEditState) {
nodeEditStore.exitNodeEditMode(true)
store.exitNodeEditMode(true)
return
}
if (store.state.penState) {

View file

@ -218,7 +218,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
function setTool(tool: Tool) {
// 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)
}
state.activeTool = tool
@ -789,7 +790,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
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) {
es.vertices[i] = { ...es.vertices[i], [prop]: target }
}