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:
Luca Candela 2026-07-25 14:05:57 -07:00 committed by GitHub
parent 0cd0ee6356
commit 18899ff13c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1439 additions and 297 deletions

View file

@ -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

View file

@ -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
}

View 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 }
}

View file

@ -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)

View file

@ -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 |

View file

@ -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

View file

@ -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",

View 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
}

View file

@ -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',

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "オートレイアウト",

View file

@ -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",

View file

@ -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": "Автораскладка",

View file

@ -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": "自动布局",

View file

@ -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',

View file

@ -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'

View file

@ -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"
/>

View file

@ -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

View file

@ -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
}

View file

@ -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,48 +34,19 @@ 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 changes = computeConstrainedResizeChanges(
editor.graph,
d.nodeId,
d.origRect,
newRect,
d.origChildren
)
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
}
for (const [childId, childChanges] of changes) {
editor.graph.updateNodePreview(childId, childChanges)
editor.renderer?.invalidateVectorPath(childId)
apply(childId, original, rect)
}
}
apply(d.nodeId, d.origRect, newRect)
}
export function applyResize(
d: DragResize,
cx: number,
@ -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()
}

View file

@ -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')
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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 {

View 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)
}

View file

@ -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 />

View file

@ -2,15 +2,22 @@
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<{
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>
@ -20,14 +27,6 @@ const { tools, activeTool, toolIcons, toolLabels, toolShortcuts, ui } = definePr
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"
/>

View file

@ -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"
/>

View file

@ -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
<DropdownMenuRadioGroup :model-value="selectedTool">
<DropdownMenuRadioItem
v-for="sub in tool.flyout"
:key="sub"
v-slot="{ active: subActive, actions }"
:tool="sub"
>
<DropdownMenuItem
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>

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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
}
}

View 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
})
})

View file

@ -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()
})

View 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)
})
})

View file

@ -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)
})
})