feat(editor): add outline stroke command
This commit is contained in:
parent
655f4fcb89
commit
1c8fe1ee8b
|
|
@ -218,6 +218,10 @@
|
|||
"id": "selection.outlineText",
|
||||
"label": "Outline text"
|
||||
},
|
||||
{
|
||||
"id": "selection.outlineStroke",
|
||||
"label": "Outline stroke"
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
|||
import { getTextOutlineSupport } from '#core/text/outlines'
|
||||
|
||||
import { makeArcPath } from './fills'
|
||||
import { textNodeToOutlinePath } from './text-outlines'
|
||||
import type { SkiaRenderer } from './renderer'
|
||||
import { nodeHasRadius } from './shapes'
|
||||
import { textNodeToOutlinePath } from './text-outlines'
|
||||
|
||||
const BOOLEAN_PATH_OP: Record<
|
||||
NonNullable<SceneNode['booleanOperation']>,
|
||||
|
|
@ -82,6 +82,10 @@ function nodeHasVisibleFill(node: SceneNode): boolean {
|
|||
return node.fills.some((fill) => fill.visible)
|
||||
}
|
||||
|
||||
export function nodeHasVisibleStroke(node: SceneNode): boolean {
|
||||
return node.strokes.some((stroke) => stroke.visible && stroke.weight > 0)
|
||||
}
|
||||
|
||||
function addVisibleStrokeOutlines(target: Path, source: Path, node: SceneNode): void {
|
||||
for (const stroke of node.strokes) {
|
||||
if (!stroke.visible || stroke.weight <= 0) continue
|
||||
|
|
@ -100,11 +104,36 @@ function canContainFlattenableChildren(node: SceneNode): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
function appendVisibleChildPaths(
|
||||
r: SkiaRenderer,
|
||||
graph: SceneGraph,
|
||||
parent: SceneNode,
|
||||
target: Path,
|
||||
makeChildPath: (child: SceneNode) => Path | null,
|
||||
failOnMissing: boolean
|
||||
): boolean | null {
|
||||
let hasPath = false
|
||||
for (const childId of parent.childIds) {
|
||||
const child = graph.getNode(childId)
|
||||
if (!child || !child.visible) continue
|
||||
const childPath = makeChildPath(child)
|
||||
if (!childPath) {
|
||||
if (failOnMissing) return null
|
||||
continue
|
||||
}
|
||||
childPath.transform(nodePathTransform(r, child))
|
||||
target.addPath(childPath)
|
||||
childPath.delete()
|
||||
hasPath = true
|
||||
}
|
||||
return hasPath
|
||||
}
|
||||
|
||||
function containerSourcePath(r: SkiaRenderer, node: SceneNode, graph: SceneGraph): Path | null {
|
||||
const path = new r.ck.Path()
|
||||
let hasPath = false
|
||||
|
||||
if (nodeHasVisibleFill(node) || node.strokes.some((stroke) => stroke.visible)) {
|
||||
if (nodeHasVisibleFill(node) || nodeHasVisibleStroke(node)) {
|
||||
const ownPath = baseShapePath(r, node)
|
||||
if (ownPath) {
|
||||
if (nodeHasVisibleFill(node)) path.addPath(ownPath)
|
||||
|
|
@ -114,19 +143,19 @@ function containerSourcePath(r: SkiaRenderer, node: SceneNode, graph: SceneGraph
|
|||
}
|
||||
}
|
||||
|
||||
for (const childId of node.childIds) {
|
||||
const child = graph.getNode(childId)
|
||||
if (!child || !child.visible) continue
|
||||
const childPath = makeBooleanSourcePath(r, child, graph)
|
||||
if (!childPath) {
|
||||
path.delete()
|
||||
return null
|
||||
}
|
||||
childPath.transform(nodePathTransform(r, child))
|
||||
path.addPath(childPath)
|
||||
childPath.delete()
|
||||
hasPath = true
|
||||
const childPaths = appendVisibleChildPaths(
|
||||
r,
|
||||
graph,
|
||||
node,
|
||||
path,
|
||||
(child) => makeBooleanSourcePath(r, child, graph),
|
||||
true
|
||||
)
|
||||
if (childPaths === null) {
|
||||
path.delete()
|
||||
return null
|
||||
}
|
||||
hasPath ||= childPaths
|
||||
|
||||
if (!hasPath) {
|
||||
path.delete()
|
||||
|
|
@ -147,10 +176,54 @@ export function makeBooleanSourcePath(
|
|||
|
||||
const path = baseShapePath(r, node)
|
||||
if (!path) return null
|
||||
if (node.strokes.some((stroke) => stroke.visible)) addVisibleStrokeOutlines(path, path, node)
|
||||
if (nodeHasVisibleStroke(node)) addVisibleStrokeOutlines(path, path, node)
|
||||
return path
|
||||
}
|
||||
|
||||
export function makeStrokeOutlinePath(
|
||||
r: SkiaRenderer,
|
||||
node: SceneNode,
|
||||
graph: SceneGraph
|
||||
): Path | null {
|
||||
if (!canMakeBooleanSourcePath(node)) return null
|
||||
if (canContainFlattenableChildren(node)) {
|
||||
const path = new r.ck.Path()
|
||||
let hasPath = false
|
||||
if (nodeHasVisibleStroke(node)) {
|
||||
const ownPath = baseShapePath(r, node)
|
||||
if (ownPath) {
|
||||
addVisibleStrokeOutlines(path, ownPath, node)
|
||||
ownPath.delete()
|
||||
hasPath = true
|
||||
}
|
||||
}
|
||||
hasPath ||=
|
||||
appendVisibleChildPaths(
|
||||
r,
|
||||
graph,
|
||||
node,
|
||||
path,
|
||||
(child) => makeStrokeOutlinePath(r, child, graph),
|
||||
false
|
||||
) ?? false
|
||||
if (hasPath) return path
|
||||
path.delete()
|
||||
return null
|
||||
}
|
||||
|
||||
if (!nodeHasVisibleStroke(node)) return null
|
||||
const path =
|
||||
node.type === 'BOOLEAN_OPERATION'
|
||||
? makeBooleanOperationPath(r, node, graph)
|
||||
: baseShapePath(r, node)
|
||||
if (!path) return null
|
||||
if (node.type === 'LINE') return path
|
||||
const outline = new r.ck.Path()
|
||||
addVisibleStrokeOutlines(outline, path, node)
|
||||
path.delete()
|
||||
return outline
|
||||
}
|
||||
|
||||
function transformedShapePath(r: SkiaRenderer, child: SceneNode, graph: SceneGraph): Path | null {
|
||||
const path = makeBooleanSourcePath(r, child, graph)
|
||||
if (!path) return null
|
||||
|
|
|
|||
|
|
@ -2,17 +2,29 @@ import { parseSVGPath } from '#core/io/formats/svg/parse-path'
|
|||
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
||||
import { copyFills } from '#core/scene-graph/copy'
|
||||
|
||||
import { makeBooleanSourcePath, nodePathTransform } from './boolean'
|
||||
import { makeBooleanSourcePath, makeStrokeOutlinePath, nodePathTransform } from './boolean'
|
||||
import type { SkiaRenderer } from './renderer'
|
||||
|
||||
export function flattenNodesToVectorProps(
|
||||
type VectorFlattenProps = Pick<
|
||||
SceneNode,
|
||||
'name' | 'x' | 'y' | 'width' | 'height' | 'fills' | 'vectorNetwork'
|
||||
>
|
||||
|
||||
type NodePathFactory = (
|
||||
renderer: SkiaRenderer,
|
||||
graph: SceneGraph,
|
||||
nodes: SceneNode[]
|
||||
): Pick<SceneNode, 'name' | 'x' | 'y' | 'width' | 'height' | 'fills' | 'vectorNetwork'> | null {
|
||||
node: SceneNode
|
||||
) => ReturnType<typeof makeBooleanSourcePath>
|
||||
|
||||
function nodesToVectorProps(
|
||||
renderer: SkiaRenderer,
|
||||
graph: SceneGraph,
|
||||
nodes: SceneNode[],
|
||||
makeNodePath: NodePathFactory
|
||||
): VectorFlattenProps | null {
|
||||
const path = new renderer.ck.Path()
|
||||
for (const node of nodes) {
|
||||
const nodePath = makeBooleanSourcePath(renderer, node, graph)
|
||||
const nodePath = makeNodePath(renderer, graph, node)
|
||||
if (!nodePath) {
|
||||
path.delete()
|
||||
return null
|
||||
|
|
@ -42,3 +54,23 @@ export function flattenNodesToVectorProps(
|
|||
vectorNetwork
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenNodesToVectorProps(
|
||||
renderer: SkiaRenderer,
|
||||
graph: SceneGraph,
|
||||
nodes: SceneNode[]
|
||||
): VectorFlattenProps | null {
|
||||
return nodesToVectorProps(renderer, graph, nodes, (r, g, node) =>
|
||||
makeBooleanSourcePath(r, node, g)
|
||||
)
|
||||
}
|
||||
|
||||
export function outlineStrokeNodesToVectorProps(
|
||||
renderer: SkiaRenderer,
|
||||
graph: SceneGraph,
|
||||
nodes: SceneNode[]
|
||||
): VectorFlattenProps | null {
|
||||
return nodesToVectorProps(renderer, graph, nodes, (r, g, node) =>
|
||||
makeStrokeOutlinePath(r, node, g)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export { canMakeBooleanSourceNode, canMakeBooleanSourcePath } from './boolean'
|
||||
export { canMakeBooleanSourceNode, canMakeBooleanSourcePath, nodeHasVisibleStroke } from './boolean'
|
||||
export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer'
|
||||
export { getAbsolutePositionFull, getAbsoluteRotation, getWorldHandles } from './coordinate'
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export function createStructureBridge(structure: StructureActions, selection: Se
|
|||
structure.booleanOperationSelected(selection.getSelectedNodes(), operation),
|
||||
flattenSelected: () => structure.flattenSelected(selection.getSelectedNodes()),
|
||||
outlineTextSelected: () => structure.outlineTextSelected(selection.getSelectedNodes()),
|
||||
outlineStrokeSelected: () => structure.outlineStrokeSelected(selection.getSelectedNodes()),
|
||||
ungroupSelected: () => structure.ungroupSelected(selection.getSelectedNode())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import {
|
|||
type BooleanOperation
|
||||
} from './structure/boolean'
|
||||
import { wrapSelectionInContainer as wrapSelectionInContainerImpl } from './structure/container-wrap'
|
||||
import { flattenSelected as flattenSelectedImpl } from './structure/flatten'
|
||||
import {
|
||||
flattenSelected as flattenSelectedImpl,
|
||||
outlineStrokeSelected as outlineStrokeSelectedImpl
|
||||
} from './structure/flatten'
|
||||
import { ungroupSelected as ungroupImpl } from './structure/group'
|
||||
import { createStructureReorderActions } from './structure/reorder'
|
||||
import { createStructureStateActions } from './structure/state'
|
||||
|
|
@ -74,8 +77,13 @@ export function createStructureActions(ctx: EditorContext) {
|
|||
}
|
||||
|
||||
function outlineTextSelected(selectedNodes: SceneNode[]) {
|
||||
if (selectedNodes.length === 0 || selectedNodes.some((node) => node.type !== 'TEXT')) return null
|
||||
return flattenSelectedImpl(ctx, selectedNodes, 'Outline text')
|
||||
if (selectedNodes.length === 0 || selectedNodes.some((node) => node.type !== 'TEXT'))
|
||||
return null
|
||||
return flattenSelectedImpl(ctx, selectedNodes, { label: 'Outline text' })
|
||||
}
|
||||
|
||||
function outlineStrokeSelected(selectedNodes: SceneNode[]) {
|
||||
return outlineStrokeSelectedImpl(ctx, selectedNodes)
|
||||
}
|
||||
|
||||
function moveToPage(pageId: string) {
|
||||
|
|
@ -107,6 +115,7 @@ export function createStructureActions(ctx: EditorContext) {
|
|||
ungroupSelected,
|
||||
flattenSelected,
|
||||
outlineTextSelected,
|
||||
outlineStrokeSelected,
|
||||
...stateActions,
|
||||
moveToPage,
|
||||
renameNode
|
||||
|
|
|
|||
|
|
@ -1,28 +1,48 @@
|
|||
import { canMakeBooleanSourceNode } from '#core/canvas/boolean'
|
||||
import { flattenNodesToVectorProps } from '#core/canvas/flatten'
|
||||
import { canMakeBooleanSourceNode, nodeHasVisibleStroke } from '#core/canvas/boolean'
|
||||
import { flattenNodesToVectorProps, outlineStrokeNodesToVectorProps } from '#core/canvas/flatten'
|
||||
import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history'
|
||||
import type { EditorContext } from '#core/editor/types'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
import { selectedNodesInSharedParent } from './selection'
|
||||
|
||||
export function flattenSelected(ctx: EditorContext, selectedNodes: SceneNode[], label = 'Flatten') {
|
||||
type VectorPropsFactory = typeof flattenNodesToVectorProps
|
||||
|
||||
type FlattenOptions = {
|
||||
label?: string
|
||||
canFlattenNode?: (node: SceneNode) => boolean
|
||||
vectorPropsFactory?: VectorPropsFactory
|
||||
}
|
||||
|
||||
export function flattenSelected(
|
||||
ctx: EditorContext,
|
||||
selectedNodes: SceneNode[],
|
||||
options: FlattenOptions = {}
|
||||
) {
|
||||
const label = options.label ?? 'Flatten'
|
||||
const canFlattenNode =
|
||||
options.canFlattenNode ?? ((node: SceneNode) => canMakeBooleanSourceNode(node, ctx.graph))
|
||||
const vectorPropsFactory = options.vectorPropsFactory ?? flattenNodesToVectorProps
|
||||
const renderer = ctx.getRenderer()
|
||||
if (!renderer) return null
|
||||
|
||||
const selection = selectedNodesInSharedParent(ctx, selectedNodes)
|
||||
if (!selection) return null
|
||||
const { topLevel, parentId, parent } = selection
|
||||
if (topLevel.some((node) => !canMakeBooleanSourceNode(node, ctx.graph))) return null
|
||||
if (topLevel.some((node) => !canFlattenNode(node))) return null
|
||||
|
||||
const childIds = topLevel.map((node) => node.id)
|
||||
const childSnapshots = childIds.map((id) => ({ id, subtree: snapshotSubtree(ctx.graph, id) }))
|
||||
const prevSelection = new Set(ctx.state.selectedIds)
|
||||
const firstIndex = Math.min(...childIds.map((id) => parent.childIds.indexOf(id)))
|
||||
const vectorProps = flattenNodesToVectorProps(renderer, ctx.graph, topLevel)
|
||||
const vectorProps = vectorPropsFactory(renderer, ctx.graph, topLevel)
|
||||
if (!vectorProps) return null
|
||||
|
||||
const vector = ctx.graph.createNode('VECTOR', parentId, { ...vectorProps, name: label, strokes: [] })
|
||||
const vector = ctx.graph.createNode('VECTOR', parentId, {
|
||||
...vectorProps,
|
||||
name: label,
|
||||
strokes: []
|
||||
})
|
||||
const vectorSnapshot = structuredClone(vector)
|
||||
ctx.graph.insertChildAt(vector.id, parentId, firstIndex)
|
||||
for (const id of childIds) ctx.graph.deleteNode(id)
|
||||
|
|
@ -51,3 +71,12 @@ export function flattenSelected(ctx: EditorContext, selectedNodes: SceneNode[],
|
|||
|
||||
return vector.id
|
||||
}
|
||||
|
||||
export function outlineStrokeSelected(ctx: EditorContext, selectedNodes: SceneNode[]) {
|
||||
return flattenSelected(ctx, selectedNodes, {
|
||||
label: 'Outline stroke',
|
||||
canFlattenNode: (node) =>
|
||||
canMakeBooleanSourceNode(node, ctx.graph) && nodeHasVisibleStroke(node),
|
||||
vectorPropsFactory: outlineStrokeNodesToVectorProps
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const EDITOR_COMMAND_METADATA = {
|
|||
contextTestId: 'context-flatten'
|
||||
},
|
||||
'selection.outlineText': { contextTestId: 'context-outline-text' },
|
||||
'selection.outlineStroke': { contextTestId: 'context-outline-stroke' },
|
||||
'selection.moveToPage': {},
|
||||
'view.zoom100': { keybinding: '$mod+Digit0' },
|
||||
'view.zoomFit': { keybinding: ['$mod+Digit1', 'Shift+Digit1'] },
|
||||
|
|
|
|||
|
|
@ -207,6 +207,14 @@ export function createSelectionCommands({
|
|||
enabled: capabilities.canOutlineText,
|
||||
run: () => editor.outlineTextSelected()
|
||||
},
|
||||
'selection.outlineStroke': {
|
||||
id: 'selection.outlineStroke',
|
||||
get label() {
|
||||
return t.value.outlineStroke
|
||||
},
|
||||
enabled: capabilities.canOutlineStroke,
|
||||
run: () => editor.outlineStrokeSelected()
|
||||
},
|
||||
'selection.moveToPage': {
|
||||
id: 'selection.moveToPage',
|
||||
get label() {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type EditorCommandId =
|
|||
| 'selection.booleanExclude'
|
||||
| 'selection.flatten'
|
||||
| 'selection.outlineText'
|
||||
| 'selection.outlineStroke'
|
||||
| 'selection.moveToPage'
|
||||
| 'view.zoom100'
|
||||
| 'view.zoomFit'
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ const CANVAS_MENU_GROUPS = [
|
|||
'selection.ungroupWhenGroup',
|
||||
'selection.wrapInAutoLayout',
|
||||
'selection.flatten',
|
||||
'selection.outlineText'
|
||||
'selection.outlineText',
|
||||
'selection.outlineStroke'
|
||||
],
|
||||
['selection.componentAction', 'selection.componentSetAction', 'selection.instanceActions'],
|
||||
['selection.toggleVisibility', 'selection.toggleLock'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { computed } from 'vue'
|
||||
|
||||
import { canMakeBooleanSourceNode } from '@open-pencil/core/canvas'
|
||||
import { canMakeBooleanSourceNode, nodeHasVisibleStroke } from '@open-pencil/core/canvas'
|
||||
|
||||
import { useSelectionState } from '#vue/editor/selection-state/use'
|
||||
import { useSceneComputed } from '#vue/internal/scene-computed/use'
|
||||
|
|
@ -51,6 +51,15 @@ export function useSelectionCapabilities() {
|
|||
nodes.every((node) => node.type === 'TEXT' && canMakeBooleanSourceNode(node, editor.graph))
|
||||
)
|
||||
}),
|
||||
canOutlineStroke: useSceneComputed(() => {
|
||||
const nodes = editor.getSelectedNodes()
|
||||
return (
|
||||
nodes.length > 0 &&
|
||||
nodes.every(
|
||||
(node) => nodeHasVisibleStroke(node) && canMakeBooleanSourceNode(node, editor.graph)
|
||||
)
|
||||
)
|
||||
}),
|
||||
canGoToMainComponent: computed(() => selection.isInstance.value),
|
||||
canCreateInstance: computed(() => selectedNode.value?.type === 'COMPONENT'),
|
||||
canMoveToPage: useSceneComputed(() => hasSelection.value && editor.graph.getPages().length > 1),
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export const commandMessages = i18n('commands', {
|
|||
excludeSelection: 'Exclude selection',
|
||||
flattenSelection: 'Flatten',
|
||||
outlineText: 'Outline text',
|
||||
outlineStroke: 'Outline stroke',
|
||||
booleanOperations: 'Boolean operations',
|
||||
flipHorizontal: 'Flip horizontal',
|
||||
flipVertical: 'Flip vertical',
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "Horizontal spiegeln",
|
||||
"flipVertical": "Vertikal spiegeln",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "Verschieben",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "Voltear horizontalmente",
|
||||
"flipVertical": "Voltear verticalmente",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "Mover",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "Retourner horizontalement",
|
||||
"flipVertical": "Retourner verticalement",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "Déplacer",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "Rifletti orizzontalmente",
|
||||
"flipVertical": "Rifletti verticalmente",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "Sposta",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "Odbij poziomo",
|
||||
"flipVertical": "Odbij pionowo",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "Przesuń",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "Отразить по горизонтали",
|
||||
"flipVertical": "Отразить по вертикали",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "Перемещение",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"flipHorizontal": "水平翻转",
|
||||
"flipVertical": "垂直翻转",
|
||||
"flattenSelection": "Flatten",
|
||||
"outlineText": "Outline text"
|
||||
"outlineText": "Outline text",
|
||||
"outlineStroke": "Outline stroke"
|
||||
},
|
||||
"tools": {
|
||||
"move": "移动",
|
||||
|
|
|
|||
|
|
@ -179,6 +179,11 @@ export const APP_MENU_SCHEMA = [
|
|||
label: 'Outline text',
|
||||
command: 'selection.outlineText'
|
||||
},
|
||||
{
|
||||
id: 'selection.outlineStroke',
|
||||
label: 'Outline stroke',
|
||||
command: 'selection.outlineStroke'
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
id: 'selection.createComponent',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const COMMAND_MENU_IDS = new Set<string>([
|
|||
'selection.booleanExclude',
|
||||
'selection.flatten',
|
||||
'selection.outlineText',
|
||||
'selection.outlineStroke',
|
||||
'selection.bringToFront',
|
||||
'selection.sendToBack',
|
||||
'view.zoom100',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import IconCombine from '~icons/lucide/combine'
|
|||
import IconCopyMinus from '~icons/lucide/copy-minus'
|
||||
import IconCopyX from '~icons/lucide/copy-x'
|
||||
import IconListCollapse from '~icons/lucide/list-collapse'
|
||||
import IconSpline from '~icons/lucide/spline'
|
||||
import IconTypeOutline from '~icons/lucide/type-outline'
|
||||
import IconSquaresIntersect from '~icons/lucide/squares-intersect'
|
||||
import {
|
||||
|
|
@ -64,7 +65,8 @@ const booleanCommandIcons = {
|
|||
'selection.booleanIntersect': IconSquaresIntersect,
|
||||
'selection.booleanExclude': IconCopyX,
|
||||
'selection.flatten': IconListCollapse,
|
||||
'selection.outlineText': IconTypeOutline
|
||||
'selection.outlineText': IconTypeOutline,
|
||||
'selection.outlineStroke': IconSpline
|
||||
} satisfies Partial<Record<EditorCommandId, Component>>
|
||||
|
||||
function contextCommandTestId(id: EditorCommandId | undefined): string | undefined {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ test('context menu shows expected items', async () => {
|
|||
await expect(contextItem('context-toggle-lock')).toBeVisible()
|
||||
await expect(contextItem('context-flatten')).toBeVisible()
|
||||
await expect(contextItem('context-outline-text')).toBeVisible()
|
||||
await expect(contextItem('context-outline-stroke')).toBeVisible()
|
||||
await expect(contextItem('context-boolean-union')).toHaveCount(0)
|
||||
|
||||
await editor.page.keyboard.press('Escape')
|
||||
|
|
|
|||
|
|
@ -36,8 +36,18 @@ describe('flattenSelected', () => {
|
|||
test('converts selected shapes into a vector path', async () => {
|
||||
const { editor, surface } = await createEditorWithRenderer()
|
||||
const pageId = editor.state.currentPageId
|
||||
const first = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40 })
|
||||
const second = editor.graph.createNode('ELLIPSE', pageId, { x: 40, y: 30, width: 50, height: 40 })
|
||||
const first = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 50,
|
||||
height: 40
|
||||
})
|
||||
const second = editor.graph.createNode('ELLIPSE', pageId, {
|
||||
x: 40,
|
||||
y: 30,
|
||||
width: 50,
|
||||
height: 40
|
||||
})
|
||||
|
||||
editor.select([first.id, second.id])
|
||||
editor.flattenSelected()
|
||||
|
|
@ -55,6 +65,53 @@ describe('flattenSelected', () => {
|
|||
surface.delete()
|
||||
})
|
||||
|
||||
test('outlines visible strokes into a vector path', async () => {
|
||||
const { editor, surface } = await createEditorWithRenderer()
|
||||
const pageId = editor.state.currentPageId
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 40,
|
||||
height: 30,
|
||||
fills: [TRANSPARENT],
|
||||
strokes: [{ type: 'SOLID', color: BLACK, weight: 8, opacity: 1, visible: true }]
|
||||
})
|
||||
|
||||
editor.select([rect.id])
|
||||
editor.outlineStrokeSelected()
|
||||
|
||||
const [vectorId] = [...editor.state.selectedIds]
|
||||
const vector = editor.graph.getNode(vectorId)
|
||||
expect(vector?.type).toBe('VECTOR')
|
||||
expect(vector?.name).toBe('Outline stroke')
|
||||
expect(vector?.x).toBe(16)
|
||||
expect(vector?.y).toBe(26)
|
||||
expect(vector?.width).toBe(48)
|
||||
expect(vector?.height).toBe(38)
|
||||
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
|
||||
expect(editor.graph.getNode(rect.id)).toBeUndefined()
|
||||
surface.delete()
|
||||
})
|
||||
|
||||
test('does not outline fill-only shapes as strokes', async () => {
|
||||
const { editor, surface } = await createEditorWithRenderer()
|
||||
const pageId = editor.state.currentPageId
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 40,
|
||||
height: 30,
|
||||
strokes: []
|
||||
})
|
||||
|
||||
editor.select([rect.id])
|
||||
const result = editor.outlineStrokeSelected()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(editor.graph.getNode(rect.id)?.type).toBe('RECTANGLE')
|
||||
surface.delete()
|
||||
})
|
||||
|
||||
test('outlines loaded text through the shared text outline path', async () => {
|
||||
await loadInterRegular()
|
||||
const { editor, surface } = await createEditorWithRenderer()
|
||||
|
|
@ -177,7 +234,10 @@ describe('flattenSelected', () => {
|
|||
test('does not flatten unsupported text nodes', async () => {
|
||||
const { editor, surface } = await createEditorWithRenderer()
|
||||
const pageId = editor.state.currentPageId
|
||||
const text = editor.graph.createNode('TEXT', pageId, { text: 'Nope', fontFamily: 'Definitely Missing Font' })
|
||||
const text = editor.graph.createNode('TEXT', pageId, {
|
||||
text: 'Nope',
|
||||
fontFamily: 'Definitely Missing Font'
|
||||
})
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId)
|
||||
|
||||
editor.select([text.id, rect.id])
|
||||
|
|
@ -194,7 +254,16 @@ describe('flattenSelected', () => {
|
|||
const pageId = editor.state.currentPageId
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId)
|
||||
const image = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
fills: [{ type: 'IMAGE', imageHash: 'image', imageScaleMode: 'FILL', color: TRANSPARENT, opacity: 1, visible: true }]
|
||||
fills: [
|
||||
{
|
||||
type: 'IMAGE',
|
||||
imageHash: 'image',
|
||||
imageScaleMode: 'FILL',
|
||||
color: TRANSPARENT,
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
editor.select([rect.id, image.id])
|
||||
editor.groupSelected()
|
||||
|
|
@ -210,8 +279,18 @@ describe('flattenSelected', () => {
|
|||
test('flattens visual descendants from groups', async () => {
|
||||
const { editor, surface } = await createEditorWithRenderer()
|
||||
const pageId = editor.state.currentPageId
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40 })
|
||||
const ellipse = editor.graph.createNode('ELLIPSE', pageId, { x: 70, y: 20, width: 30, height: 30 })
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 50,
|
||||
height: 40
|
||||
})
|
||||
const ellipse = editor.graph.createNode('ELLIPSE', pageId, {
|
||||
x: 70,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 30
|
||||
})
|
||||
editor.select([rect.id, ellipse.id])
|
||||
editor.groupSelected()
|
||||
const [groupId] = [...editor.state.selectedIds]
|
||||
|
|
@ -235,7 +314,9 @@ describe('flattenSelected', () => {
|
|||
width: 50,
|
||||
height: 40,
|
||||
fills: [],
|
||||
strokes: [{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 8, align: 'CENTER' }]
|
||||
strokes: [
|
||||
{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 8, align: 'CENTER' }
|
||||
]
|
||||
})
|
||||
|
||||
editor.select([rect.id])
|
||||
|
|
@ -263,7 +344,12 @@ describe('flattenSelected', () => {
|
|||
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, vectorId, after.id])
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, first.id, second.id, after.id])
|
||||
expect(editor.graph.getNode(pageId)?.childIds).toEqual([
|
||||
before.id,
|
||||
first.id,
|
||||
second.id,
|
||||
after.id
|
||||
])
|
||||
expect(editor.state.selectedIds).toEqual(new Set([first.id, second.id]))
|
||||
|
||||
editor.undo.redo()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ describe('buildCanvasContextMenu', () => {
|
|||
'selection.wrapInAutoLayout',
|
||||
'selection.flatten',
|
||||
'selection.outlineText',
|
||||
'selection.outlineStroke',
|
||||
'---',
|
||||
'selection.createComponent',
|
||||
'---',
|
||||
|
|
|
|||
Loading…
Reference in a new issue