feat(lint): suggest group-to-frame and hidden-layer fixes; keep canvas edits in code previews (#870)
* feat(lint): suggest converting groups to frames and deleting hidden layers no-groups and no-hidden-layers now carry suggestions the Lint panel, the lint_fix tool and editor.applyLintFixes can apply. A group becomes a frame in place, keeping its id, children, bounds and look; a hidden layer is deleted with its children. Neither is offered for locked layers or inside components and instances, and both are checked again when applied. The editor gains convertGroupToFrame and deleteNodes, which deleteSelected now uses, and applies lint fixes through a bridge so structure changes and property updates share one undo step. lint_fix becomes a document mutation because atomic tools cannot remove layers. * fix(code): keep canvas edits made while replaced code waits to render Replacing all the code drops its layer links until the preview links it again, so a canvas edit in the preview delay could not be patched into the code and the preview drew over it. Edits made while code waits to render are now applied again once the preview has linked the code, in the same undo step, and reach the code like any other canvas change. * fix(figma-api): default paint opacity and visibility like Figma Plugin scripts may leave out a paint's opacity and visible; Figma reads them back as 1 and true. The fills and strokes setters stored the paint as given, so the Design panel received an undefined opacity. * build(dev): forward only errors from the browser console Vite forwards browser logs when an agent starts the dev server. Serializing a Vue warning's component props walks the editor state and freezes the tab, so warnings stay in the browser console. * fix(code): follow values while they are dragged or scrubbed Live previews change layers without a new scene version, so the code kept the old value until the gesture ended. The Code tab now follows preview updates once per frame, as the Design panel does, and goes back when the gesture is cancelled. * fix(code): keep canvas edits when the replaced code fails to preview A failed preview took the canvas edits waiting for it and dropped them, and stopped recording new ones, so correcting the code drew over them. The edits now wait for the next preview.
This commit is contained in:
parent
767fd99493
commit
bb69735d27
|
|
@ -17,9 +17,9 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Check designs from the new Lint tab in the right panel: issues on the page, in the selection, or across the document are grouped by rule, hovering one highlights its layer on the canvas, clicking selects it and brings it into view, and one-click fixes bind colors to the variable they match and round subpixel geometry for a row or a whole group, and snap radius, spacing, and small text to the scale one row at a time. Rules can be turned off individually or switched between the Recommended, Strict, and Accessibility presets.
|
||||
- Check designs from the new Lint tab in the right panel: issues on the page, in the selection, or across the document are grouped by rule, hovering one highlights its layer on the canvas, clicking selects it and brings it into view, and one-click fixes bind colors to the variable they match and round subpixel geometry for a row or a whole group, and snap radius, spacing, and small text to the scale, convert groups to frames, and delete hidden layers one row at a time. Rules can be turned off individually or switched between the Recommended, Strict, and Accessibility presets.
|
||||
- See which layer the code is about in the Code tab's Design JSX and Tailwind JSX: the element around the cursor marks its opening and closing tag and shows its layer on the canvas as a tinted box, apart from canvas hover, and design issues are underlined on the property that causes them, including while you edit Design JSX live.
|
||||
- Fix lint issues outside the app: `openpencil lint --fix -o fixed.fig` binds colors to the variable they match and rounds subpixel geometry, and the `lint` and `lint_fix` tools let MCP clients and AI chat check a page and apply those fixes, plus radius, spacing, and text size suggestions on request. Lint messages carry each fix as data in `fix` and `suggestions`.
|
||||
- Fix lint issues outside the app: `openpencil lint --fix -o fixed.fig` binds colors to the variable they match and rounds subpixel geometry, and the `lint` and `lint_fix` tools let MCP clients and AI chat check a page and apply those fixes, plus radius, spacing, text size, group-to-frame, and hidden-layer suggestions on request. Lint messages carry each fix as data in `fix` and `suggestions`.
|
||||
- Mark layers with errors and warnings on the canvas while you work, and pin those outside the view to the canvas edge in their direction, with matching marks in the Layers panel and error and warning counts in the page list; hover a marker or pin for its issues or click it to open them in Lint, and turn markers on or off with View → Design issues.
|
||||
- Follow collaborators and their AI agents from the avatars in the toolbar: an avatar counts that person's agents, hovering lists what each is doing and on which page, and clicking follows. A frame in their color and a “Following …” bar show whom you follow; Escape, clicking, scrolling, zooming, or switching pages stops it. Your own avatar renames your agents and leaves the room.
|
||||
- See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them.
|
||||
|
|
@ -53,6 +53,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Give paints set through the plugin API, `eval`, and AI and MCP scripts an opacity of 1 and make them visible when the script leaves those out, as Figma does. Such paints were stored without them, which the Design panel could not show.
|
||||
- Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none.
|
||||
- Open Figma files that use slots with each instance's own slot content instead of its component's default, keep slot properties, their settings, and instance content when saving back to `.fig`, and keep an instance's slot content when you switch its variant.
|
||||
- Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading.
|
||||
|
|
|
|||
34
packages/core/src/editor/bridges/lint.ts
Normal file
34
packages/core/src/editor/bridges/lint.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { createClipboardActions } from '#core/editor/clipboard'
|
||||
import type { createNodeActions } from '#core/editor/nodes'
|
||||
import type { createStructureActions } from '#core/editor/structure'
|
||||
import type { EditorContext } from '#core/editor/types'
|
||||
import { applyLintFixes as applyFixes, type LintFixRequest } from '#core/lint/fixes'
|
||||
|
||||
type NodeActions = ReturnType<typeof createNodeActions>
|
||||
type StructureActions = ReturnType<typeof createStructureActions>
|
||||
type ClipboardActions = ReturnType<typeof createClipboardActions>
|
||||
|
||||
export function createLintFixBridge(
|
||||
ctx: EditorContext,
|
||||
nodes: NodeActions,
|
||||
structure: StructureActions,
|
||||
clipboard: ClipboardActions
|
||||
) {
|
||||
/** Applies lint fixes that still hold as one undo step; returns how many applied. */
|
||||
function applyLintFixes(requests: readonly LintFixRequest[]): number {
|
||||
return ctx.undo.runBatch('Fix design issues', () =>
|
||||
applyFixes(
|
||||
{
|
||||
graph: ctx.graph,
|
||||
updateNode: (id, changes) => nodes.updateNodeWithUndo(id, changes, 'Fix design issue'),
|
||||
bindVariable: nodes.bindVariable,
|
||||
convertToFrame: structure.convertGroupToFrame,
|
||||
deleteNodes: (ids) => clipboard.deleteNodes(ids)
|
||||
},
|
||||
requests
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return { applyLintFixes }
|
||||
}
|
||||
|
|
@ -310,14 +310,18 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
return missingImageHashes(nodeIds).length > 0
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
/**
|
||||
* Deletes layers with their children as one undo step, skipping locked ones. Deleted layers
|
||||
* leave the selection, and undo restores the selection from before.
|
||||
*/
|
||||
function deleteNodes(nodeIds: Iterable<string>, nextSelection?: ReadonlySet<string>) {
|
||||
const entries: Array<{
|
||||
id: string
|
||||
parentId: string
|
||||
index: number
|
||||
subtree: Map<string, SceneNode>
|
||||
}> = []
|
||||
for (const id of ctx.state.selectedIds) {
|
||||
for (const id of nodeIds) {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (!node || node.locked) continue
|
||||
const parentId = node.parentId ?? ctx.state.currentPageId
|
||||
|
|
@ -336,13 +340,15 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
const prevSelection = new Set(ctx.state.selectedIds)
|
||||
for (const { id } of entries) ctx.graph.deleteNode(id)
|
||||
relayoutParents()
|
||||
const selectionAfter =
|
||||
nextSelection ?? new Set([...prevSelection].filter((id) => ctx.graph.getNode(id)))
|
||||
|
||||
ctx.undo.push({
|
||||
label: 'Delete',
|
||||
forward: () => {
|
||||
for (const { id } of entries) ctx.graph.deleteNode(id)
|
||||
relayoutParents()
|
||||
ctx.setSelectedIds(new Set())
|
||||
ctx.setSelectedIds(new Set(selectionAfter))
|
||||
},
|
||||
inverse: () => {
|
||||
restoreDeletedEntries(ctx, entries)
|
||||
|
|
@ -350,7 +356,11 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
ctx.setSelectedIds(prevSelection)
|
||||
}
|
||||
})
|
||||
ctx.setSelectedIds(new Set())
|
||||
ctx.setSelectedIds(new Set(selectionAfter))
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
deleteNodes(ctx.state.selectedIds, new Set())
|
||||
}
|
||||
|
||||
const copyActions = createClipboardCopyActions(ctx)
|
||||
|
|
@ -368,6 +378,7 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
pasteSnapshot,
|
||||
pasteFromHTML,
|
||||
warnMissingImages,
|
||||
deleteNodes,
|
||||
deleteSelected,
|
||||
...assetActions,
|
||||
...exportActions
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { fontResolver } from '#core/text/resolver'
|
|||
import { createAlignmentActions } from './alignment'
|
||||
import { createClipboardBridge } from './bridges/clipboard'
|
||||
import { createComponentBridge } from './bridges/components'
|
||||
import { createLintFixBridge } from './bridges/lint'
|
||||
import { createStructureBridge } from './bridges/structure'
|
||||
import { createUndoBridge } from './bridges/undo'
|
||||
import { createClipboardActions } from './clipboard'
|
||||
|
|
@ -235,6 +236,7 @@ export function createEditor(options?: EditorOptions) {
|
|||
const componentBridge = createComponentBridge(components, selection, structure, pages)
|
||||
const structureBridge = createStructureBridge(structure, selection)
|
||||
const undoBridge = createUndoBridge(undoActions, selection)
|
||||
const lintFixBridge = createLintFixBridge(ctx, nodes, structure, clipboard)
|
||||
|
||||
function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) {
|
||||
_ck = ck
|
||||
|
|
@ -371,7 +373,10 @@ export function createEditor(options?: EditorOptions) {
|
|||
...componentBridge,
|
||||
|
||||
// Structure — bridge functions that need selectedNodes
|
||||
...structureBridge
|
||||
...structureBridge,
|
||||
|
||||
// Lint fixes, which touch nodes, structure and deletion in one undo step
|
||||
...lintFixBridge
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from '@open-pencil/scene-graph'
|
||||
|
||||
import { reconcileVariableLayouts } from '#core/layout/variables'
|
||||
import { applyLintFixes as applyFixes, type LintFixRequest } from '#core/lint/fixes'
|
||||
|
||||
import { createLayoutModeActions } from './layout-mode'
|
||||
import { createNodePreviewActions } from './node-preview'
|
||||
|
|
@ -111,23 +110,8 @@ export function createNodeActions(ctx: EditorContext) {
|
|||
)
|
||||
}
|
||||
|
||||
/** Applies lint fixes that still hold as one undo step; returns how many applied. */
|
||||
function applyLintFixes(requests: readonly LintFixRequest[]): number {
|
||||
return ctx.undo.runBatch('Fix design issues', () =>
|
||||
applyFixes(
|
||||
{
|
||||
graph: ctx.graph,
|
||||
updateNode: (id, changes) => updateNodeWithUndo(id, changes, 'Fix design issue'),
|
||||
bindVariable: variableBindingActions.bindVariable
|
||||
},
|
||||
requests
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
updateNode,
|
||||
applyLintFixes,
|
||||
...createNodePreviewActions(ctx, updateNode),
|
||||
updateNodeWithUndo,
|
||||
setOpacity,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,24 @@ export function createStructureActions(ctx: EditorContext) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a group into a frame in place. The layer keeps its id, children, bounds and look: a
|
||||
* group has no fill and does not clip, and the frame starts the same way.
|
||||
*/
|
||||
function convertGroupToFrame(nodeId: string) {
|
||||
if (ctx.graph.getNode(nodeId)?.type !== 'GROUP') return
|
||||
const apply = (type: 'GROUP' | 'FRAME') => {
|
||||
ctx.graph.updateNode(nodeId, { type })
|
||||
ctx.runLayoutForNode(nodeId)
|
||||
}
|
||||
apply('FRAME')
|
||||
ctx.undo.push({
|
||||
label: 'Convert to frame',
|
||||
forward: () => apply('FRAME'),
|
||||
inverse: () => apply('GROUP')
|
||||
})
|
||||
}
|
||||
|
||||
function booleanOperationSelected(selectedNodes: SceneNode[], operation: BooleanOperation) {
|
||||
return booleanOperationSelectedImpl(ctx, selectedNodes, operation)
|
||||
}
|
||||
|
|
@ -142,6 +160,7 @@ export function createStructureActions(ctx: EditorContext) {
|
|||
wrapInAutoLayout,
|
||||
groupSelected,
|
||||
frameSelection,
|
||||
convertGroupToFrame,
|
||||
booleanOperationSelected,
|
||||
ungroupSelected,
|
||||
flattenSelected,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ function styleReference(
|
|||
}
|
||||
}
|
||||
|
||||
/** Figma treats a paint's `opacity` and `visible` as optional, defaulting to 1 and `true`. */
|
||||
function paintDefaults(paint: Partial<Pick<Fill, 'opacity' | 'visible'>>) {
|
||||
return { opacity: paint.opacity ?? 1, visible: paint.visible ?? true }
|
||||
}
|
||||
|
||||
export function installVisualNodeProxyAccessors(
|
||||
prototype: object,
|
||||
internals: NodeProxyInternals,
|
||||
|
|
@ -38,6 +43,7 @@ export function installVisualNodeProxyAccessors(
|
|||
updateNode(this, internals, {
|
||||
fills: value.map((fill) => ({
|
||||
...fill,
|
||||
...paintDefaults(fill),
|
||||
color: normalizeColor(fill.color),
|
||||
gradientStops: fill.gradientStops?.map((stop) => ({
|
||||
...stop,
|
||||
|
|
@ -53,7 +59,11 @@ export function installVisualNodeProxyAccessors(
|
|||
},
|
||||
set(this: ProxyThis, value: readonly Stroke[]) {
|
||||
updateNode(this, internals, {
|
||||
strokes: value.map((stroke) => ({ ...stroke, color: normalizeColor(stroke.color) }))
|
||||
strokes: value.map((stroke) => ({
|
||||
...stroke,
|
||||
...paintDefaults(stroke),
|
||||
color: normalizeColor(stroke.color)
|
||||
}))
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
|||
import { colorToHex } from '@open-pencil/scene-graph/color'
|
||||
|
||||
import type { LintFix, LintMessage } from './types'
|
||||
import { ownsComponentTree } from './utils'
|
||||
|
||||
/** A fix for the layer it was reported on. A `LintMessage` with a `fix` is one. */
|
||||
export interface LintFixRequest {
|
||||
|
|
@ -17,13 +18,20 @@ export interface LintFixTarget {
|
|||
readonly graph: SceneGraph
|
||||
updateNode(nodeId: string, changes: Partial<SceneNode>): void
|
||||
bindVariable(nodeId: string, path: string, variableId: string): void
|
||||
convertToFrame(nodeId: string): void
|
||||
/** Deletes layers with their children; none of them is inside another. */
|
||||
deleteNodes(nodeIds: readonly string[]): void
|
||||
}
|
||||
|
||||
export function graphFixTarget(graph: SceneGraph): LintFixTarget {
|
||||
return {
|
||||
graph,
|
||||
updateNode: (nodeId, changes) => graph.updateNode(nodeId, changes),
|
||||
bindVariable: (nodeId, path, variableId) => graph.bindVariable(nodeId, path, variableId)
|
||||
bindVariable: (nodeId, path, variableId) => graph.bindVariable(nodeId, path, variableId),
|
||||
convertToFrame: (nodeId) => graph.updateNode(nodeId, { type: 'FRAME' }),
|
||||
deleteNodes: (nodeIds) => {
|
||||
for (const nodeId of nodeIds) graph.deleteNode(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,30 +57,76 @@ function canBind(graph: SceneGraph, node: SceneNode, path: string, variableId: s
|
|||
return colorToHex({ ...paint.color, a: 1 }) === colorToHex({ ...color, a: 1 })
|
||||
}
|
||||
|
||||
function* ancestors(graph: SceneGraph, node: SceneNode): Generator<SceneNode> {
|
||||
for (let id = node.parentId; id;) {
|
||||
const parent = graph.getNode(id)
|
||||
if (!parent) return
|
||||
yield parent
|
||||
id = parent.parentId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural fixes apply only where the layer's structure is the user's to change: not locked,
|
||||
* and not inside a component or an instance.
|
||||
*/
|
||||
function canRestructure(graph: SceneGraph, node: SceneNode): boolean {
|
||||
if (node.locked) return false
|
||||
for (const parent of ancestors(graph, node)) if (ownsComponentTree(parent.type)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function hasAncestorIn(graph: SceneGraph, node: SceneNode, ids: ReadonlySet<string>): boolean {
|
||||
for (const parent of ancestors(graph, node)) if (ids.has(parent.id)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies fixes that still hold against the current graph. Changes to one layer merge into a
|
||||
* single update. Returns the number of fixes applied.
|
||||
* single update, and deletions run last so other fixes still find their layers. Returns the
|
||||
* number of fixes applied.
|
||||
*/
|
||||
export function applyLintFixes(target: LintFixTarget, requests: readonly LintFixRequest[]): number {
|
||||
const { graph } = target
|
||||
const updates = new Map<string, Partial<SceneNode>>()
|
||||
const conversions = new Set<string>()
|
||||
const deletions = new Set<string>()
|
||||
let applied = 0
|
||||
for (const { nodeId, fix } of requests) {
|
||||
const node = graph.getNode(nodeId)
|
||||
if (!node) continue
|
||||
if (fix.kind === 'bind-variable') {
|
||||
if (!canBind(graph, node, fix.path, fix.variableId)) continue
|
||||
target.bindVariable(nodeId, fix.path, fix.variableId)
|
||||
applied++
|
||||
continue
|
||||
switch (fix.kind) {
|
||||
case 'bind-variable':
|
||||
if (!canBind(graph, node, fix.path, fix.variableId)) continue
|
||||
target.bindVariable(nodeId, fix.path, fix.variableId)
|
||||
break
|
||||
case 'set': {
|
||||
const changes = Object.fromEntries(
|
||||
Object.entries(fix.changes).filter(([key, value]) => Reflect.get(node, key) !== value)
|
||||
)
|
||||
if (Object.keys(changes).length === 0) continue
|
||||
updates.set(nodeId, { ...updates.get(nodeId), ...changes })
|
||||
break
|
||||
}
|
||||
case 'convert-to-frame':
|
||||
if (node.type !== 'GROUP' || conversions.has(nodeId) || !canRestructure(graph, node))
|
||||
continue
|
||||
conversions.add(nodeId)
|
||||
break
|
||||
case 'delete':
|
||||
// Only a layer that is still hidden: one shown since the check is in use.
|
||||
if (node.visible || deletions.has(nodeId) || !canRestructure(graph, node)) continue
|
||||
deletions.add(nodeId)
|
||||
break
|
||||
}
|
||||
const changes = Object.fromEntries(
|
||||
Object.entries(fix.changes).filter(([key, value]) => Reflect.get(node, key) !== value)
|
||||
)
|
||||
if (Object.keys(changes).length === 0) continue
|
||||
updates.set(nodeId, { ...updates.get(nodeId), ...changes })
|
||||
applied++
|
||||
}
|
||||
for (const [nodeId, changes] of updates) target.updateNode(nodeId, changes)
|
||||
for (const nodeId of conversions) target.convertToFrame(nodeId)
|
||||
const roots = [...deletions].filter((nodeId) => {
|
||||
const node = graph.getNode(nodeId)
|
||||
return node ? !hasAncestorIn(graph, node, deletions) : false
|
||||
})
|
||||
if (roots.length > 0) target.deleteNodes(roots)
|
||||
return applied
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { defineRule } from '#core/lint/rule'
|
||||
import { inComponentTree } from '#core/lint/utils'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-groups',
|
||||
|
|
@ -10,7 +12,10 @@ export default defineRule({
|
|||
context.report({
|
||||
node,
|
||||
message: 'Group should be converted to Frame',
|
||||
suggest: 'Groups cannot use auto layout. Convert to Frame for better control.'
|
||||
suggest: 'Groups cannot use auto layout. Convert to Frame for better control.',
|
||||
...(node.locked || inComponentTree(node)
|
||||
? {}
|
||||
: { suggestions: [{ kind: 'convert-to-frame' }] })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { defineRule } from '#core/lint/rule'
|
||||
import { inComponentTree } from '#core/lint/utils'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-hidden-layers',
|
||||
|
|
@ -6,11 +8,12 @@ export default defineRule({
|
|||
description: 'Hidden layers may indicate unused elements'
|
||||
},
|
||||
check(node, context) {
|
||||
if (!node.visible)
|
||||
context.report({
|
||||
node,
|
||||
message: 'Hidden layer detected',
|
||||
suggest: 'Delete if unused or keep only if required for component states'
|
||||
})
|
||||
if (node.visible) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Hidden layer detected',
|
||||
suggest: 'Delete if unused or keep only if required for component states',
|
||||
...(node.locked || inComponentTree(node) ? {} : { suggestions: [{ kind: 'delete' }] })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export type LintFixProperty =
|
|||
export type LintFix =
|
||||
| { kind: 'bind-variable'; path: string; variableId: string; variableName: string }
|
||||
| { kind: 'set'; changes: Readonly<Partial<Record<LintFixProperty, number>>> }
|
||||
/** Turns a group into a frame in place, keeping its children, bounds and look. */
|
||||
| { kind: 'convert-to-frame' }
|
||||
/** Deletes the layer with its children. */
|
||||
| { kind: 'delete' }
|
||||
|
||||
export interface LintMessage {
|
||||
ruleId: string
|
||||
|
|
|
|||
|
|
@ -52,3 +52,26 @@ export function getNodePath(node: LintPathNode): string[] {
|
|||
}
|
||||
|
||||
export const SPACING_SCALE = [0, 1, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 128]
|
||||
|
||||
const COMPONENT_TREE_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
|
||||
|
||||
/** Layer types whose descendants' structure belongs to a component. */
|
||||
export function ownsComponentTree(type: string): boolean {
|
||||
return COMPONENT_TREE_TYPES.has(type)
|
||||
}
|
||||
|
||||
interface LintTreeNode {
|
||||
type: string
|
||||
parent?: LintTreeNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a layer sits inside a component or an instance, whose structure belongs to the
|
||||
* component: instance layers cannot be removed, and a component property may show a hidden one.
|
||||
*/
|
||||
export function inComponentTree(node: LintTreeNode): boolean {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (ownsComponentTree(current.type)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,8 +91,9 @@ export const lintFix = defineTool({
|
|||
description:
|
||||
'Apply lint fixes: bind colors to the variable they match and round geometry to whole pixels. ' +
|
||||
'With suggestions: true, also apply each finding’s first suggestion (snap radius and spacing to the scale, ' +
|
||||
'raise text to the minimum size). Returns what was applied and what remains.',
|
||||
execution: { kind: 'sync', mutation: 'properties' },
|
||||
'raise text to the minimum size, convert groups to frames, delete hidden layers). ' +
|
||||
'Returns what was applied and what remains.',
|
||||
execution: { kind: 'sync', mutation: 'document' },
|
||||
input: v.object({
|
||||
...lintScopeInput,
|
||||
suggestions: v.optional(
|
||||
|
|
|
|||
|
|
@ -183,3 +183,116 @@ describe('editor.applyLintFixes', () => {
|
|||
expect(restored.boundVariables['fills/0/color']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function suggestionRequests(messages: readonly LintMessage[]) {
|
||||
return messages.flatMap((m) => (m.suggestions ?? []).map((fix) => ({ nodeId: m.nodeId, fix })))
|
||||
}
|
||||
|
||||
describe('structure suggestions', () => {
|
||||
test('convert a group to a frame in place, keeping its layers and look', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const group = graph.createNode('GROUP', pageId, { x: 40, y: 20, width: 30, height: 30 })
|
||||
const dot = graph.createNode('ELLIPSE', group.id, { width: 10, height: 10 })
|
||||
|
||||
const messages = lint(graph, 'no-groups')
|
||||
expect(messages[0]?.suggestions).toEqual([{ kind: 'convert-to-frame' }])
|
||||
expect(safeFixes(messages)).toEqual([])
|
||||
|
||||
expect(applyLintFixes(graphFixTarget(graph), suggestionRequests(messages))).toBe(1)
|
||||
const frame = getNodeOrThrow(graph, group.id)
|
||||
expect(frame).toMatchObject({ type: 'FRAME', x: 40, y: 20, width: 30, height: 30 })
|
||||
expect(frame.childIds).toEqual([dot.id])
|
||||
expect(frame.fills).toEqual([])
|
||||
expect(frame.clipsContent).toBe(false)
|
||||
expect(lint(graph, 'no-groups')).toEqual([])
|
||||
})
|
||||
|
||||
test('delete hidden layers once, together with hidden layers inside them', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const panel = graph.createNode('FRAME', pageId, { visible: false })
|
||||
const inner = graph.createNode('RECTANGLE', panel.id, { visible: false })
|
||||
const shown = graph.createNode('RECTANGLE', pageId)
|
||||
|
||||
const messages = lint(graph, 'no-hidden-layers')
|
||||
expect(messages.map((m) => m.suggestions)).toEqual([[{ kind: 'delete' }], [{ kind: 'delete' }]])
|
||||
|
||||
expect(applyLintFixes(graphFixTarget(graph), suggestionRequests(messages))).toBe(2)
|
||||
expect(graph.getNode(panel.id)).toBeUndefined()
|
||||
expect(graph.getNode(inner.id)).toBeUndefined()
|
||||
expect(graph.getNode(shown.id)).toBeDefined()
|
||||
})
|
||||
|
||||
test('keep a layer shown again since the check', () => {
|
||||
const graph = new SceneGraph()
|
||||
const layer = graph.createNode('RECTANGLE', graph.getPages()[0].id, { visible: false })
|
||||
const messages = lint(graph, 'no-hidden-layers')
|
||||
|
||||
graph.updateNode(layer.id, { visible: true })
|
||||
|
||||
expect(applyLintFixes(graphFixTarget(graph), suggestionRequests(messages))).toBe(0)
|
||||
expect(graph.getNode(layer.id)).toBeDefined()
|
||||
})
|
||||
|
||||
test('leave locked layers and the structure of components and instances alone', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const lockedGroup = graph.createNode('GROUP', pageId, { locked: true })
|
||||
const lockedHidden = graph.createNode('RECTANGLE', pageId, { visible: false, locked: true })
|
||||
const button = graph.createNode('COMPONENT', pageId)
|
||||
const icon = graph.createNode('GROUP', button.id)
|
||||
const badge = graph.createNode('RECTANGLE', button.id, { visible: false })
|
||||
const instance = graph.createNode('INSTANCE', pageId, { componentId: button.id })
|
||||
const copy = graph.createNode('RECTANGLE', instance.id, { visible: false })
|
||||
|
||||
const messages = [...lint(graph, 'no-groups'), ...lint(graph, 'no-hidden-layers')]
|
||||
// The linter does not look inside instances, so the copy is only checked when applying.
|
||||
const reported = [lockedGroup, lockedHidden, icon, badge].map((node) => node.id)
|
||||
expect(reported.every((id) => messages.some((m) => m.nodeId === id))).toBe(true)
|
||||
expect(messages.every((m) => m.suggestions === undefined)).toBe(true)
|
||||
|
||||
// Requests built elsewhere are checked against the graph as well.
|
||||
const requests = [
|
||||
{ nodeId: lockedGroup.id, fix: { kind: 'convert-to-frame' } as const },
|
||||
{ nodeId: icon.id, fix: { kind: 'convert-to-frame' } as const },
|
||||
{ nodeId: lockedHidden.id, fix: { kind: 'delete' } as const },
|
||||
{ nodeId: copy.id, fix: { kind: 'delete' } as const }
|
||||
]
|
||||
expect(applyLintFixes(graphFixTarget(graph), requests)).toBe(0)
|
||||
expect(getNodeOrThrow(graph, icon.id).type).toBe('GROUP')
|
||||
expect(graph.getNode(copy.id)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor structure fixes', () => {
|
||||
test('convert and delete as one undo step that restores layers and selection', () => {
|
||||
const editor = createEditor()
|
||||
const pageId = editor.graph.getPages()[0].id
|
||||
const group = editor.graph.createNode('GROUP', pageId, { width: 30, height: 30 })
|
||||
editor.graph.createNode('ELLIPSE', group.id, { width: 10, height: 10 })
|
||||
const hidden = editor.graph.createNode('RECTANGLE', pageId, { visible: false })
|
||||
editor.select([hidden.id, group.id])
|
||||
|
||||
const messages = createLinter({
|
||||
rules: ['no-groups', 'no-hidden-layers'],
|
||||
config: { rules: { 'no-groups': 'warning', 'no-hidden-layers': 'warning' } }
|
||||
}).lintGraph(editor.graph).messages
|
||||
|
||||
expect(editor.applyLintFixes(suggestionRequests(messages))).toBe(2)
|
||||
expect(getNodeOrThrow(editor.graph, group.id).type).toBe('FRAME')
|
||||
expect(editor.graph.getNode(hidden.id)).toBeUndefined()
|
||||
expect([...editor.state.selectedIds]).toEqual([group.id])
|
||||
|
||||
editor.undoAction()
|
||||
|
||||
expect(getNodeOrThrow(editor.graph, group.id).type).toBe('GROUP')
|
||||
expect(getNodeOrThrow(editor.graph, hidden.id).visible).toBe(false)
|
||||
expect(new Set(editor.state.selectedIds)).toEqual(new Set([hidden.id, group.id]))
|
||||
|
||||
editor.redoAction()
|
||||
|
||||
expect(getNodeOrThrow(editor.graph, group.id).type).toBe('FRAME')
|
||||
expect(editor.graph.getNode(hidden.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Rows that can be fixed in one step show a button on hover:
|
|||
- A hardcoded color that matches one of the document's color variables binds to it (link button). The row names the variable.
|
||||
- Subpixel positions and sizes round to whole pixels. Values that auto layout or text resizing sets, and vector artwork and the parts of groups, are left alone.
|
||||
- Off-scale corner radius and spacing change to the nearest scale value, and text below the minimum size grows to it (wand button). These change the design, so they apply one row at a time.
|
||||
- A group converts to a frame in place, keeping its layers, position, and look (frame button), and a hidden layer can be deleted (trash button). Neither is offered for locked layers or layers inside a component or instance, whose structure belongs to the component.
|
||||
|
||||
Binding colors and rounding pixels keep the design as it looks, so their groups also offer **Bind all** or **Fix all**. Every fix is a single undo step.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "Auf ganze Pixel runden",
|
||||
"fixUseValue": "Auf {value} ändern",
|
||||
"fixFullRadius": "Vollen Radius verwenden",
|
||||
"fixConvertToFrame": "In Rahmen umwandeln",
|
||||
"fixDeleteLayer": "Ebene löschen",
|
||||
"fixAll": "Alle {count} beheben",
|
||||
"ruleNoDefaultNames": "Standardname der Ebene",
|
||||
"ruleNoDefaultNamesHelp": "Aussagekräftige Namen machen Ebenen leichter auffindbar, übergebbar und wiederverwendbar.",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "Redondear a píxeles enteros",
|
||||
"fixUseValue": "Cambiar a {value}",
|
||||
"fixFullRadius": "Usar radio completo",
|
||||
"fixConvertToFrame": "Convertir en marco",
|
||||
"fixDeleteLayer": "Eliminar capa",
|
||||
"fixAll": "Corregir las {count}",
|
||||
"ruleNoDefaultNames": "Nombre de capa predeterminado",
|
||||
"ruleNoDefaultNamesHelp": "Los nombres descriptivos facilitan encontrar, entregar y reutilizar capas.",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "Arrondir au pixel entier",
|
||||
"fixUseValue": "Remplacer par {value}",
|
||||
"fixFullRadius": "Utiliser un rayon complet",
|
||||
"fixConvertToFrame": "Convertir en cadre",
|
||||
"fixDeleteLayer": "Supprimer le calque",
|
||||
"fixAll": "Tout corriger ({count})",
|
||||
"ruleNoDefaultNames": "Nom de calque par défaut",
|
||||
"ruleNoDefaultNamesHelp": "Des noms descriptifs facilitent la recherche, la transmission et la réutilisation des calques.",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "Arrotonda a pixel interi",
|
||||
"fixUseValue": "Cambia in {value}",
|
||||
"fixFullRadius": "Usa raggio completo",
|
||||
"fixConvertToFrame": "Converti in frame",
|
||||
"fixDeleteLayer": "Elimina livello",
|
||||
"fixAll": "Correggi tutti ({count})",
|
||||
"ruleNoDefaultNames": "Nome del livello predefinito",
|
||||
"ruleNoDefaultNamesHelp": "Nomi descrittivi rendono i livelli più facili da trovare, consegnare e riutilizzare.",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "整数ピクセルに丸める",
|
||||
"fixUseValue": "{value} に変更",
|
||||
"fixFullRadius": "最大の角丸を使う",
|
||||
"fixConvertToFrame": "フレームに変換",
|
||||
"fixDeleteLayer": "レイヤーを削除",
|
||||
"fixAll": "{count} 件すべて修正",
|
||||
"ruleNoDefaultNames": "デフォルトのレイヤー名",
|
||||
"ruleNoDefaultNamesHelp": "わかりやすい名前を付けると、レイヤーを探しやすく、受け渡しや再利用もしやすくなります。",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "Zaokrąglij do pełnych pikseli",
|
||||
"fixUseValue": "Zmień na {value}",
|
||||
"fixFullRadius": "Użyj pełnego promienia",
|
||||
"fixConvertToFrame": "Przekształć w ramkę",
|
||||
"fixDeleteLayer": "Usuń warstwę",
|
||||
"fixAll": "Popraw wszystkie ({count})",
|
||||
"ruleNoDefaultNames": "Domyślna nazwa warstwy",
|
||||
"ruleNoDefaultNamesHelp": "Opisowe nazwy ułatwiają znajdowanie, przekazywanie i ponowne użycie warstw.",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "Округлить до целых пикселей",
|
||||
"fixUseValue": "Изменить на {value}",
|
||||
"fixFullRadius": "Использовать полное скругление",
|
||||
"fixConvertToFrame": "Преобразовать во фрейм",
|
||||
"fixDeleteLayer": "Удалить слой",
|
||||
"fixAll": "Исправить все ({count})",
|
||||
"ruleNoDefaultNames": "Стандартное имя слоя",
|
||||
"ruleNoDefaultNamesHelp": "Понятные имена помогают находить, передавать и переиспользовать слои.",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
"fixRoundPixels": "取整到整像素",
|
||||
"fixUseValue": "改为 {value}",
|
||||
"fixFullRadius": "使用完全圆角",
|
||||
"fixConvertToFrame": "转换为画框",
|
||||
"fixDeleteLayer": "删除图层",
|
||||
"fixAll": "全部修复({count})",
|
||||
"ruleNoDefaultNames": "默认图层名称",
|
||||
"ruleNoDefaultNamesHelp": "描述性的名称让图层更易查找、交付和复用。",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export const designCheckMessageDefaults = {
|
|||
fixRoundPixels: 'Round to whole pixels',
|
||||
fixUseValue: params('Change to {value}'),
|
||||
fixFullRadius: 'Use full radius',
|
||||
fixConvertToFrame: 'Convert to frame',
|
||||
fixDeleteLayer: 'Delete layer',
|
||||
fixAll: params('Fix all {count}'),
|
||||
ruleNoDefaultNames: 'Default layer name',
|
||||
ruleNoDefaultNamesHelp: 'Descriptive names make layers easier to find, hand off and reuse.',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { renderTree } from '@open-pencil/core/design-jsx'
|
||||
import { computeAllLayouts } from '@open-pencil/core/layout'
|
||||
import { reconcileRenderedLayers, type TreeNode } from '@open-pencil/design-jsx'
|
||||
import type { Vector } from '@open-pencil/scene-graph'
|
||||
import type { SceneNode, Vector } from '@open-pencil/scene-graph'
|
||||
|
||||
import { convertDesignJSXRoots } from '@/app/code/sandbox/convert'
|
||||
import { evaluateDesignJSX } from '@/app/code/sandbox/evaluate'
|
||||
|
|
@ -140,6 +140,26 @@ export async function previewDesignJSX(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies canvas edits made while the code waited to render, which its preview drew over. They
|
||||
* came after the code, so they win, and join the edit's undo step. Layers the preview removed
|
||||
* are skipped.
|
||||
*/
|
||||
export function reapplyCanvasEdits(
|
||||
store: EditorStore,
|
||||
session: DesignJSXEditSession,
|
||||
edits: ReadonlyMap<string, Partial<SceneNode>>
|
||||
): void {
|
||||
const present = [...edits].filter(([id]) => store.graph.getNode(id))
|
||||
if (present.length === 0) return
|
||||
const before = store.snapshotPage()
|
||||
const selection = [...store.state.selectedIds]
|
||||
for (const [id, changes] of present) store.graph.updateNode(id, changes)
|
||||
computeAllLayouts(store.graph, store.state.currentPageId)
|
||||
store.requestRender()
|
||||
recordPreview(store, session, before, selection)
|
||||
}
|
||||
|
||||
function sameIds(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((id, index) => id === b[index])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ function fixLabel(fix: LintFix, messages: Messages): string {
|
|||
if (fix.kind === 'bind-variable') {
|
||||
return messages.bindVariable({ variable: fix.variableName })
|
||||
}
|
||||
if (fix.kind === 'convert-to-frame') return messages.fixConvertToFrame
|
||||
if (fix.kind === 'delete') return messages.fixDeleteLayer
|
||||
const changes = Object.entries(fix.changes)
|
||||
// Geometry changes only come from rounding to whole pixels.
|
||||
if (changes.length !== 1 || changes.some(([property]) => property in GEOMETRY_LABELS)) {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import {
|
|||
onBeforeUnmount,
|
||||
ref,
|
||||
shallowRef,
|
||||
nextTick,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import { JSX_REFERENCE } from '@open-pencil/design-jsx'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import { useEditorEvent, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
commitDOMCodeSession,
|
||||
|
|
@ -26,6 +28,7 @@ import { useCodeLayers } from '@/app/code/layers/use'
|
|||
import {
|
||||
createDesignJSXEditSession,
|
||||
previewDesignJSX,
|
||||
reapplyCanvasEdits,
|
||||
resetDesignJSXPreview,
|
||||
type DesignJSXEditSession,
|
||||
type DesignJSXLayerLine
|
||||
|
|
@ -65,6 +68,30 @@ let previewing = 0
|
|||
let previewedVersion: number | null = null
|
||||
/** The selection the code was generated for. */
|
||||
let followedSelection: string | null = null
|
||||
/** Code typed since the last preview started, which the canvas does not show yet. */
|
||||
let previewDue = false
|
||||
/**
|
||||
* Canvas edits made while code waits to render. Code replaced as a whole has no links to patch
|
||||
* them into until its preview links it again, so they are applied once more after that preview.
|
||||
*/
|
||||
let canvasEdits = new Map<string, Partial<SceneNode>>()
|
||||
|
||||
useEditorEvent('node:updated', (id, changes) => {
|
||||
if (!previewDue || previewing > 0 || source.value !== 'design-jsx') return
|
||||
canvasEdits.set(id, { ...canvasEdits.get(id), ...structuredClone(changes) })
|
||||
})
|
||||
|
||||
function takeCanvasEdits(): Map<string, Partial<SceneNode>> {
|
||||
const edits = canvasEdits
|
||||
canvasEdits = new Map()
|
||||
previewDue = false
|
||||
return edits
|
||||
}
|
||||
|
||||
/** Hands edits taken by a superseded or failed preview to the next one, behind any made since. */
|
||||
function returnCanvasEdits(edits: ReadonlyMap<string, Partial<SceneNode>>) {
|
||||
for (const [id, changes] of edits) canvasEdits.set(id, { ...changes, ...canvasEdits.get(id) })
|
||||
}
|
||||
|
||||
/** Set by "Write JSX": the editor stays open for new layers until something is selected. */
|
||||
const composing = ref(false)
|
||||
|
|
@ -141,6 +168,7 @@ async function commitCurrentSession(): Promise<void> {
|
|||
await pendingPreview
|
||||
await previewQueue
|
||||
updateVersion += 1
|
||||
takeCanvasEdits()
|
||||
const dom = domSession
|
||||
designSession.value = null
|
||||
domSession = null
|
||||
|
|
@ -157,6 +185,7 @@ async function commitCurrentSession(): Promise<void> {
|
|||
|
||||
async function runPreview(version: number): Promise<void> {
|
||||
if (version !== updateVersion || readOnly.value || !draft.value.trim()) return
|
||||
const edits = takeCanvasEdits()
|
||||
status.value = 'updating'
|
||||
error.value = ''
|
||||
previewing += 1
|
||||
|
|
@ -178,14 +207,26 @@ async function runPreview(version: number): Promise<void> {
|
|||
previewedVersion = store.state.sceneVersion
|
||||
followedSelection = selectionKey.value
|
||||
}
|
||||
if (version !== updateVersion) return
|
||||
if (version !== updateVersion) {
|
||||
returnCanvasEdits(edits)
|
||||
return
|
||||
}
|
||||
if (previewLayers) codeLayers.showPreview(previewLayers)
|
||||
if (!result.ok) {
|
||||
status.value = 'error'
|
||||
error.value = result.error
|
||||
// The code still waits to render, so its next preview applies these and later edits.
|
||||
previewDue = true
|
||||
returnCanvasEdits(edits)
|
||||
return
|
||||
}
|
||||
status.value = 'updated'
|
||||
const session = designSession.value
|
||||
if (!session || edits.size === 0) return
|
||||
// Once the code is linked again, the edits reach it as canvas changes do.
|
||||
await nextTick()
|
||||
if (version === updateVersion) reapplyCanvasEdits(store, session, edits)
|
||||
else returnCanvasEdits(edits)
|
||||
}
|
||||
|
||||
const schedulePreview = useDebounceFn(
|
||||
|
|
@ -204,6 +245,7 @@ function updateDraft(value: string, origin: 'user' | 'layers'): void {
|
|||
edited.value = true
|
||||
error.value = ''
|
||||
updateVersion += 1
|
||||
previewDue = true
|
||||
pendingPreview = schedulePreview(updateVersion)
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +253,7 @@ async function resetDraft(): Promise<void> {
|
|||
updateVersion += 1
|
||||
await pendingPreview
|
||||
await previewQueue
|
||||
takeCanvasEdits()
|
||||
const design = designSession.value
|
||||
const dom = domSession
|
||||
designSession.value = null
|
||||
|
|
@ -255,7 +298,7 @@ function showCanvas(next: Exclude<CodeSource, 'html-css'>) {
|
|||
* generated again; code the person wrote is patched where layers changed, keeping the rest as
|
||||
* written. Another selection starts over with its generated code.
|
||||
*/
|
||||
function followCanvas() {
|
||||
function followCanvas(live = false) {
|
||||
const current = source.value
|
||||
if (!editorActive.value || current === 'html-css' || previewing > 0) return
|
||||
if (selectionKey.value !== followedSelection) {
|
||||
|
|
@ -266,15 +309,27 @@ function followCanvas() {
|
|||
showCanvas(current)
|
||||
return
|
||||
}
|
||||
if (store.state.sceneVersion === previewedVersion) return
|
||||
// A live preview leaves the scene version alone, so it is followed whatever the version.
|
||||
if (!live && store.state.sceneVersion === previewedVersion) return
|
||||
codeEditor.value?.patchFromLayers()
|
||||
}
|
||||
|
||||
watch([() => store.state.sceneVersion, selectionKey, source, editorActive], followCanvas, {
|
||||
watch([() => store.state.sceneVersion, selectionKey, source, editorActive], () => followCanvas(), {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
/** Dragging or scrubbing a value previews it; the code follows once per frame, like the Design panel. */
|
||||
let livePreviewFrame = 0
|
||||
useEditorEvent('node:previewUpdated', () => {
|
||||
if (livePreviewFrame) return
|
||||
livePreviewFrame = requestAnimationFrame(() => {
|
||||
livePreviewFrame = 0
|
||||
followCanvas(true)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(livePreviewFrame)
|
||||
void commitCurrentSession()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,16 @@ const styles = designCheck()
|
|||
class="size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<icon-lucide-frame
|
||||
v-else-if="row.action.kind === 'convert-to-frame'"
|
||||
class="size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<icon-lucide-trash-2
|
||||
v-else-if="row.action.kind === 'delete'"
|
||||
class="size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<icon-lucide-wand-sparkles v-else class="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Tip>
|
||||
|
|
|
|||
|
|
@ -352,3 +352,64 @@ test('a reorder that cannot move the code still writes layers added with it', as
|
|||
'Frame'
|
||||
)
|
||||
})
|
||||
|
||||
test('a canvas edit right after replacing all the code survives its preview', async () => {
|
||||
await buildScene(editor.page)
|
||||
await openCode(editor.page)
|
||||
const code = (await codeText(editor.page)).replace(/(name="Card"[^>]*?) h=\{160\}/, '$1 h={170}')
|
||||
await codeLine(editor.page, 'name="Card"').click()
|
||||
await editor.page.keyboard.press('ControlOrMeta+a')
|
||||
await editor.page.keyboard.insertText(code)
|
||||
// Before the preview runs, the replaced code has no links to patch.
|
||||
await editLayer(editor.page, 'Card', { width: 400 })
|
||||
|
||||
await expect.poll(() => cardSize(editor.page)).toBe('400x170')
|
||||
await expect(codeLine(editor.page, 'name="Card"')).toContainText('w={400}')
|
||||
await expect(codeLine(editor.page, 'name="Card"')).toContainText('h={170}')
|
||||
|
||||
// The edit applied again belongs to the code's undo step, which leaves the canvas edit.
|
||||
await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
|
||||
await expect.poll(() => cardSize(editor.page)).toBe('400x160')
|
||||
})
|
||||
|
||||
test('code follows a value while it is dragged and goes back when the drag is cancelled', async () => {
|
||||
await buildScene(editor.page)
|
||||
await openCode(editor.page)
|
||||
|
||||
const [during, after] = await editor.page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
const card = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Card')
|
||||
if (!store || !card) throw new Error('Card not found')
|
||||
const frames = async (count: number) => {
|
||||
for (let i = 0; i < count; i++) await new Promise(requestAnimationFrame)
|
||||
}
|
||||
const code = () => document.querySelector('[data-slot="code-editor"] .cm-content')?.textContent
|
||||
const preview = store.beginNodePreview('Resize')
|
||||
preview.update(card.id, { width: 360 })
|
||||
await frames(3)
|
||||
const live = code()
|
||||
preview.cancel()
|
||||
await frames(3)
|
||||
return [live, code()]
|
||||
})
|
||||
|
||||
expect(during).toContain('w={360}')
|
||||
expect(after).toContain('w={320}')
|
||||
})
|
||||
|
||||
test('a canvas edit while replaced code does not render survives the corrected code', async () => {
|
||||
await buildScene(editor.page)
|
||||
await openCode(editor.page)
|
||||
const valid = (await codeText(editor.page)).replace(/(name="Card"[^>]*?) h=\{160\}/, '$1 h={170}')
|
||||
await codeLine(editor.page, 'name="Card"').click()
|
||||
await editor.page.keyboard.press('ControlOrMeta+a')
|
||||
await editor.page.keyboard.insertText(`${valid}\n<Frame`)
|
||||
await expect(editor.page.getByTestId('code-panel-status')).toHaveText('Preview failed')
|
||||
|
||||
await editLayer(editor.page, 'Card', { width: 400 })
|
||||
await editor.page.keyboard.press('ControlOrMeta+a')
|
||||
await editor.page.keyboard.insertText(valid)
|
||||
|
||||
await expect.poll(() => cardSize(editor.page)).toBe('400x170')
|
||||
await expect(codeLine(editor.page, 'name="Card"')).toContainText('w={400}')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ describe('property access', () => {
|
|||
expect(rect.fills[0].color.r).toBe(1)
|
||||
})
|
||||
|
||||
test('paints default to full opacity and visible, as in Figma', () => {
|
||||
const api = createAPI()
|
||||
const rect = api.createRectangle()
|
||||
// Plugin scripts may leave out `opacity` and `visible`; Figma fills them in.
|
||||
Reflect.set(rect, 'fills', [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }])
|
||||
Reflect.set(rect, 'strokes', [{ type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 0.5 }])
|
||||
expect(rect.fills[0]).toMatchObject({ opacity: 1, visible: true })
|
||||
expect(rect.strokes[0]).toMatchObject({ opacity: 0.5, visible: true })
|
||||
})
|
||||
|
||||
test('opacity get/set', () => {
|
||||
const api = createAPI()
|
||||
const frame = api.createFrame()
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ export function createDevServerOptions(host: string | undefined, rootDir: string
|
|||
port: 1421
|
||||
}
|
||||
: undefined,
|
||||
// Vite forwards browser logs to the terminal when an agent starts it. Vue warnings carry the
|
||||
// component's props, and serializing the editor state behind them freezes the tab, so only
|
||||
// errors are forwarded.
|
||||
forwardConsole: { unhandledErrors: true, logLevels: ['error'] },
|
||||
watch: {
|
||||
// Ignore nested checkouts, not an active checkout whose own path contains .worktrees.
|
||||
ignored: [...WATCH_IGNORED, `${normalizePath(resolve(rootDir, '.worktrees'))}/**`]
|
||||
|
|
|
|||
Loading…
Reference in a new issue