feat(editor): add Figma-style frame presets (#418)
Add frame preset creation and resizing with constraint-aware layout, undo/redo, toolbar flyouts, localization, documentation, and regression coverage.
This commit is contained in:
parent
0cd0ee6356
commit
18899ff13c
|
|
@ -20,6 +20,7 @@
|
|||
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.
|
||||
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, segmented controls, property lists, color models, fill controls, and gradient primitives.
|
||||
- Connect local MCP clients through automatically discovered private Unix sockets on macOS and Linux, with localhost TCP fallback. (#338)
|
||||
- Create centered frames from current Figma-style device and asset presets, or resize selected frames from the Design panel while preserving their names.
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SECTION_DEFAULT_STROKE
|
||||
} from '#core/constants'
|
||||
|
||||
import { createFramePresetActions } from './shapes/frame-presets'
|
||||
import { createPenActions } from './shapes/pen'
|
||||
import { adoptNodesIntoSection as adoptNodesIntoSectionImpl } from './shapes/section-adopt'
|
||||
import type { EditorContext } from './types'
|
||||
|
|
@ -38,7 +39,8 @@ export function createShapeActions(ctx: EditorContext) {
|
|||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
parentId?: string
|
||||
parentId?: string,
|
||||
name?: string
|
||||
): string {
|
||||
const fill = DEFAULT_FILLS[type] ?? DEFAULT_FILLS.RECTANGLE
|
||||
const pid = parentId ?? ctx.state.currentPageId
|
||||
|
|
@ -47,7 +49,8 @@ export function createShapeActions(ctx: EditorContext) {
|
|||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
fills: [{ ...fill }]
|
||||
fills: [{ ...fill }],
|
||||
...(name ? { name } : {})
|
||||
}
|
||||
if (type === 'SECTION') {
|
||||
overrides.strokes = [{ ...SECTION_DEFAULT_STROKE }]
|
||||
|
|
@ -79,6 +82,7 @@ export function createShapeActions(ctx: EditorContext) {
|
|||
}
|
||||
|
||||
const penActions = createPenActions(ctx, createShape)
|
||||
const framePresetActions = createFramePresetActions(ctx, createShape)
|
||||
|
||||
function setTool(tool: typeof ctx.state.activeTool) {
|
||||
ctx.setActiveTool(tool)
|
||||
|
|
@ -87,6 +91,7 @@ export function createShapeActions(ctx: EditorContext) {
|
|||
return {
|
||||
createShape,
|
||||
...penActions,
|
||||
...framePresetActions,
|
||||
adoptNodesIntoSection: (sectionId: string) => adoptNodesIntoSectionImpl(ctx, sectionId),
|
||||
setTool
|
||||
}
|
||||
|
|
|
|||
154
packages/core/src/editor/shapes/frame-presets.ts
Normal file
154
packages/core/src/editor/shapes/frame-presets.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
collectResizeDescendants,
|
||||
computeConstrainedResizeChanges,
|
||||
type ResizeSnapshot
|
||||
} from '@open-pencil/scene-graph/resize'
|
||||
|
||||
import type { EditorContext } from '#core/editor/types'
|
||||
|
||||
export interface FramePresetDimensions {
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type FrameResizePatch = Pick<
|
||||
SceneNode,
|
||||
'width' | 'height' | 'primaryAxisSizing' | 'counterAxisSizing' | 'layoutGrow' | 'layoutAlignSelf'
|
||||
>
|
||||
|
||||
type CreateShape = (
|
||||
type: 'FRAME',
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
parentId: string | undefined,
|
||||
name: string
|
||||
) => string
|
||||
|
||||
function fixedSizePatch(
|
||||
ctx: EditorContext,
|
||||
node: SceneNode,
|
||||
preset: FramePresetDimensions
|
||||
): FrameResizePatch {
|
||||
const parent = node.parentId ? ctx.graph.getNode(node.parentId) : undefined
|
||||
const inheritsStretch =
|
||||
node.layoutPositioning !== 'ABSOLUTE' &&
|
||||
parent?.layoutMode !== 'NONE' &&
|
||||
(parent?.layoutMode === 'GRID' || parent?.counterAxisAlign === 'STRETCH')
|
||||
|
||||
return {
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
layoutGrow: 0,
|
||||
layoutAlignSelf:
|
||||
node.layoutAlignSelf === 'STRETCH' || (node.layoutAlignSelf === 'AUTO' && inheritsStretch)
|
||||
? 'MIN'
|
||||
: node.layoutAlignSelf
|
||||
}
|
||||
}
|
||||
|
||||
export function createFramePresetActions(ctx: EditorContext, createShape: CreateShape) {
|
||||
function createFrameFromPreset(preset: FramePresetDimensions): string {
|
||||
const { width: viewportWidth, height: viewportHeight } = ctx.getViewportSize()
|
||||
const centerX = (viewportWidth / 2 - ctx.state.panX) / ctx.state.zoom
|
||||
const centerY = (viewportHeight / 2 - ctx.state.panY) / ctx.state.zoom
|
||||
const previousSelection = new Set(ctx.state.selectedIds)
|
||||
const id = ctx.undo.runBatch('Create frame', () => {
|
||||
const createdId = createShape(
|
||||
'FRAME',
|
||||
centerX - preset.width / 2,
|
||||
centerY - preset.height / 2,
|
||||
preset.width,
|
||||
preset.height,
|
||||
undefined,
|
||||
preset.name
|
||||
)
|
||||
const createdSelection = new Set([createdId])
|
||||
ctx.setSelectedIds(createdSelection)
|
||||
ctx.undo.push({
|
||||
label: 'Select created frame',
|
||||
forward: () => ctx.setSelectedIds(new Set(createdSelection)),
|
||||
inverse: () => ctx.setSelectedIds(new Set(previousSelection))
|
||||
})
|
||||
return createdId
|
||||
})
|
||||
|
||||
ctx.setActiveTool('SELECT')
|
||||
ctx.requestRender()
|
||||
return id
|
||||
}
|
||||
|
||||
function applyResize(
|
||||
id: string,
|
||||
root: Partial<SceneNode>,
|
||||
descendants: ReadonlyMap<string, Partial<SceneNode> | ResizeSnapshot>
|
||||
) {
|
||||
ctx.graph.updateNode(id, root)
|
||||
for (const [childId, changes] of descendants) {
|
||||
ctx.graph.updateNode(childId, changes)
|
||||
if ('vectorNetwork' in changes) ctx.getRenderer()?.invalidateVectorPath(childId)
|
||||
}
|
||||
ctx.runLayoutForNode(id)
|
||||
}
|
||||
|
||||
function applyLayoutAwareResize(
|
||||
id: string,
|
||||
previous: FrameResizePatch,
|
||||
next: FrameResizePatch,
|
||||
originals: ReadonlyMap<string, ResizeSnapshot>
|
||||
) {
|
||||
ctx.graph.updateNode(id, next)
|
||||
const provisional = computeConstrainedResizeChanges(ctx.graph, id, previous, next, originals)
|
||||
for (const [childId, changes] of provisional) ctx.graph.updateNode(childId, changes)
|
||||
ctx.runLayoutForNode(id)
|
||||
|
||||
const final = computeConstrainedResizeChanges(ctx.graph, id, previous, next, originals)
|
||||
applyResize(id, next, final)
|
||||
}
|
||||
|
||||
function resizeFrameToPreset(id: string, preset: FramePresetDimensions) {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (node?.type !== 'FRAME') return
|
||||
|
||||
const previous = {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
primaryAxisSizing: node.primaryAxisSizing,
|
||||
counterAxisSizing: node.counterAxisSizing,
|
||||
layoutGrow: node.layoutGrow,
|
||||
layoutAlignSelf: node.layoutAlignSelf
|
||||
}
|
||||
const next = fixedSizePatch(ctx, node, preset)
|
||||
if (
|
||||
previous.width === next.width &&
|
||||
previous.height === next.height &&
|
||||
previous.primaryAxisSizing === next.primaryAxisSizing &&
|
||||
previous.counterAxisSizing === next.counterAxisSizing &&
|
||||
previous.layoutGrow === next.layoutGrow &&
|
||||
previous.layoutAlignSelf === next.layoutAlignSelf
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalDescendants = collectResizeDescendants(ctx.graph, id) ?? new Map()
|
||||
applyLayoutAwareResize(id, previous, next, originalDescendants)
|
||||
const resizedDescendants = collectResizeDescendants(ctx.graph, id) ?? new Map()
|
||||
ctx.undo.push({
|
||||
label: 'Resize frame to preset',
|
||||
forward: () => applyLayoutAwareResize(id, previous, next, originalDescendants),
|
||||
inverse: () => {
|
||||
applyLayoutAwareResize(id, next, previous, resizedDescendants)
|
||||
// Constraint math rounds and clamps, so only the captured snapshot can restore exactly.
|
||||
applyResize(id, previous, originalDescendants)
|
||||
}
|
||||
})
|
||||
ctx.requestRender()
|
||||
}
|
||||
|
||||
return { createFrameFromPreset, resizeFrameToPreset }
|
||||
}
|
||||
|
|
@ -48,8 +48,9 @@ export function createGridChildNode(child: SceneNode): YogaNode {
|
|||
}
|
||||
const hasLayout = child.layoutMode !== 'NONE'
|
||||
const explicitStretch = child.layoutGrow > 0 || child.layoutAlignSelf === 'STRETCH'
|
||||
const inheritsContainerStretch = hasLayout && child.layoutAlignSelf === 'AUTO'
|
||||
|
||||
if (explicitStretch || hasLayout) {
|
||||
if (explicitStretch || inheritsContainerStretch) {
|
||||
yogaChild.setWidthStretch()
|
||||
} else {
|
||||
yogaChild.setWidth(child.width)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Feature-by-feature comparison of Figma Design capabilities with Open Pencil's cu
|
|||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Shape tools (Rectangle, Ellipse, Line, Polygon, Star) | ✅ | All basic shape types; polygon side count and star inner radius configurable |
|
||||
| Frames | ✅ | Clip content, independent coordinate system |
|
||||
| Frames | ✅ | Clip content, independent coordinate system, and Figma-style creation and resize presets |
|
||||
| Groups | ✅ | <kbd>⌘</kbd><kbd>G</kbd> to group, <kbd>⇧</kbd><kbd>⌘</kbd><kbd>G</kbd> to ungroup |
|
||||
| Sections | ✅ | Title pills, auto-adopt overlapping nodes, luminance-adaptive text |
|
||||
| Arc tool (arcs, semi-circles, rings) | ✅ | arcData with start/end angle and inner radius |
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ Click **+** to add an effect. Each effect row is collapsible with inline control
|
|||
|
||||
**Frames** are containers. Drag shapes into a frame to make them children. Frames can clip their content (off by default) and support [auto layout](./auto-layout).
|
||||
|
||||
Select the Frame tool to browse collapsible presets for phones, tablets, desktops, presentations, watches, paper, social media, Figma Community assets, and archived devices in the Design panel. Choosing a preset creates a named frame centered in the viewport and returns to the Select tool. With an existing frame selected, use its Frame preset dropdown to resize it without changing its name.
|
||||
|
||||
**Sections** are top-level containers that automatically adopt overlapping sibling nodes when drawn. They're useful for organizing large canvases into logical areas. Sections display a title pill that you can drag.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@
|
|||
"import": "./dist/geometry.js",
|
||||
"default": "./dist/geometry.js"
|
||||
},
|
||||
"./resize": {
|
||||
"types": "./dist/resize.d.ts",
|
||||
"bun": "./src/resize.ts",
|
||||
"import": "./dist/resize.js",
|
||||
"default": "./dist/resize.js"
|
||||
},
|
||||
"./parse-path": {
|
||||
"types": "./dist/parse-path.d.ts",
|
||||
"bun": "./src/parse-path.ts",
|
||||
|
|
|
|||
184
packages/scene-graph/src/resize.ts
Normal file
184
packages/scene-graph/src/resize.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import type { Rect } from './primitives'
|
||||
import type { ConstraintType, SceneNode, VectorNetwork } from './types'
|
||||
import { cloneVectorNetwork } from './vector-network'
|
||||
|
||||
export type ResizeSnapshot = Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'vectorNetwork'>
|
||||
|
||||
interface ResizeGraph {
|
||||
getNode(id: string): SceneNode | undefined
|
||||
}
|
||||
|
||||
const CONSTRAINT_CONTAINER_TYPES = new Set([
|
||||
'FRAME',
|
||||
'COMPONENT',
|
||||
'COMPONENT_SET',
|
||||
'INSTANCE',
|
||||
'GROUP',
|
||||
'BOOLEAN_OPERATION'
|
||||
])
|
||||
|
||||
function constrainedAxis(
|
||||
position: number,
|
||||
size: number,
|
||||
parentBefore: number,
|
||||
parentAfter: number,
|
||||
constraint: ConstraintType
|
||||
): { position: number; size: number } {
|
||||
const delta = parentAfter - parentBefore
|
||||
if (constraint === 'MAX') return { position: position + delta, size }
|
||||
if (constraint === 'CENTER') return { position: position + delta / 2, size }
|
||||
if (constraint === 'STRETCH') return { position, size: Math.max(1, size + delta) }
|
||||
if (constraint === 'SCALE' && parentBefore > 0) {
|
||||
const scale = parentAfter / parentBefore
|
||||
return { position: position * scale, size: Math.max(1, size * scale) }
|
||||
}
|
||||
return { position, size }
|
||||
}
|
||||
|
||||
export function constrainedChildRect(
|
||||
child: Rect,
|
||||
parentBefore: Pick<Rect, 'width' | 'height'>,
|
||||
parentAfter: Pick<Rect, 'width' | 'height'>,
|
||||
horizontal: ConstraintType,
|
||||
vertical: ConstraintType
|
||||
): Rect {
|
||||
const x = constrainedAxis(child.x, child.width, parentBefore.width, parentAfter.width, horizontal)
|
||||
const y = constrainedAxis(
|
||||
child.y,
|
||||
child.height,
|
||||
parentBefore.height,
|
||||
parentAfter.height,
|
||||
vertical
|
||||
)
|
||||
return {
|
||||
x: Math.round(x.position),
|
||||
y: Math.round(y.position),
|
||||
width: Math.round(x.size),
|
||||
height: Math.round(y.size)
|
||||
}
|
||||
}
|
||||
|
||||
export function scaledChildRect(
|
||||
child: Rect,
|
||||
parentBefore: Pick<Rect, 'width' | 'height'>,
|
||||
parentAfter: Pick<Rect, 'width' | 'height'>
|
||||
): Rect {
|
||||
return constrainedChildRect(child, parentBefore, parentAfter, 'SCALE', 'SCALE')
|
||||
}
|
||||
|
||||
export function scaleVectorNetworkForResize(
|
||||
vectorNetwork: VectorNetwork | null,
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
width: number,
|
||||
height: number
|
||||
): VectorNetwork | null {
|
||||
if (!vectorNetwork || originalWidth <= 0 || originalHeight <= 0) return null
|
||||
|
||||
const scaleX = width / originalWidth
|
||||
const scaleY = height / originalHeight
|
||||
if (scaleX === 1 && scaleY === 1) return null
|
||||
|
||||
return {
|
||||
vertices: vectorNetwork.vertices.map((vertex) => ({
|
||||
...vertex,
|
||||
x: vertex.x * scaleX,
|
||||
y: vertex.y * scaleY
|
||||
})),
|
||||
segments: vectorNetwork.segments.map((segment) => ({
|
||||
...segment,
|
||||
tangentStart: {
|
||||
x: segment.tangentStart.x * scaleX,
|
||||
y: segment.tangentStart.y * scaleY
|
||||
},
|
||||
tangentEnd: {
|
||||
x: segment.tangentEnd.x * scaleX,
|
||||
y: segment.tangentEnd.y * scaleY
|
||||
}
|
||||
})),
|
||||
regions: vectorNetwork.regions
|
||||
}
|
||||
}
|
||||
|
||||
export function collectResizeDescendants(
|
||||
graph: ResizeGraph,
|
||||
rootId: string
|
||||
): Map<string, ResizeSnapshot> | null {
|
||||
const root = graph.getNode(rootId)
|
||||
if (!root || !CONSTRAINT_CONTAINER_TYPES.has(root.type)) return null
|
||||
const snapshots = new Map<string, ResizeSnapshot>()
|
||||
|
||||
const collect = (parentId: string) => {
|
||||
const parent = graph.getNode(parentId)
|
||||
if (!parent) return
|
||||
for (const childId of parent.childIds) {
|
||||
const child = graph.getNode(childId)
|
||||
if (!child) continue
|
||||
snapshots.set(childId, {
|
||||
x: child.x,
|
||||
y: child.y,
|
||||
width: child.width,
|
||||
height: child.height,
|
||||
vectorNetwork: child.vectorNetwork ? cloneVectorNetwork(child.vectorNetwork) : null
|
||||
})
|
||||
collect(childId)
|
||||
}
|
||||
}
|
||||
|
||||
collect(rootId)
|
||||
return snapshots.size > 0 ? snapshots : null
|
||||
}
|
||||
|
||||
export function computeConstrainedResizeChanges(
|
||||
graph: ResizeGraph,
|
||||
rootId: string,
|
||||
rootBefore: Pick<Rect, 'width' | 'height'>,
|
||||
rootAfter: Pick<Rect, 'width' | 'height'>,
|
||||
originals: ReadonlyMap<string, ResizeSnapshot>
|
||||
): Map<string, Partial<SceneNode>> {
|
||||
const changes = new Map<string, Partial<SceneNode>>()
|
||||
|
||||
const compute = (
|
||||
parentId: string,
|
||||
parentBefore: Pick<Rect, 'width' | 'height'>,
|
||||
parentAfter: Pick<Rect, 'width' | 'height'>
|
||||
) => {
|
||||
const parent = graph.getNode(parentId)
|
||||
if (!parent) return
|
||||
const scalesChildren = parent.type === 'GROUP' || parent.type === 'BOOLEAN_OPERATION'
|
||||
for (const childId of parent.childIds) {
|
||||
const original = originals.get(childId)
|
||||
const child = graph.getNode(childId)
|
||||
if (!original || !child) continue
|
||||
const isInFlow = parent.layoutMode !== 'NONE' && child.layoutPositioning !== 'ABSOLUTE'
|
||||
if (isInFlow) {
|
||||
compute(childId, original, child)
|
||||
continue
|
||||
}
|
||||
const rect = scalesChildren
|
||||
? scaledChildRect(original, parentBefore, parentAfter)
|
||||
: constrainedChildRect(
|
||||
original,
|
||||
parentBefore,
|
||||
parentAfter,
|
||||
child.horizontalConstraint,
|
||||
child.verticalConstraint
|
||||
)
|
||||
const childChanges: Partial<SceneNode> = { ...rect }
|
||||
const vectorNetwork = scaleVectorNetworkForResize(
|
||||
original.vectorNetwork,
|
||||
original.width,
|
||||
original.height,
|
||||
rect.width,
|
||||
rect.height
|
||||
)
|
||||
if (vectorNetwork) childChanges.vectorNetwork = vectorNetwork
|
||||
changes.set(childId, childChanges)
|
||||
// The final pass sees layout containers after Yoga has resolved HUG/FILL sizing.
|
||||
compute(childId, original, child.layoutMode === 'NONE' ? rect : child)
|
||||
}
|
||||
}
|
||||
|
||||
compute(rootId, rootBefore, rootAfter)
|
||||
return changes
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export default defineConfig({
|
|||
coordinate: './src/coordinate.ts',
|
||||
matrix: './src/matrix.ts',
|
||||
geometry: './src/geometry.ts',
|
||||
resize: './src/resize.ts',
|
||||
'parse-path': './src/parse-path.ts'
|
||||
},
|
||||
platform: 'neutral',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@
|
|||
"ai": "KI",
|
||||
"assets": "Elemente",
|
||||
"page": "Seite",
|
||||
"frame": "Frame",
|
||||
"framePreset": "Frame-Voreinstellung",
|
||||
"framePresetCustom": "Benutzerdefiniert",
|
||||
"framePresetCategoryPhone": "Telefon",
|
||||
"framePresetCategoryTablet": "Tablet",
|
||||
"framePresetCategoryDesktop": "Desktop",
|
||||
"framePresetCategoryPresentation": "Präsentation",
|
||||
"framePresetCategoryWatch": "Uhr",
|
||||
"framePresetCategoryPaper": "Papier",
|
||||
"framePresetCategorySocialMedia": "Soziale Medien",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "Archiv",
|
||||
"position": "Position",
|
||||
"layout": "Layout",
|
||||
"autoLayout": "Auto-Layout",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
"ai": "IA",
|
||||
"assets": "Recursos",
|
||||
"page": "Página",
|
||||
"frame": "Marco",
|
||||
"framePreset": "Preajuste de marco",
|
||||
"framePresetCustom": "Personalizado",
|
||||
"framePresetCategoryPhone": "Teléfono",
|
||||
"framePresetCategoryTablet": "Tableta",
|
||||
"framePresetCategoryDesktop": "Escritorio",
|
||||
"framePresetCategoryPresentation": "Presentación",
|
||||
"framePresetCategoryWatch": "Reloj",
|
||||
"framePresetCategoryPaper": "Papel",
|
||||
"framePresetCategorySocialMedia": "Redes sociales",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "Archivo",
|
||||
"position": "Posición",
|
||||
"layout": "Diseño",
|
||||
"autoLayout": "Auto-layout",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@
|
|||
"ai": "IA",
|
||||
"assets": "Ressources",
|
||||
"page": "Page",
|
||||
"frame": "Cadre",
|
||||
"framePreset": "Préréglage de cadre",
|
||||
"framePresetCustom": "Personnalisé",
|
||||
"framePresetCategoryPhone": "Téléphone",
|
||||
"framePresetCategoryTablet": "Tablette",
|
||||
"framePresetCategoryDesktop": "Ordinateur",
|
||||
"framePresetCategoryPresentation": "Présentation",
|
||||
"framePresetCategoryWatch": "Montre",
|
||||
"framePresetCategoryPaper": "Papier",
|
||||
"framePresetCategorySocialMedia": "Réseaux sociaux",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "Archives",
|
||||
"position": "Position",
|
||||
"layout": "Disposition",
|
||||
"autoLayout": "Auto-layout",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@
|
|||
"ai": "AI",
|
||||
"assets": "Risorse",
|
||||
"page": "Pagina",
|
||||
"frame": "Cornice",
|
||||
"framePreset": "Preimpostazione cornice",
|
||||
"framePresetCustom": "Personalizzata",
|
||||
"framePresetCategoryPhone": "Telefono",
|
||||
"framePresetCategoryTablet": "Tablet",
|
||||
"framePresetCategoryDesktop": "Desktop",
|
||||
"framePresetCategoryPresentation": "Presentazione",
|
||||
"framePresetCategoryWatch": "Orologio",
|
||||
"framePresetCategoryPaper": "Carta",
|
||||
"framePresetCategorySocialMedia": "Social media",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "Archivio",
|
||||
"position": "Posizione",
|
||||
"layout": "Layout",
|
||||
"autoLayout": "Auto-layout",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
"ai": "AI",
|
||||
"assets": "アセット",
|
||||
"page": "ページ",
|
||||
"frame": "フレーム",
|
||||
"framePreset": "フレームプリセット",
|
||||
"framePresetCustom": "カスタム",
|
||||
"framePresetCategoryPhone": "スマートフォン",
|
||||
"framePresetCategoryTablet": "タブレット",
|
||||
"framePresetCategoryDesktop": "デスクトップ",
|
||||
"framePresetCategoryPresentation": "プレゼンテーション",
|
||||
"framePresetCategoryWatch": "ウォッチ",
|
||||
"framePresetCategoryPaper": "用紙",
|
||||
"framePresetCategorySocialMedia": "ソーシャルメディア",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "アーカイブ",
|
||||
"position": "位置",
|
||||
"layout": "レイアウト",
|
||||
"autoLayout": "オートレイアウト",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@
|
|||
"ai": "AI",
|
||||
"assets": "Zasoby",
|
||||
"page": "Strona",
|
||||
"frame": "Ramka",
|
||||
"framePreset": "Ustawienie ramki",
|
||||
"framePresetCustom": "Niestandardowe",
|
||||
"framePresetCategoryPhone": "Telefon",
|
||||
"framePresetCategoryTablet": "Tablet",
|
||||
"framePresetCategoryDesktop": "Komputer",
|
||||
"framePresetCategoryPresentation": "Prezentacja",
|
||||
"framePresetCategoryWatch": "Zegarek",
|
||||
"framePresetCategoryPaper": "Papier",
|
||||
"framePresetCategorySocialMedia": "Media społecznościowe",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "Archiwum",
|
||||
"position": "Pozycja",
|
||||
"layout": "Układ",
|
||||
"autoLayout": "Auto-layout",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@
|
|||
"ai": "AI",
|
||||
"assets": "Ассеты",
|
||||
"page": "Страница",
|
||||
"frame": "Фрейм",
|
||||
"framePreset": "Размер фрейма",
|
||||
"framePresetCustom": "Пользовательский",
|
||||
"framePresetCategoryPhone": "Телефон",
|
||||
"framePresetCategoryTablet": "Планшет",
|
||||
"framePresetCategoryDesktop": "Компьютер",
|
||||
"framePresetCategoryPresentation": "Презентация",
|
||||
"framePresetCategoryWatch": "Часы",
|
||||
"framePresetCategoryPaper": "Бумага",
|
||||
"framePresetCategorySocialMedia": "Социальные сети",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "Архив",
|
||||
"position": "Позиция",
|
||||
"layout": "Раскладка",
|
||||
"autoLayout": "Автораскладка",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@
|
|||
"ai": "AI",
|
||||
"assets": "资源",
|
||||
"page": "页面",
|
||||
"frame": "画框",
|
||||
"framePreset": "画框预设",
|
||||
"framePresetCustom": "自定义",
|
||||
"framePresetCategoryPhone": "手机",
|
||||
"framePresetCategoryTablet": "平板电脑",
|
||||
"framePresetCategoryDesktop": "桌面设备",
|
||||
"framePresetCategoryPresentation": "演示文稿",
|
||||
"framePresetCategoryWatch": "手表",
|
||||
"framePresetCategoryPaper": "纸张",
|
||||
"framePresetCategorySocialMedia": "社交媒体",
|
||||
"framePresetCategoryFigmaCommunity": "Figma Community",
|
||||
"framePresetCategoryArchive": "归档",
|
||||
"position": "位置",
|
||||
"layout": "布局",
|
||||
"autoLayout": "自动布局",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@ export const panelMessageDefaults = {
|
|||
spread: 'Spread',
|
||||
|
||||
page: 'Page',
|
||||
frame: 'Frame',
|
||||
framePreset: 'Frame preset',
|
||||
framePresetCustom: 'Custom',
|
||||
framePresetCategoryPhone: 'Phone',
|
||||
framePresetCategoryTablet: 'Tablet',
|
||||
framePresetCategoryDesktop: 'Desktop',
|
||||
framePresetCategoryPresentation: 'Presentation',
|
||||
framePresetCategoryWatch: 'Watch',
|
||||
framePresetCategoryPaper: 'Paper',
|
||||
framePresetCategorySocialMedia: 'Social media',
|
||||
framePresetCategoryFigmaCommunity: 'Figma Community',
|
||||
framePresetCategoryArchive: 'Archive',
|
||||
position: 'Position',
|
||||
layout: 'Layout',
|
||||
autoLayout: 'Auto layout',
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ export type {
|
|||
UseFlatReorderDragOptions
|
||||
} from '#vue/shared/drag/useFlatReorderDrag'
|
||||
export { useInlineRename } from '#vue/editor/inline-rename/use'
|
||||
export { useToolbarState } from '#vue/primitives/Toolbar/useToolbarState'
|
||||
export {
|
||||
getToolbarToolSelection,
|
||||
isToolbarToolActive,
|
||||
useToolbarState
|
||||
} from '#vue/primitives/Toolbar/useToolbarState'
|
||||
export { useNodeFontStatus } from '#vue/shared/font-status/use'
|
||||
export { usePropScrub } from '#vue/controls/prop-scrub/use'
|
||||
export { toolCursor } from '#vue/editor/tool-cursor'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { EDITOR_TOOLS } from '@open-pencil/core/editor'
|
||||
|
||||
import { useEditor } from '#vue/editor/context'
|
||||
|
|
@ -14,6 +14,19 @@ const { tools = EDITOR_TOOLS } = defineProps<{
|
|||
const editor = useEditor()
|
||||
const activeTool = computed(() => editor.state.activeTool)
|
||||
const expandedFlyout = ref<Tool | null>(null)
|
||||
const flyoutSelections = reactive(new Map<Tool, Tool>())
|
||||
|
||||
watch(
|
||||
activeTool,
|
||||
(currentTool) => {
|
||||
for (const tool of tools) {
|
||||
if (tool.flyout?.includes(currentTool)) {
|
||||
flyoutSelections.set(tool.key, currentTool)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function setTool(tool: Tool) {
|
||||
editor.setTool(tool)
|
||||
|
|
@ -38,6 +51,7 @@ provideToolbar({
|
|||
editor,
|
||||
tools,
|
||||
activeTool,
|
||||
flyoutSelections,
|
||||
expandedFlyout,
|
||||
setTool,
|
||||
toggleFlyout,
|
||||
|
|
@ -49,6 +63,7 @@ provideToolbar({
|
|||
<slot
|
||||
:tools="tools"
|
||||
:active-tool="activeTool"
|
||||
:flyout-selections="flyoutSelections"
|
||||
:expanded-flyout="expandedFlyout"
|
||||
:actions="actions"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface ToolbarContext {
|
|||
editor: Editor
|
||||
tools: EditorToolDef[]
|
||||
activeTool: ComputedRef<Tool>
|
||||
flyoutSelections: ReadonlyMap<Tool, Tool>
|
||||
expandedFlyout: Ref<Tool | null>
|
||||
setTool: (tool: Tool) => void
|
||||
toggleFlyout: (tool: Tool) => void
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ import type { Tool, EditorToolDef } from '@open-pencil/core/editor'
|
|||
|
||||
const CATEGORY_COUNT = 3
|
||||
|
||||
export function isToolbarToolActive(tool: EditorToolDef, activeTool: Tool): boolean {
|
||||
return tool.key === activeTool || (tool.flyout?.includes(activeTool) ?? false)
|
||||
}
|
||||
|
||||
export function getToolbarToolSelection(
|
||||
tool: EditorToolDef,
|
||||
activeTool: Tool,
|
||||
flyoutSelections?: ReadonlyMap<Tool, Tool>
|
||||
): Tool {
|
||||
if (tool.flyout?.includes(activeTool)) return activeTool
|
||||
return flyoutSelections?.get(tool.key) ?? tool.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns responsive toolbar UI state for mobile category paging.
|
||||
*
|
||||
|
|
@ -17,16 +30,6 @@ export function useToolbarState() {
|
|||
const hasPrev = computed(() => mobileCategory.value > 0)
|
||||
const hasNext = computed(() => mobileCategory.value < CATEGORY_COUNT - 1)
|
||||
|
||||
function isActive(tool: EditorToolDef, activeTool: Tool): boolean {
|
||||
if (tool.key === activeTool) return true
|
||||
return tool.flyout?.includes(activeTool) ?? false
|
||||
}
|
||||
|
||||
function activeKeyForTool(tool: EditorToolDef, activeTool: Tool): Tool {
|
||||
if (tool.flyout?.includes(activeTool)) return activeTool
|
||||
return tool.key
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
if (!hasPrev.value) return
|
||||
slideDirection.value = -1
|
||||
|
|
@ -44,8 +47,8 @@ export function useToolbarState() {
|
|||
slideDirection,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
isActive,
|
||||
activeKeyForTool,
|
||||
isActive: isToolbarToolActive,
|
||||
activeKeyForTool: getToolbarToolSelection,
|
||||
goPrev,
|
||||
goNext
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
export { constrainToAspectRatio } from '#vue/shared/input/resize/rect'
|
||||
export { tryStartResize } from '#vue/shared/input/resize/start'
|
||||
import type { Editor } from '@open-pencil/core/editor'
|
||||
import { computeLayout } from '@open-pencil/core/layout'
|
||||
import { computeAllLayouts } from '@open-pencil/core/layout'
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
computeConstrainedResizeChanges,
|
||||
scaleVectorNetworkForResize
|
||||
} from '@open-pencil/scene-graph/resize'
|
||||
|
||||
import { constrainedChildRect, scaledChildRect } from '#vue/shared/input/resize/constraints'
|
||||
import { calculateResizeRect } from '#vue/shared/input/resize/rect'
|
||||
import { scaleVectorNetworkForResize } from '#vue/shared/input/resize/vector'
|
||||
import type { DragResize } from '#vue/shared/input/types'
|
||||
|
||||
function resizeChanges(d: DragResize, cx: number, cy: number, constrain: boolean) {
|
||||
|
|
@ -32,46 +34,17 @@ function applyConstrainedChildren(
|
|||
editor: Editor
|
||||
) {
|
||||
if (!d.origChildren || d.origRect.width <= 0 || d.origRect.height <= 0) return
|
||||
|
||||
const apply = (
|
||||
parentId: string,
|
||||
parentBefore: Pick<SceneNode, 'width' | 'height'>,
|
||||
parentAfter: Pick<SceneNode, 'width' | 'height'>
|
||||
) => {
|
||||
const parent = editor.graph.getNode(parentId)
|
||||
if (!parent) return
|
||||
const scalesChildren = parent.type === 'GROUP' || parent.type === 'BOOLEAN_OPERATION'
|
||||
for (const childId of parent.childIds) {
|
||||
const original = d.origChildren?.get(childId)
|
||||
const child = editor.graph.getNode(childId)
|
||||
if (!original || !child) continue
|
||||
const rect = scalesChildren
|
||||
? scaledChildRect(original, parentBefore, parentAfter)
|
||||
: constrainedChildRect(
|
||||
original,
|
||||
parentBefore,
|
||||
parentAfter,
|
||||
child.horizontalConstraint,
|
||||
child.verticalConstraint
|
||||
)
|
||||
const childChanges: Partial<SceneNode> = { ...rect }
|
||||
if (original.vectorNetwork) {
|
||||
const scaled = scaleVectorNetworkForResize(
|
||||
original.vectorNetwork,
|
||||
original.width,
|
||||
original.height,
|
||||
rect.width,
|
||||
rect.height
|
||||
)
|
||||
if (scaled) childChanges.vectorNetwork = scaled
|
||||
}
|
||||
editor.graph.updateNodePreview(childId, childChanges)
|
||||
editor.renderer?.invalidateVectorPath(childId)
|
||||
apply(childId, original, rect)
|
||||
}
|
||||
const changes = computeConstrainedResizeChanges(
|
||||
editor.graph,
|
||||
d.nodeId,
|
||||
d.origRect,
|
||||
newRect,
|
||||
d.origChildren
|
||||
)
|
||||
for (const [childId, childChanges] of changes) {
|
||||
editor.graph.updateNodePreview(childId, childChanges)
|
||||
editor.renderer?.invalidateVectorPath(childId)
|
||||
}
|
||||
|
||||
apply(d.nodeId, d.origRect, newRect)
|
||||
}
|
||||
|
||||
export function applyResize(
|
||||
|
|
@ -84,11 +57,9 @@ export function applyResize(
|
|||
const { changes, newRect } = resizeChanges(d, cx, cy, constrain)
|
||||
editor.graph.updateNodePreview(d.nodeId, changes)
|
||||
applyConstrainedChildren(d, newRect, editor)
|
||||
|
||||
const node = editor.graph.getNode(d.nodeId)
|
||||
if (node?.layoutMode !== 'NONE') {
|
||||
editor.graph.runPreviewUpdates(() => computeLayout(editor.graph, d.nodeId))
|
||||
}
|
||||
editor.graph.runPreviewUpdates(() => computeAllLayouts(editor.graph, d.nodeId))
|
||||
applyConstrainedChildren(d, newRect, editor)
|
||||
editor.graph.runPreviewUpdates(() => computeAllLayouts(editor.graph, d.nodeId))
|
||||
editor.requestRepaint()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import type { ConstraintType } from '@open-pencil/scene-graph'
|
||||
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import type { OrigChildState } from '#vue/shared/input/types'
|
||||
|
||||
function constrainedAxis(
|
||||
position: number,
|
||||
size: number,
|
||||
parentBefore: number,
|
||||
parentAfter: number,
|
||||
constraint: ConstraintType
|
||||
): { position: number; size: number } {
|
||||
const delta = parentAfter - parentBefore
|
||||
if (constraint === 'MAX') return { position: position + delta, size }
|
||||
if (constraint === 'CENTER') return { position: position + delta / 2, size }
|
||||
if (constraint === 'STRETCH') return { position, size: Math.max(1, size + delta) }
|
||||
if (constraint === 'SCALE' && parentBefore > 0) {
|
||||
const scale = parentAfter / parentBefore
|
||||
return { position: position * scale, size: Math.max(1, size * scale) }
|
||||
}
|
||||
return { position, size }
|
||||
}
|
||||
|
||||
export function constrainedChildRect(
|
||||
child: OrigChildState,
|
||||
parentBefore: Pick<Rect, 'width' | 'height'>,
|
||||
parentAfter: Pick<Rect, 'width' | 'height'>,
|
||||
horizontal: ConstraintType,
|
||||
vertical: ConstraintType
|
||||
): Rect {
|
||||
const x = constrainedAxis(child.x, child.width, parentBefore.width, parentAfter.width, horizontal)
|
||||
const y = constrainedAxis(
|
||||
child.y,
|
||||
child.height,
|
||||
parentBefore.height,
|
||||
parentAfter.height,
|
||||
vertical
|
||||
)
|
||||
return {
|
||||
x: Math.round(x.position),
|
||||
y: Math.round(y.position),
|
||||
width: Math.round(x.size),
|
||||
height: Math.round(y.size)
|
||||
}
|
||||
}
|
||||
|
||||
export function scaledChildRect(
|
||||
child: OrigChildState,
|
||||
parentBefore: Pick<Rect, 'width' | 'height'>,
|
||||
parentAfter: Pick<Rect, 'width' | 'height'>
|
||||
): Rect {
|
||||
return constrainedChildRect(child, parentBefore, parentAfter, 'SCALE', 'SCALE')
|
||||
}
|
||||
|
|
@ -1,44 +1,9 @@
|
|||
import type { Editor } from '@open-pencil/core/editor'
|
||||
import { cloneVectorNetwork } from '@open-pencil/scene-graph'
|
||||
import { collectResizeDescendants } from '@open-pencil/scene-graph/resize'
|
||||
|
||||
import { getHitHandleByMatrix } from '#vue/shared/input/geometry'
|
||||
import type { DragResize, OrigChildState } from '#vue/shared/input/types'
|
||||
|
||||
const CONSTRAINT_CONTAINER_TYPES = new Set([
|
||||
'FRAME',
|
||||
'COMPONENT',
|
||||
'COMPONENT_SET',
|
||||
'INSTANCE',
|
||||
'GROUP',
|
||||
'BOOLEAN_OPERATION'
|
||||
])
|
||||
|
||||
function collectDescendants(id: string, editor: Editor): Map<string, OrigChildState> | null {
|
||||
const root = editor.graph.getNode(id)
|
||||
if (!root || !CONSTRAINT_CONTAINER_TYPES.has(root.type)) return null
|
||||
const map = new Map<string, OrigChildState>()
|
||||
|
||||
const collect = (parentId: string) => {
|
||||
const parent = editor.graph.getNode(parentId)
|
||||
if (!parent) return
|
||||
for (const childId of parent.childIds) {
|
||||
const child = editor.graph.getNode(childId)
|
||||
if (!child) continue
|
||||
if (parent.layoutMode !== 'NONE' && child.layoutPositioning !== 'ABSOLUTE') continue
|
||||
map.set(childId, {
|
||||
x: child.x,
|
||||
y: child.y,
|
||||
width: child.width,
|
||||
height: child.height,
|
||||
vectorNetwork: child.vectorNetwork ? cloneVectorNetwork(child.vectorNetwork) : null
|
||||
})
|
||||
collect(childId)
|
||||
}
|
||||
}
|
||||
|
||||
collect(id)
|
||||
return map.size > 0 ? map : null
|
||||
}
|
||||
import type { DragResize } from '#vue/shared/input/types'
|
||||
|
||||
export function tryStartResize(cx: number, cy: number, editor: Editor): DragResize | null {
|
||||
for (const id of editor.state.selectedIds) {
|
||||
|
|
@ -54,7 +19,7 @@ export function tryStartResize(cx: number, cy: number, editor: Editor): DragResi
|
|||
origRect: { x: node.x, y: node.y, width: node.width, height: node.height },
|
||||
nodeId: id,
|
||||
origVectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null,
|
||||
origChildren: collectDescendants(id, editor)
|
||||
origChildren: collectResizeDescendants(editor.graph, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
type VectorNetwork = NonNullable<SceneNode['vectorNetwork']>
|
||||
|
||||
export function scaleVectorNetworkForResize(
|
||||
vectorNetwork: VectorNetwork | null,
|
||||
origWidth: number,
|
||||
origHeight: number,
|
||||
width: number,
|
||||
height: number
|
||||
): VectorNetwork | null {
|
||||
if (!vectorNetwork || origWidth <= 0 || origHeight <= 0) return null
|
||||
|
||||
const sx = width / origWidth
|
||||
const sy = height / origHeight
|
||||
if (sx === 1 && sy === 1) return null
|
||||
|
||||
return {
|
||||
vertices: vectorNetwork.vertices.map((vertex) => ({
|
||||
...vertex,
|
||||
x: vertex.x * sx,
|
||||
y: vertex.y * sy
|
||||
})),
|
||||
segments: vectorNetwork.segments.map((segment) => ({
|
||||
...segment,
|
||||
tangentStart: {
|
||||
x: segment.tangentStart.x * sx,
|
||||
y: segment.tangentStart.y * sy
|
||||
},
|
||||
tangentEnd: {
|
||||
x: segment.tangentEnd.x * sx,
|
||||
y: segment.tangentEnd.y * sy
|
||||
}
|
||||
})),
|
||||
regions: vectorNetwork.regions
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Tool } from '@open-pencil/core/editor'
|
||||
import type { NodeType, VectorNetwork } from '@open-pencil/scene-graph'
|
||||
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
import type { ResizeSnapshot } from '@open-pencil/scene-graph/resize'
|
||||
|
||||
export type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
|
||||
|
||||
|
|
@ -37,14 +38,6 @@ export interface DragPan {
|
|||
startPanY: number
|
||||
}
|
||||
|
||||
export interface OrigChildState {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
vectorNetwork: VectorNetwork | null
|
||||
}
|
||||
|
||||
export interface DragResize {
|
||||
type: 'resize'
|
||||
handle: HandlePosition
|
||||
|
|
@ -53,7 +46,7 @@ export interface DragResize {
|
|||
origRect: Rect
|
||||
nodeId: string
|
||||
origVectorNetwork: VectorNetwork | null
|
||||
origChildren: Map<string, OrigChildState> | null
|
||||
origChildren: Map<string, ResizeSnapshot> | null
|
||||
}
|
||||
|
||||
export interface DragMarquee {
|
||||
|
|
|
|||
223
src/app/editor/frame-presets.ts
Normal file
223
src/app/editor/frame-presets.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
export type FramePresetCategoryId =
|
||||
| 'phone'
|
||||
| 'tablet'
|
||||
| 'desktop'
|
||||
| 'presentation'
|
||||
| 'watch'
|
||||
| 'paper'
|
||||
| 'social-media'
|
||||
| 'figma-community'
|
||||
| 'archive'
|
||||
|
||||
export type FramePresetCategoryLabelKey =
|
||||
| 'framePresetCategoryPhone'
|
||||
| 'framePresetCategoryTablet'
|
||||
| 'framePresetCategoryDesktop'
|
||||
| 'framePresetCategoryPresentation'
|
||||
| 'framePresetCategoryWatch'
|
||||
| 'framePresetCategoryPaper'
|
||||
| 'framePresetCategorySocialMedia'
|
||||
| 'framePresetCategoryFigmaCommunity'
|
||||
| 'framePresetCategoryArchive'
|
||||
|
||||
export interface FramePreset {
|
||||
id: string
|
||||
/** Canonical Figma preset label, kept verbatim across locales. */
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface FramePresetCategory {
|
||||
id: FramePresetCategoryId
|
||||
labelKey: FramePresetCategoryLabelKey
|
||||
presets: readonly FramePreset[]
|
||||
}
|
||||
|
||||
export const FRAME_PRESET_CATEGORIES: readonly FramePresetCategory[] = [
|
||||
{
|
||||
id: 'phone',
|
||||
labelKey: 'framePresetCategoryPhone',
|
||||
presets: [
|
||||
{ id: 'iphone-17', name: 'iPhone 17', width: 402, height: 874 },
|
||||
{ id: 'iphone-16-17-pro', name: 'iPhone 16 & 17 Pro', width: 402, height: 874 },
|
||||
{ id: 'iphone-16', name: 'iPhone 16', width: 393, height: 852 },
|
||||
{
|
||||
id: 'iphone-16-17-pro-max',
|
||||
name: 'iPhone 16 & 17 Pro Max',
|
||||
width: 440,
|
||||
height: 956
|
||||
},
|
||||
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', width: 430, height: 932 },
|
||||
{ id: 'iphone-air', name: 'iPhone Air', width: 420, height: 912 },
|
||||
{
|
||||
id: 'iphone-14-15-pro-max',
|
||||
name: 'iPhone 14 & 15 Pro Max',
|
||||
width: 430,
|
||||
height: 932
|
||||
},
|
||||
{ id: 'iphone-14-15-pro', name: 'iPhone 14 & 15 Pro', width: 393, height: 852 },
|
||||
{ id: 'iphone-13-14', name: 'iPhone 13 & 14', width: 390, height: 844 },
|
||||
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', width: 428, height: 926 },
|
||||
{ id: 'android-compact', name: 'Android Compact', width: 412, height: 917 },
|
||||
{ id: 'android-medium', name: 'Android Medium', width: 700, height: 840 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tablet',
|
||||
labelKey: 'framePresetCategoryTablet',
|
||||
presets: [
|
||||
{ id: 'ipad-mini-8-3', name: 'iPad mini 8.3', width: 744, height: 1133 },
|
||||
{ id: 'surface-pro-8', name: 'Surface Pro 8', width: 1440, height: 960 },
|
||||
{ id: 'ipad-pro-11', name: 'iPad Pro 11"', width: 834, height: 1194 },
|
||||
{ id: 'ipad-pro-12-9', name: 'iPad Pro 12.9"', width: 1024, height: 1366 },
|
||||
{ id: 'android-expanded', name: 'Android Expanded', width: 1280, height: 800 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
labelKey: 'framePresetCategoryDesktop',
|
||||
presets: [
|
||||
{ id: 'macbook-air', name: 'MacBook Air', width: 1280, height: 832 },
|
||||
{ id: 'macbook-pro-14', name: 'MacBook Pro 14"', width: 1512, height: 982 },
|
||||
{ id: 'macbook-pro-16', name: 'MacBook Pro 16"', width: 1728, height: 1117 },
|
||||
{ id: 'desktop', name: 'Desktop', width: 1440, height: 1024 },
|
||||
{ id: 'wireframe', name: 'Wireframe', width: 1440, height: 1024 },
|
||||
{ id: 'tv', name: 'TV', width: 1280, height: 720 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'presentation',
|
||||
labelKey: 'framePresetCategoryPresentation',
|
||||
presets: [
|
||||
{ id: 'slide-16-9', name: 'Slide 16:9', width: 1920, height: 1080 },
|
||||
{ id: 'slide-4-3', name: 'Slide 4:3', width: 1024, height: 768 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'watch',
|
||||
labelKey: 'framePresetCategoryWatch',
|
||||
presets: [
|
||||
{
|
||||
id: 'apple-watch-series-10-42',
|
||||
name: 'Apple Watch Series 10 42mm',
|
||||
width: 187,
|
||||
height: 223
|
||||
},
|
||||
{
|
||||
id: 'apple-watch-series-10-46',
|
||||
name: 'Apple Watch Series 10 46mm',
|
||||
width: 208,
|
||||
height: 248
|
||||
},
|
||||
{ id: 'apple-watch-41', name: 'Apple Watch 41mm', width: 176, height: 215 },
|
||||
{ id: 'apple-watch-45', name: 'Apple Watch 45mm', width: 198, height: 242 },
|
||||
{ id: 'apple-watch-44', name: 'Apple Watch 44mm', width: 184, height: 224 },
|
||||
{ id: 'apple-watch-40', name: 'Apple Watch 40mm', width: 162, height: 197 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'paper',
|
||||
labelKey: 'framePresetCategoryPaper',
|
||||
presets: [
|
||||
{ id: 'a4', name: 'A4', width: 595, height: 842 },
|
||||
{ id: 'a5', name: 'A5', width: 420, height: 595 },
|
||||
{ id: 'a6', name: 'A6', width: 297, height: 420 },
|
||||
{ id: 'letter', name: 'Letter', width: 612, height: 792 },
|
||||
{ id: 'tabloid', name: 'Tabloid', width: 792, height: 1224 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'social-media',
|
||||
labelKey: 'framePresetCategorySocialMedia',
|
||||
presets: [
|
||||
{ id: 'twitter-post', name: 'Twitter post', width: 1200, height: 675 },
|
||||
{ id: 'twitter-header', name: 'Twitter header', width: 1500, height: 500 },
|
||||
{ id: 'facebook-post', name: 'Facebook post', width: 1200, height: 630 },
|
||||
{ id: 'facebook-cover', name: 'Facebook cover', width: 820, height: 312 },
|
||||
{ id: 'instagram-post', name: 'Instagram post', width: 1080, height: 1350 },
|
||||
{ id: 'instagram-story', name: 'Instagram story', width: 1080, height: 1920 },
|
||||
{ id: 'dribbble-shot', name: 'Dribbble shot', width: 400, height: 300 },
|
||||
{ id: 'dribbble-shot-hd', name: 'Dribbble shot HD', width: 800, height: 600 },
|
||||
{ id: 'linkedin-cover', name: 'LinkedIn cover', width: 1584, height: 396 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'figma-community',
|
||||
labelKey: 'framePresetCategoryFigmaCommunity',
|
||||
presets: [
|
||||
{ id: 'plugin-icon', name: 'Plugin icon', width: 128, height: 128 },
|
||||
{ id: 'profile-banner', name: 'Profile banner', width: 1680, height: 240 },
|
||||
{ id: 'plugin-file-cover', name: 'Plugin / file cover', width: 1920, height: 1080 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
labelKey: 'framePresetCategoryArchive',
|
||||
presets: [
|
||||
{ id: 'iphone-13-mini', name: 'iPhone 13 mini', width: 375, height: 812 },
|
||||
{ id: 'iphone-se', name: 'iPhone SE', width: 320, height: 568 },
|
||||
{ id: 'iphone-13-pro-max', name: 'iPhone 13 Pro Max', width: 428, height: 926 },
|
||||
{ id: 'iphone-13-pro', name: 'iPhone 13 / 13 Pro', width: 390, height: 844 },
|
||||
{ id: 'iphone-11-pro-max', name: 'iPhone 11 Pro Max', width: 414, height: 896 },
|
||||
{ id: 'iphone-11-pro-x', name: 'iPhone 11 Pro / X', width: 375, height: 812 },
|
||||
{ id: 'iphone-8-plus', name: 'iPhone 8 Plus', width: 414, height: 736 },
|
||||
{ id: 'iphone-8', name: 'iPhone 8', width: 375, height: 667 },
|
||||
{ id: 'android-small', name: 'Android Small', width: 360, height: 640 },
|
||||
{ id: 'android-large', name: 'Android Large', width: 360, height: 800 },
|
||||
{ id: 'google-pixel-2', name: 'Google Pixel 2', width: 411, height: 731 },
|
||||
{ id: 'google-pixel-2-xl', name: 'Google Pixel 2 XL', width: 411, height: 823 },
|
||||
{ id: 'ipad-mini-5', name: 'iPad mini 5', width: 768, height: 1024 },
|
||||
{ id: 'surface-pro-4', name: 'Surface Pro 4', width: 1368, height: 912 },
|
||||
{ id: 'macbook', name: 'MacBook', width: 1152, height: 700 },
|
||||
{ id: 'macbook-pro', name: 'MacBook Pro', width: 1440, height: 900 },
|
||||
{ id: 'surface-book', name: 'Surface Book', width: 1500, height: 1000 },
|
||||
{ id: 'apple-watch-42', name: 'Apple Watch 42mm', width: 156, height: 195 },
|
||||
{ id: 'apple-watch-38', name: 'Apple Watch 38mm', width: 136, height: 170 },
|
||||
{ id: 'imac', name: 'iMac', width: 1280, height: 720 },
|
||||
{ id: 'macintosh-128k', name: 'Macintosh 128k', width: 512, height: 342 }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const FRAME_PRESETS = FRAME_PRESET_CATEGORIES.flatMap((category) => category.presets)
|
||||
|
||||
function uniquePresetCategoriesBySize(
|
||||
categories: readonly FramePresetCategory[]
|
||||
): readonly FramePresetCategory[] {
|
||||
const seenSizes = new Set<string>()
|
||||
return categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
presets: category.presets.filter((preset) => {
|
||||
const size = `${preset.width}x${preset.height}`
|
||||
if (seenSizes.has(size)) return false
|
||||
seenSizes.add(size)
|
||||
return true
|
||||
})
|
||||
}))
|
||||
.filter((category) => category.presets.length > 0)
|
||||
}
|
||||
|
||||
export const FRAME_RESIZE_PRESET_CATEGORIES = uniquePresetCategoriesBySize(FRAME_PRESET_CATEGORIES)
|
||||
export const FRAME_RESIZE_PRESETS = FRAME_RESIZE_PRESET_CATEGORIES.flatMap(
|
||||
(category) => category.presets
|
||||
)
|
||||
|
||||
function findPreset(
|
||||
presets: readonly FramePreset[],
|
||||
width: number,
|
||||
height: number,
|
||||
preferredName?: string
|
||||
): FramePreset | undefined {
|
||||
const matches = presets.filter((preset) => preset.width === width && preset.height === height)
|
||||
return matches.find((preset) => preset.name === preferredName) ?? matches[0]
|
||||
}
|
||||
|
||||
export function findFrameResizePreset(
|
||||
width: number,
|
||||
height: number,
|
||||
preferredName?: string
|
||||
): FramePreset | undefined {
|
||||
return findPreset(FRAME_RESIZE_PRESETS, width, height, preferredName)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|||
|
||||
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { COMPONENT_TYPES, nodeIcon } from '@/app/editor/icons'
|
||||
import PanelHeader from '@/components/ui/panel/PanelHeader.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
|
@ -22,8 +23,12 @@ import StrokeSection from './properties/StrokeSection.vue'
|
|||
import TypographySection from './properties/TypographySection.vue'
|
||||
import VariablesSection from './properties/VariablesSection.vue'
|
||||
import ComponentPropertiesSection from './properties/component-properties/ComponentPropertiesSection.vue'
|
||||
import FramePresetsSection from './properties/frame-presets/FramePresetsSection.vue'
|
||||
import FramePresetSelect from './properties/frame-presets/FramePresetSelect.vue'
|
||||
|
||||
const variablesOpen = ref(false)
|
||||
const store = useEditorStore()
|
||||
const activeTool = computed(() => store.state.activeTool)
|
||||
const { selectedNode: node, selectedCount: multiCount } = useSelectionState()
|
||||
const showBooleanOperations = computed(() => multiCount.value >= 2)
|
||||
const { getCommand } = useEditorCommands()
|
||||
|
|
@ -38,9 +43,17 @@ const { panels } = useI18n()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Frame tool presets replace selection properties, matching Figma. -->
|
||||
<div
|
||||
v-if="activeTool === 'FRAME'"
|
||||
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
|
||||
>
|
||||
<FramePresetsSection />
|
||||
</div>
|
||||
|
||||
<!-- Multi-select summary -->
|
||||
<div
|
||||
v-if="multiCount > 1"
|
||||
v-else-if="multiCount > 1"
|
||||
data-test-id="design-panel-multi"
|
||||
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
|
||||
>
|
||||
|
|
@ -108,6 +121,8 @@ const { panels } = useI18n()
|
|||
|
||||
<ComponentPropertiesSection v-if="node.type === 'INSTANCE'" />
|
||||
|
||||
<FramePresetSelect v-if="node.type === 'FRAME'" />
|
||||
|
||||
<PositionSection />
|
||||
<ConstraintsSection />
|
||||
<LayoutSection />
|
||||
|
|
|
|||
|
|
@ -2,32 +2,31 @@
|
|||
import Tip from '@/components/ui/Tip.vue'
|
||||
import ToolButton from '@/components/Toolbar/ToolButton.vue'
|
||||
import ToolFlyout from '@/components/Toolbar/ToolFlyout.vue'
|
||||
import { toolbarToolTestId, ToolbarItem } from '@open-pencil/vue'
|
||||
import {
|
||||
getToolbarToolSelection,
|
||||
isToolbarToolActive,
|
||||
toolbarToolTestId,
|
||||
ToolbarItem
|
||||
} from '@open-pencil/vue'
|
||||
|
||||
import type { Tool } from '@open-pencil/vue'
|
||||
import type { EditorToolDef } from '@open-pencil/core/editor'
|
||||
import type { ToolbarUI, ToolIconMap, ToolLabels } from '@/components/Toolbar/types'
|
||||
|
||||
const { tools, activeTool, toolIcons, toolLabels, toolShortcuts, ui } = defineProps<{
|
||||
tools: EditorToolDef[]
|
||||
activeTool: Tool
|
||||
toolIcons: ToolIconMap
|
||||
toolLabels: ToolLabels
|
||||
toolShortcuts: Record<Tool, string>
|
||||
ui?: ToolbarUI
|
||||
}>()
|
||||
const { tools, activeTool, flyoutSelections, toolIcons, toolLabels, toolShortcuts, ui } =
|
||||
defineProps<{
|
||||
tools: EditorToolDef[]
|
||||
activeTool: Tool
|
||||
flyoutSelections: ReadonlyMap<Tool, Tool>
|
||||
toolIcons: ToolIconMap
|
||||
toolLabels: ToolLabels
|
||||
toolShortcuts: Record<Tool, string>
|
||||
ui?: ToolbarUI
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
setTool: [tool: Tool]
|
||||
}>()
|
||||
|
||||
function isActive(tool: EditorToolDef) {
|
||||
return tool.key === activeTool || (tool.flyout?.includes(activeTool) ?? false)
|
||||
}
|
||||
|
||||
function activeKeyForTool(tool: EditorToolDef) {
|
||||
return tool.flyout?.includes(activeTool) ? activeTool : tool.key
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -39,11 +38,12 @@ function activeKeyForTool(tool: EditorToolDef) {
|
|||
<template v-for="tool in tools" :key="tool.key">
|
||||
<Tip
|
||||
v-if="tool.flyout && tool.flyout.length > 1"
|
||||
:label="`${toolLabels[activeKeyForTool(tool)]} (${tool.shortcut})`"
|
||||
:label="`${toolLabels[getToolbarToolSelection(tool, activeTool, flyoutSelections)]} (${tool.shortcut})`"
|
||||
>
|
||||
<ToolFlyout
|
||||
:tool="tool"
|
||||
:active-tool="activeTool"
|
||||
:selected-tool="getToolbarToolSelection(tool, activeTool, flyoutSelections)"
|
||||
:tool-icons="toolIcons"
|
||||
:tool-labels="toolLabels"
|
||||
:tool-shortcuts="toolShortcuts"
|
||||
|
|
@ -58,7 +58,7 @@ function activeKeyForTool(tool: EditorToolDef) {
|
|||
:data-test-id="toolbarToolTestId(tool.key)"
|
||||
:icon="toolIcons[tool.key]"
|
||||
:label="toolLabels[tool.key]"
|
||||
:active="active || isActive(tool)"
|
||||
:active="active || isToolbarToolActive(tool, activeTool)"
|
||||
:ui="ui"
|
||||
@click="actions.select"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import ToolButton from '@/components/Toolbar/ToolButton.vue'
|
|||
import ToolFlyout from '@/components/Toolbar/ToolFlyout.vue'
|
||||
import ToolbarActionGroup from '@/components/Toolbar/ToolbarActionGroup.vue'
|
||||
import toolbarTheme from '@/theme/toolbar'
|
||||
import { toolbarToolTestId, ToolbarItem } from '@open-pencil/vue'
|
||||
import { getToolbarToolSelection, toolbarToolTestId, ToolbarItem } from '@open-pencil/vue'
|
||||
|
||||
import type { Tool } from '@open-pencil/vue'
|
||||
import type { EditorToolDef } from '@open-pencil/core/editor'
|
||||
|
|
@ -23,6 +23,7 @@ import type {
|
|||
const {
|
||||
tools,
|
||||
activeTool,
|
||||
flyoutSelections,
|
||||
toolIcons,
|
||||
toolLabels,
|
||||
toolShortcuts,
|
||||
|
|
@ -36,6 +37,7 @@ const {
|
|||
} = defineProps<{
|
||||
tools: EditorToolDef[]
|
||||
activeTool: Tool
|
||||
flyoutSelections: ReadonlyMap<Tool, Tool>
|
||||
toolIcons: ToolIconMap
|
||||
toolLabels: ToolLabels
|
||||
toolShortcuts: Record<Tool, string>
|
||||
|
|
@ -64,10 +66,6 @@ const slideVariants = {
|
|||
exit: (dir: unknown) => ({ opacity: 0, x: (dir as number) * -20 })
|
||||
}
|
||||
|
||||
function activeKeyForTool(tool: EditorToolDef) {
|
||||
return tool.flyout?.includes(activeTool) ? activeTool : tool.key
|
||||
}
|
||||
|
||||
function navigationClass(disabled: boolean) {
|
||||
return toolbar({ disabled }).navigationAction({ class: ui?.navigationAction })
|
||||
}
|
||||
|
|
@ -118,6 +116,7 @@ function navigationClass(disabled: boolean) {
|
|||
mobile
|
||||
:tool="tool"
|
||||
:active-tool="activeTool"
|
||||
:selected-tool="getToolbarToolSelection(tool, activeTool, flyoutSelections)"
|
||||
:tool-icons="toolIcons"
|
||||
:tool-labels="toolLabels"
|
||||
:tool-shortcuts="toolShortcuts"
|
||||
|
|
@ -130,7 +129,10 @@ function navigationClass(disabled: boolean) {
|
|||
mobile
|
||||
:data-test-id="toolbarToolTestId(tool.key, true)"
|
||||
:icon="toolIcons[tool.key]"
|
||||
:active="active || activeKeyForTool(tool) === activeTool"
|
||||
:active="
|
||||
active ||
|
||||
getToolbarToolSelection(tool, activeTool, flyoutSelections) === activeTool
|
||||
"
|
||||
:ui="ui"
|
||||
@click="actions.select"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { computed } from 'vue'
|
|||
import { tv } from 'tailwind-variants'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
|
|
@ -16,11 +18,11 @@ import { menu } from '@/components/ui/menu'
|
|||
import toolbarTheme from '@/theme/toolbar'
|
||||
import ToolButton from '@/components/Toolbar/ToolButton.vue'
|
||||
import {
|
||||
isToolbarToolActive,
|
||||
toolbarFlyoutItemTestId,
|
||||
toolbarFlyoutTestId,
|
||||
toolbarToolTestId,
|
||||
vTestId,
|
||||
ToolbarItem
|
||||
vTestId
|
||||
} from '@open-pencil/vue'
|
||||
|
||||
import type { Tool } from '@open-pencil/vue'
|
||||
|
|
@ -30,6 +32,7 @@ import type { ToolbarUI, ToolIconMap, ToolLabels } from '@/components/Toolbar/ty
|
|||
const {
|
||||
tool,
|
||||
activeTool,
|
||||
selectedTool,
|
||||
toolIcons,
|
||||
toolLabels,
|
||||
toolShortcuts,
|
||||
|
|
@ -38,6 +41,7 @@ const {
|
|||
} = defineProps<{
|
||||
tool: EditorToolDef
|
||||
activeTool: Tool
|
||||
selectedTool: Tool
|
||||
toolIcons: ToolIconMap
|
||||
toolLabels: ToolLabels
|
||||
toolShortcuts: Record<Tool, string>
|
||||
|
|
@ -46,7 +50,7 @@ const {
|
|||
}>()
|
||||
|
||||
const toolbar = tv(toolbarTheme)
|
||||
const triggerActive = computed(() => isActiveTool(activeKeyForTool()))
|
||||
const triggerActive = computed(() => isToolbarToolActive(tool, activeTool))
|
||||
const styles = computed(() => toolbar({ active: triggerActive.value, mobile }))
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -57,32 +61,24 @@ defineSlots<{
|
|||
default(props: { label: string }): unknown
|
||||
}>()
|
||||
|
||||
function isActiveTool(key: Tool) {
|
||||
return (
|
||||
tool.key === activeTool || (tool.flyout?.includes(activeTool) ?? false) || key === activeTool
|
||||
)
|
||||
}
|
||||
|
||||
function activeKeyForTool() {
|
||||
return tool.flyout?.includes(activeTool) ? activeTool : tool.key
|
||||
}
|
||||
|
||||
function flyoutItemClass(subActive: boolean) {
|
||||
return menu().item({ class: toolbar({ subActive }).flyoutItem({ class: ui?.flyoutItem }) })
|
||||
function flyoutItemClass() {
|
||||
return menu({ justify: 'start' }).item({
|
||||
class: toolbar().flyoutItem({ class: ui?.flyoutItem })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="styles.flyoutGroup({ class: ui?.flyoutGroup })">
|
||||
<slot :label="`${toolLabels[activeKeyForTool()]} (${tool.shortcut})`">
|
||||
<slot :label="`${toolLabels[selectedTool]} (${tool.shortcut})`">
|
||||
<ToolButton
|
||||
:data-test-id="toolbarToolTestId(activeKeyForTool(), mobile)"
|
||||
:icon="toolIcons[activeKeyForTool()]"
|
||||
:label="toolLabels[activeKeyForTool()]"
|
||||
:data-test-id="toolbarToolTestId(selectedTool, mobile)"
|
||||
:icon="toolIcons[selectedTool]"
|
||||
:label="toolLabels[selectedTool]"
|
||||
:active="triggerActive"
|
||||
:mobile="mobile"
|
||||
:ui="ui"
|
||||
@click="emit('select', activeKeyForTool())"
|
||||
@click="emit('select', selectedTool)"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
|
|
@ -90,7 +86,6 @@ function flyoutItemClass(subActive: boolean) {
|
|||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
v-test-id="toolbarFlyoutTestId(tool.key, mobile)"
|
||||
:data-active="triggerActive || undefined"
|
||||
:data-mobile="mobile || undefined"
|
||||
:aria-label="`${toolLabels[tool.key]} options`"
|
||||
:class="styles.flyoutTrigger({ class: ui?.flyoutTrigger })"
|
||||
|
|
@ -106,18 +101,25 @@ function flyoutItemClass(subActive: boolean) {
|
|||
align="start"
|
||||
:class="styles.flyoutContent({ class: ui?.flyoutContent })"
|
||||
>
|
||||
<ToolbarItem
|
||||
v-for="sub in tool.flyout"
|
||||
:key="sub"
|
||||
v-slot="{ active: subActive, actions }"
|
||||
:tool="sub"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuRadioGroup :model-value="selectedTool">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="sub in tool.flyout"
|
||||
:key="sub"
|
||||
v-test-id="toolbarFlyoutItemTestId(sub, mobile)"
|
||||
:data-active="subActive || undefined"
|
||||
:class="flyoutItemClass(subActive)"
|
||||
@select="actions.select"
|
||||
:value="sub"
|
||||
:data-active="sub === selectedTool || undefined"
|
||||
:class="flyoutItemClass()"
|
||||
@select="emit('select', sub)"
|
||||
>
|
||||
<span
|
||||
data-slot="flyout-item-indicator"
|
||||
:class="styles.flyoutItemIndicator({ class: ui?.flyoutItemIndicator })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<icon-lucide-check class="size-3.5" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<component
|
||||
:is="toolIcons[sub]"
|
||||
:class="styles.flyoutItemIcon({ class: ui?.flyoutItemIcon })"
|
||||
|
|
@ -128,8 +130,8 @@ function flyoutItemClass(subActive: boolean) {
|
|||
<AppShortcutText v-if="!mobile && toolShortcuts[sub]">
|
||||
{{ toolShortcuts[sub] }}
|
||||
</AppShortcutText>
|
||||
</DropdownMenuItem>
|
||||
</ToolbarItem>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
|
|
|
|||
|
|
@ -66,11 +66,12 @@ function onActionTap(item: ToolbarActionItem) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<ToolbarRoot v-slot="{ tools, activeTool, actions }">
|
||||
<ToolbarRoot v-slot="{ tools, activeTool, flyoutSelections, actions }">
|
||||
<DesktopToolbar
|
||||
v-if="!isMobile"
|
||||
:tools="tools"
|
||||
:active-tool="activeTool"
|
||||
:flyout-selections="flyoutSelections"
|
||||
:tool-icons="toolIcons"
|
||||
:tool-labels="toolLabels"
|
||||
:tool-shortcuts="toolShortcuts"
|
||||
|
|
@ -82,6 +83,7 @@ function onActionTap(item: ToolbarActionItem) {
|
|||
v-else
|
||||
:tools="tools"
|
||||
:active-tool="activeTool"
|
||||
:flyout-selections="flyoutSelections"
|
||||
:tool-icons="toolIcons"
|
||||
:tool-labels="toolLabels"
|
||||
:tool-shortcuts="toolShortcuts"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useI18n, useSelectionState } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import {
|
||||
findFrameResizePreset,
|
||||
FRAME_RESIZE_PRESET_CATEGORIES,
|
||||
FRAME_RESIZE_PRESETS
|
||||
} from '@/app/editor/frame-presets'
|
||||
import AppGroupedSelect from '@/components/ui/AppGroupedSelect.vue'
|
||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||
|
||||
const store = useEditorStore()
|
||||
const { selectedNode } = useSelectionState()
|
||||
const { panels } = useI18n()
|
||||
|
||||
const selectedPreset = computed(() => {
|
||||
const node = selectedNode.value
|
||||
return node ? findFrameResizePreset(node.width, node.height, node.name) : undefined
|
||||
})
|
||||
const selectedPresetId = computed({
|
||||
get: () => selectedPreset.value?.id ?? 'custom',
|
||||
set: (id: string) => {
|
||||
const node = selectedNode.value
|
||||
const preset = FRAME_RESIZE_PRESETS.find((candidate) => candidate.id === id)
|
||||
if (node?.type === 'FRAME' && preset) store.resizeFrameToPreset(node.id, preset)
|
||||
}
|
||||
})
|
||||
const groups = computed(() =>
|
||||
FRAME_RESIZE_PRESET_CATEGORIES.map((category) => ({
|
||||
label: panels.value[category.labelKey],
|
||||
items: category.presets.map((preset) => ({ value: preset.id, label: preset.name }))
|
||||
}))
|
||||
)
|
||||
const displayValue = computed(() => selectedPreset.value?.name ?? panels.value.framePresetCustom)
|
||||
const selectUI = {
|
||||
content: 'max-h-80',
|
||||
viewport: 'max-h-80'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PanelSection :label="panels.frame">
|
||||
<AppGroupedSelect
|
||||
v-model="selectedPresetId"
|
||||
data-property="frame-preset"
|
||||
:aria-label="panels.framePreset"
|
||||
:groups="groups"
|
||||
:display-value="displayValue"
|
||||
:ui="selectUI"
|
||||
/>
|
||||
</PanelSection>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
|
||||
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { FRAME_PRESET_CATEGORIES, type FramePreset } from '@/app/editor/frame-presets'
|
||||
|
||||
const store = useEditorStore()
|
||||
const { panels } = useI18n()
|
||||
|
||||
function createFrame(preset: FramePreset) {
|
||||
store.createFrameFromPreset(preset)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :aria-label="panels.frame">
|
||||
<div class="flex h-10 items-center border-b border-border px-3">
|
||||
<span role="heading" aria-level="2" class="text-[11px] font-semibold text-surface">
|
||||
{{ panels.frame }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CollapsibleRoot
|
||||
v-for="category in FRAME_PRESET_CATEGORIES"
|
||||
:key="category.id"
|
||||
v-slot="{ open }"
|
||||
:default-open="category.id === 'phone'"
|
||||
class="border-b border-border"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
class="flex h-9 w-full items-center gap-1.5 px-3 text-left text-[11px] text-surface hover:bg-hover"
|
||||
>
|
||||
<icon-lucide-chevron-right
|
||||
class="size-3 shrink-0 transition-transform data-[open]:rotate-90"
|
||||
:data-open="open || undefined"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ panels[category.labelKey] }}</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent class="pb-1.5">
|
||||
<button
|
||||
v-for="preset in category.presets"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
:data-frame-preset="preset.id"
|
||||
class="flex h-7 w-full items-center gap-2 px-7 text-left text-[11px] text-surface hover:bg-hover"
|
||||
@click="createFrame(preset)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ preset.name }}</span>
|
||||
<span class="shrink-0 tabular-nums text-muted">
|
||||
{{ preset.width }} × {{ preset.height }}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -5,10 +5,11 @@ const toolbarTheme = {
|
|||
icon: 'size-4',
|
||||
flyoutGroup: 'flex items-center',
|
||||
flyoutTrigger:
|
||||
'flex h-8 w-3 cursor-pointer items-center justify-center border-none bg-transparent text-muted transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
|
||||
'flex h-8 w-3 cursor-pointer items-center justify-center border-none bg-transparent text-muted transition-colors outline-none data-[state=open]:bg-hover data-[state=open]:text-surface focus-visible:ring-1 focus-visible:ring-accent',
|
||||
flyoutTriggerIcon: 'size-2.5',
|
||||
flyoutContent: '',
|
||||
flyoutItem: '',
|
||||
flyoutItemIndicator: 'flex size-3.5 shrink-0 items-center justify-center',
|
||||
flyoutItemIcon: 'size-3.5',
|
||||
flyoutItemLabel: 'flex-1',
|
||||
navigationAction:
|
||||
|
|
@ -21,19 +22,18 @@ const toolbarTheme = {
|
|||
variants: {
|
||||
active: {
|
||||
true: {
|
||||
button: 'bg-accent text-white',
|
||||
flyoutTrigger: 'bg-accent text-white'
|
||||
button: 'bg-accent text-white'
|
||||
},
|
||||
false: {}
|
||||
},
|
||||
mobile: {
|
||||
true: {
|
||||
button: 'rounded-[6px] select-none',
|
||||
flyoutTrigger: 'rounded-[6px] select-none'
|
||||
flyoutTrigger: 'rounded-[6px] select-none active:bg-hover active:text-surface'
|
||||
},
|
||||
false: {
|
||||
button: 'rounded-lg',
|
||||
flyoutTrigger: 'rounded-lg'
|
||||
flyoutTrigger: 'rounded-lg hover:bg-hover hover:text-surface'
|
||||
}
|
||||
},
|
||||
disabled: {
|
||||
|
|
@ -41,12 +41,6 @@ const toolbarTheme = {
|
|||
navigationAction: 'pointer-events-none'
|
||||
},
|
||||
false: {}
|
||||
},
|
||||
subActive: {
|
||||
true: {
|
||||
flyoutItem: 'bg-accent text-white'
|
||||
},
|
||||
false: {}
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
|
|
@ -54,24 +48,21 @@ const toolbarTheme = {
|
|||
active: false,
|
||||
mobile: true,
|
||||
class: {
|
||||
button: 'active:bg-hover',
|
||||
flyoutTrigger: 'active:bg-hover'
|
||||
button: 'active:bg-hover'
|
||||
}
|
||||
},
|
||||
{
|
||||
active: false,
|
||||
mobile: false,
|
||||
class: {
|
||||
button: 'hover:bg-hover hover:text-surface',
|
||||
flyoutTrigger: 'hover:bg-hover hover:text-surface'
|
||||
button: 'hover:bg-hover hover:text-surface'
|
||||
}
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
active: false,
|
||||
mobile: false,
|
||||
disabled: false,
|
||||
subActive: false
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
69
tests/e2e/properties/frame-presets.spec.ts
Normal file
69
tests/e2e/properties/frame-presets.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { getSelectedNode } from '#tests/helpers/store'
|
||||
import { toolbarToolTestId } from '#tests/helpers/test-ids'
|
||||
|
||||
const editor = useEditorSetup()
|
||||
|
||||
test('creates and resizes a frame with presets', async () => {
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('openPencil store not available')
|
||||
const id = store.createShape('RECTANGLE', 100, 100, 80, 80)
|
||||
store.select([id])
|
||||
})
|
||||
|
||||
await editor.canvas.pressKey('f')
|
||||
await expect(editor.page.getByRole('region', { name: 'Frame' })).toBeVisible()
|
||||
await expect(editor.page.getByRole('button', { name: 'iPhone Air 420 × 912' })).toBeVisible()
|
||||
await expect(editor.page.getByRole('button', { name: 'iPad mini 8.3 744 × 1133' })).toBeHidden()
|
||||
|
||||
await editor.page.getByRole('button', { name: 'Tablet', exact: true }).click()
|
||||
await expect(editor.page.getByRole('button', { name: 'iPad mini 8.3 744 × 1133' })).toBeVisible()
|
||||
|
||||
await editor.page.getByRole('button', { name: 'iPhone Air 420 × 912' }).click()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
let frame = await getSelectedNode(editor.page)
|
||||
expect(frame).toMatchObject({ type: 'FRAME', name: 'iPhone Air', width: 420, height: 912 })
|
||||
await expect(editor.page.getByTestId(toolbarToolTestId('SELECT'))).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
|
||||
await editor.canvas.undo()
|
||||
expect(await getSelectedNode(editor.page)).toMatchObject({ type: 'RECTANGLE' })
|
||||
|
||||
await editor.canvas.redo()
|
||||
expect(await getSelectedNode(editor.page)).toMatchObject({
|
||||
type: 'FRAME',
|
||||
name: 'iPhone Air',
|
||||
width: 420,
|
||||
height: 912
|
||||
})
|
||||
|
||||
const framePreset = editor.page.getByRole('combobox', { name: 'Frame preset' })
|
||||
await framePreset.click()
|
||||
await expect(editor.page.getByRole('option', { name: 'Wireframe', exact: true })).toHaveCount(0)
|
||||
await editor.page.getByRole('option', { name: 'Desktop', exact: true }).click()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
frame = await getSelectedNode(editor.page)
|
||||
expect(frame).toMatchObject({ type: 'FRAME', name: 'iPhone Air', width: 1440, height: 1024 })
|
||||
await expect(framePreset).toContainText('Desktop')
|
||||
|
||||
await editor.canvas.undo()
|
||||
expect(await getSelectedNode(editor.page)).toMatchObject({
|
||||
type: 'FRAME',
|
||||
name: 'iPhone Air',
|
||||
width: 420,
|
||||
height: 912
|
||||
})
|
||||
|
||||
await editor.canvas.redo()
|
||||
expect(await getSelectedNode(editor.page)).toMatchObject({
|
||||
type: 'FRAME',
|
||||
name: 'iPhone Air',
|
||||
width: 1440,
|
||||
height: 1024
|
||||
})
|
||||
})
|
||||
|
|
@ -10,7 +10,16 @@ const editor = useEditorSetup()
|
|||
|
||||
test('shapes flyout opens', async () => {
|
||||
await editor.page.getByTestId(toolbarFlyoutTestId('RECTANGLE')).click()
|
||||
await expect(editor.page.getByTestId(toolbarFlyoutItemTestId('POLYGON'))).toBeVisible()
|
||||
const rectangleItem = editor.page.getByTestId(toolbarFlyoutItemTestId('RECTANGLE'))
|
||||
const polygonItem = editor.page.getByTestId(toolbarFlyoutItemTestId('POLYGON'))
|
||||
|
||||
await expect(polygonItem).toBeVisible()
|
||||
await expect(rectangleItem).toHaveAttribute('data-active', 'true')
|
||||
await expect(rectangleItem).toHaveAttribute('role', 'menuitemradio')
|
||||
await expect(rectangleItem).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(rectangleItem.locator('[data-slot="flyout-item-indicator"] svg')).toHaveCount(1)
|
||||
await expect(polygonItem).not.toHaveAttribute('data-active', 'true')
|
||||
await expect(polygonItem).toHaveAttribute('aria-checked', 'false')
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
|
|
@ -39,6 +48,29 @@ test('Star tool creates STAR node', async () => {
|
|||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
test('shape flyout remembers its selection independently of the active tool', async () => {
|
||||
await editor.canvas.pressKey('f')
|
||||
await expect(editor.page.getByTestId(toolbarToolTestId('STAR'))).not.toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
|
||||
await editor.page.getByTestId(toolbarFlyoutTestId('RECTANGLE')).click()
|
||||
const starItem = editor.page.getByTestId(toolbarFlyoutItemTestId('STAR'))
|
||||
const rectangleItem = editor.page.getByTestId(toolbarFlyoutItemTestId('RECTANGLE'))
|
||||
|
||||
await expect(starItem).toHaveAttribute('data-active', 'true')
|
||||
await expect(starItem).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(starItem.locator('[data-slot="flyout-item-indicator"] svg')).toHaveCount(1)
|
||||
await expect(rectangleItem).not.toHaveAttribute('data-active', 'true')
|
||||
await expect(rectangleItem).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await rectangleItem.hover()
|
||||
await expect(rectangleItem).toHaveAttribute('data-highlighted', '')
|
||||
await expect(starItem).toHaveAttribute('data-active', 'true')
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
test('Pen creates VECTOR node with 3 vertices on Enter', async () => {
|
||||
await editor.canvas.pressKey('Escape')
|
||||
await editor.canvas.pressKey('p')
|
||||
|
|
@ -98,8 +130,28 @@ test('Pen close path creates VECTOR with closed region', async () => {
|
|||
})
|
||||
|
||||
test('Frame flyout shows Frame and Section items', async () => {
|
||||
await editor.page.getByTestId(toolbarFlyoutTestId('FRAME')).click()
|
||||
await expect(editor.page.getByTestId(toolbarFlyoutItemTestId('FRAME'))).toBeVisible()
|
||||
await expect(editor.page.getByTestId(toolbarFlyoutItemTestId('SECTION'))).toBeVisible()
|
||||
await editor.canvas.pressKey('f')
|
||||
const frameButton = editor.page.getByTestId(toolbarToolTestId('FRAME'))
|
||||
const frameOptions = editor.page.getByTestId(toolbarFlyoutTestId('FRAME'))
|
||||
|
||||
await expect(frameButton).toHaveAttribute('data-active', 'true')
|
||||
await expect(frameOptions).not.toHaveAttribute('data-active', 'true')
|
||||
await expect(frameOptions).toHaveAttribute('data-state', 'closed')
|
||||
|
||||
await frameOptions.click()
|
||||
await expect(frameOptions).toHaveAttribute('data-state', 'open')
|
||||
await expect(frameOptions).not.toHaveAttribute('data-active', 'true')
|
||||
const frameItem = editor.page.getByTestId(toolbarFlyoutItemTestId('FRAME'))
|
||||
await expect(frameItem).toBeVisible()
|
||||
await expect(frameItem).toHaveAttribute('data-active', 'true')
|
||||
await expect(frameItem.locator('[data-slot="flyout-item-indicator"] svg')).toHaveCount(1)
|
||||
const sectionItem = editor.page.getByTestId(toolbarFlyoutItemTestId('SECTION'))
|
||||
await expect(sectionItem).toBeVisible()
|
||||
await expect(sectionItem).not.toHaveAttribute('data-active', 'true')
|
||||
await expect(sectionItem.locator('[data-slot="flyout-item-indicator"] svg')).toHaveCount(0)
|
||||
|
||||
await sectionItem.hover()
|
||||
await expect(sectionItem).toHaveAttribute('data-highlighted', '')
|
||||
await expect(frameItem).toHaveAttribute('data-active', 'true')
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
|
|
|||
303
tests/engine/editor/frame-presets.test.ts
Normal file
303
tests/engine/editor/frame-presets.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
import { computeLayout } from '@open-pencil/core/layout'
|
||||
|
||||
import { getNodeOrThrow } from '#tests/helpers/assert'
|
||||
|
||||
const PHONE_PRESET = { name: 'iPhone Air', width: 420, height: 912 }
|
||||
const DESKTOP_PRESET = { name: 'Desktop', width: 1440, height: 1024 }
|
||||
|
||||
describe('frame presets', () => {
|
||||
test('creates a named frame at the viewport center and supports undo and redo', () => {
|
||||
const editor = createEditor({ getViewportSize: () => ({ width: 1000, height: 800 }) })
|
||||
editor.state.panX = 100
|
||||
editor.state.panY = 50
|
||||
editor.state.zoom = 2
|
||||
const previousId = editor.createShape('RECTANGLE', 10, 20, 30, 40)
|
||||
editor.select([previousId])
|
||||
editor.setTool('FRAME')
|
||||
|
||||
const id = editor.createFrameFromPreset(PHONE_PRESET)
|
||||
const frame = getNodeOrThrow(editor.graph, id)
|
||||
|
||||
expect(frame).toMatchObject({
|
||||
type: 'FRAME',
|
||||
name: 'iPhone Air',
|
||||
x: -10,
|
||||
y: -281,
|
||||
width: 420,
|
||||
height: 912,
|
||||
parentId: editor.state.currentPageId
|
||||
})
|
||||
expect(editor.state.selectedIds).toEqual(new Set([id]))
|
||||
expect(editor.state.activeTool).toBe('SELECT')
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(id)).toBeUndefined()
|
||||
expect(editor.state.selectedIds).toEqual(new Set([previousId]))
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
name: 'iPhone Air',
|
||||
width: 420,
|
||||
height: 912
|
||||
})
|
||||
expect(editor.state.selectedIds).toEqual(new Set([id]))
|
||||
})
|
||||
|
||||
test('resizes a frame without renaming it and supports undo and redo', () => {
|
||||
const editor = createEditor()
|
||||
const id = editor.createFrameFromPreset(PHONE_PRESET)
|
||||
editor.graph.updateNode(id, {
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'HUG',
|
||||
layoutGrow: 1,
|
||||
layoutAlignSelf: 'STRETCH'
|
||||
})
|
||||
const nestedId = editor.graph.createNode('FRAME', id, {
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 100,
|
||||
height: 80,
|
||||
horizontalConstraint: 'STRETCH'
|
||||
}).id
|
||||
const grandchildId = editor.graph.createNode('RECTANGLE', nestedId, {
|
||||
x: 10,
|
||||
y: 15,
|
||||
width: 20,
|
||||
height: 25,
|
||||
horizontalConstraint: 'MAX'
|
||||
}).id
|
||||
|
||||
editor.resizeFrameToPreset(id, DESKTOP_PRESET)
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
name: 'iPhone Air',
|
||||
width: 1440,
|
||||
height: 1024,
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
layoutGrow: 0,
|
||||
layoutAlignSelf: 'MIN'
|
||||
})
|
||||
expect(getNodeOrThrow(editor.graph, nestedId)).toMatchObject({ x: 20, width: 1120 })
|
||||
expect(getNodeOrThrow(editor.graph, grandchildId)).toMatchObject({ x: 1030, width: 20 })
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
name: 'iPhone Air',
|
||||
width: 420,
|
||||
height: 912,
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'HUG',
|
||||
layoutGrow: 1,
|
||||
layoutAlignSelf: 'STRETCH'
|
||||
})
|
||||
expect(getNodeOrThrow(editor.graph, nestedId)).toMatchObject({ x: 20, width: 100 })
|
||||
expect(getNodeOrThrow(editor.graph, grandchildId)).toMatchObject({ x: 10, width: 20 })
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
name: 'iPhone Air',
|
||||
width: 1440,
|
||||
height: 1024
|
||||
})
|
||||
expect(getNodeOrThrow(editor.graph, nestedId)).toMatchObject({ x: 20, width: 1120 })
|
||||
expect(getNodeOrThrow(editor.graph, grandchildId)).toMatchObject({ x: 1030, width: 20 })
|
||||
})
|
||||
|
||||
test('keeps exact preset dimensions inside a stretching auto-layout parent', () => {
|
||||
const editor = createEditor()
|
||||
const parentId = editor.graph.createNode('FRAME', editor.state.currentPageId, {
|
||||
width: 500,
|
||||
height: 500,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
counterAxisAlign: 'STRETCH'
|
||||
}).id
|
||||
const id = editor.graph.createNode('FRAME', parentId, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutAlignSelf: 'AUTO'
|
||||
}).id
|
||||
computeLayout(editor.graph, parentId)
|
||||
expect(getNodeOrThrow(editor.graph, id).height).toBe(500)
|
||||
|
||||
editor.resizeFrameToPreset(id, { name: 'Custom', width: 200, height: 300 })
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
width: 200,
|
||||
height: 300,
|
||||
layoutAlignSelf: 'MIN'
|
||||
})
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
width: 100,
|
||||
height: 500,
|
||||
layoutAlignSelf: 'AUTO'
|
||||
})
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
width: 200,
|
||||
height: 300,
|
||||
layoutAlignSelf: 'MIN'
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps exact preset dimensions for an auto-layout frame inside a grid', () => {
|
||||
const editor = createEditor()
|
||||
const parentId = editor.graph.createNode('FRAME', editor.state.currentPageId, {
|
||||
width: 500,
|
||||
height: 500,
|
||||
layoutMode: 'GRID',
|
||||
gridTemplateColumns: [{ sizing: 'FR', value: 1 }],
|
||||
gridTemplateRows: [{ sizing: 'FR', value: 1 }]
|
||||
}).id
|
||||
const id = editor.graph.createNode('FRAME', parentId, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutAlignSelf: 'AUTO'
|
||||
}).id
|
||||
computeLayout(editor.graph, parentId)
|
||||
expect(getNodeOrThrow(editor.graph, id).width).toBe(500)
|
||||
|
||||
editor.resizeFrameToPreset(id, { name: 'Custom', width: 200, height: 300 })
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
width: 200,
|
||||
height: 300,
|
||||
layoutAlignSelf: 'MIN'
|
||||
})
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
width: 500,
|
||||
height: 100,
|
||||
layoutAlignSelf: 'AUTO'
|
||||
})
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
|
||||
width: 200,
|
||||
height: 300,
|
||||
layoutAlignSelf: 'MIN'
|
||||
})
|
||||
})
|
||||
|
||||
test('recomputes nested constraints across resize, undo, and redo', () => {
|
||||
const editor = createEditor()
|
||||
const id = editor.graph.createNode('FRAME', editor.state.currentPageId, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
}).id
|
||||
const childId = editor.graph.createNode('FRAME', id, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
layoutGrow: 1
|
||||
}).id
|
||||
const nestedFrameId = editor.graph.createNode('FRAME', childId, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
layoutGrow: 1
|
||||
}).id
|
||||
const constrainedId = editor.graph.createNode('RECTANGLE', nestedFrameId, {
|
||||
x: 80,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
horizontalConstraint: 'MAX'
|
||||
}).id
|
||||
|
||||
editor.resizeFrameToPreset(id, { name: 'Wide', width: 200, height: 100 })
|
||||
expect(getNodeOrThrow(editor.graph, childId).width).toBe(200)
|
||||
expect(getNodeOrThrow(editor.graph, nestedFrameId).width).toBe(200)
|
||||
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(180)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, childId).width).toBe(100)
|
||||
expect(getNodeOrThrow(editor.graph, nestedFrameId).width).toBe(100)
|
||||
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(80)
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, childId).width).toBe(200)
|
||||
expect(getNodeOrThrow(editor.graph, nestedFrameId).width).toBe(200)
|
||||
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(180)
|
||||
})
|
||||
|
||||
test('restores exact constrained geometry after lossy resize rounding', () => {
|
||||
const editor = createEditor()
|
||||
const id = editor.graph.createNode('FRAME', editor.state.currentPageId, {
|
||||
width: 100,
|
||||
height: 100
|
||||
}).id
|
||||
const childId = editor.graph.createNode('RECTANGLE', id, {
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
horizontalConstraint: 'CENTER'
|
||||
}).id
|
||||
|
||||
editor.resizeFrameToPreset(id, { name: 'iPhone 16', width: 393, height: 852 })
|
||||
expect(getNodeOrThrow(editor.graph, childId).x).toBe(157)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, childId).x).toBe(10)
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, childId).x).toBe(157)
|
||||
})
|
||||
|
||||
test('uses post-layout geometry for constraints nested in a HUG frame', () => {
|
||||
const editor = createEditor()
|
||||
const id = editor.graph.createNode('FRAME', editor.state.currentPageId, {
|
||||
width: 100,
|
||||
height: 100
|
||||
}).id
|
||||
const hugId = editor.graph.createNode('FRAME', id, {
|
||||
width: 50,
|
||||
height: 50,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'FIXED',
|
||||
horizontalConstraint: 'STRETCH'
|
||||
}).id
|
||||
editor.graph.createNode('RECTANGLE', hugId, {
|
||||
width: 50,
|
||||
height: 10
|
||||
})
|
||||
const constrainedId = editor.graph.createNode('RECTANGLE', hugId, {
|
||||
x: 40,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
horizontalConstraint: 'MAX'
|
||||
}).id
|
||||
computeLayout(editor.graph, hugId)
|
||||
|
||||
editor.resizeFrameToPreset(id, { name: 'Wide', width: 200, height: 100 })
|
||||
expect(getNodeOrThrow(editor.graph, hugId).width).toBe(50)
|
||||
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(40)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, hugId).width).toBe(50)
|
||||
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(40)
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, hugId).width).toBe(50)
|
||||
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(40)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,11 +2,15 @@ import { describe, expect, test } from 'bun:test'
|
|||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
import type { ConstraintType } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
collectResizeDescendants,
|
||||
constrainedChildRect,
|
||||
type ResizeSnapshot
|
||||
} from '@open-pencil/scene-graph/resize'
|
||||
import { constraintPins, isConstraintEligible, toggleConstraintPin } from '@open-pencil/vue'
|
||||
|
||||
import { applyResize } from '#vue/shared/input/resize'
|
||||
import { constrainedChildRect } from '#vue/shared/input/resize/constraints'
|
||||
import type { DragResize, OrigChildState } from '#vue/shared/input/types'
|
||||
import type { DragResize } from '#vue/shared/input/types'
|
||||
|
||||
import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
|
||||
|
||||
|
|
@ -15,8 +19,8 @@ function original(node: {
|
|||
y: number
|
||||
width: number
|
||||
height: number
|
||||
vectorNetwork: OrigChildState['vectorNetwork']
|
||||
}): OrigChildState {
|
||||
vectorNetwork: ResizeSnapshot['vectorNetwork']
|
||||
}): ResizeSnapshot {
|
||||
return {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
|
|
@ -54,7 +58,7 @@ describe('constraint control model', () => {
|
|||
})
|
||||
|
||||
describe('constraint resize geometry', () => {
|
||||
const child: OrigChildState = {
|
||||
const child: ResizeSnapshot = {
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 40,
|
||||
|
|
@ -121,4 +125,44 @@ describe('constraint resize geometry', () => {
|
|||
expect(graph.getNode(nested.id)).toMatchObject({ x: 10, width: 150 })
|
||||
expect(graph.getNode(grandchild.id)).toMatchObject({ x: 130, width: 10 })
|
||||
})
|
||||
|
||||
test('repositions constrained descendants after an in-flow child grows in resize preview', () => {
|
||||
const graph = makeSceneGraph()
|
||||
const pageId = firstPageId(graph)
|
||||
const root = graph.createNode('FRAME', pageId, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const nested = graph.createNode('FRAME', root.id, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutGrow: 1
|
||||
})
|
||||
const grandchild = graph.createNode('RECTANGLE', nested.id, {
|
||||
x: 80,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
horizontalConstraint: 'MAX'
|
||||
})
|
||||
const editor = createEditor({ graph })
|
||||
const drag: DragResize = {
|
||||
type: 'resize',
|
||||
handle: 'e',
|
||||
startX: 100,
|
||||
startY: 50,
|
||||
origRect: { x: root.x, y: root.y, width: root.width, height: root.height },
|
||||
nodeId: root.id,
|
||||
origVectorNetwork: null,
|
||||
origChildren: collectResizeDescendants(graph, root.id)
|
||||
}
|
||||
|
||||
applyResize(drag, 200, 50, false, editor)
|
||||
|
||||
expect(graph.getNode(nested.id)?.width).toBe(200)
|
||||
expect(graph.getNode(grandchild.id)?.x).toBe(180)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue