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:
parent
455f04d6f1
commit
0ff5a87ccb
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ description: Рисование векторных контуров с крив
|
||||||
|
|
||||||
- **Клик** — ставит угловую точку (прямолинейный сегмент)
|
- **Клик** — ставит угловую точку (прямолинейный сегмент)
|
||||||
- **Клик + перетаскивание** — ставит точку кривой с касательными ручками Безье: направление и длина перетаскивания определяют форму кривой
|
- **Клик + перетаскивание** — ставит точку кривой с касательными ручками Безье: направление и длина перетаскивания определяют форму кривой
|
||||||
|
- **Удерживайте <kbd>Space</kbd>** во время перетаскивания (не отпуская кнопку мыши), чтобы переместить саму точку
|
||||||
|
|
||||||
Кликайте по нескольким точкам, чтобы строить контур сегмент за сегментом. Линия предпросмотра тянется от последней поставленной точки к вашему курсору при перемещении.
|
Кликайте по нескольким точкам, чтобы строить контур сегмент за сегментом. Линия предпросмотра тянется от последней поставленной точки к вашему курсору при перемещении.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue