feat(editor): add blend mode and mask controls

- Add selection mask commands, shortcuts, context-menu access, and mask type controls
- Add mixed-selection blend mode editing with single-step undo
- Document the headless mask composable and localize the new controls
This commit is contained in:
Danila Poyarkov 2026-07-10 17:01:28 +03:00
parent 1750199b9c
commit 0aae91f4f2
38 changed files with 633 additions and 66 deletions

View file

@ -11,6 +11,7 @@
- Add richer Design JSX authoring for components, variables, structured fills, gradients, shadows, and blur effects.
- Add overlap analysis for finding layout collisions and overflowing children from the CLI, AI tools, and MCP.
- Add saved per-node export settings for repeat exports.
- Add Design panel controls for layer blend modes and alpha, vector, and luminance masks.
- Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.
- Publish lower-level SceneGraph, Pen, Kiwi, Fig, and DOM/CSS functionality through clearer package boundaries for SDK and automation consumers.

View file

@ -27,6 +27,7 @@ These are the main composables most `@open-pencil/vue` consumers will use.
- [usePosition](./use-position)
- [useLayout](./use-layout)
- [useAppearance](./use-appearance)
- [useMask](./use-mask)
- [useTypography](./use-typography)
- [useExport](./use-export)
- [useFillControls](./use-fill-controls)

View file

@ -0,0 +1,28 @@
---
title: useMask
description: Read and update mask state for the selected node.
---
# useMask
`useMask()` provides headless state and actions for mask controls in a property panel.
## Usage
```ts
import { useMask } from '@open-pencil/vue'
const { active, maskType, setMaskType } = useMask()
```
- `active` is `true` when exactly one selected node is being used as a mask.
- `maskType` is the selected node's `ALPHA`, `VECTOR`, or `LUMINANCE` mask type.
- `setMaskType(type)` updates the active mask with undo support.
Use the `selection.toggleMask` editor command to turn masking on or off so toolbar, context-menu, and keyboard behavior stays consistent.
## Related APIs
- [useAppearance](./use-appearance)
- [useEditorCommands](./use-editor-commands)
- [SDK API Overview](../)

View file

@ -115,6 +115,7 @@ These are the main APIs most SDK consumers should start with.
- `usePosition()`
- `useLayout()`
- `useAppearance()`
- `useMask()`
- `useTypography()`
- `useExport()`
- `useFillControls()`

View file

@ -2,7 +2,7 @@ import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
import { MIXED, type MixedValue } from '#vue/controls/node-props/use'
@ -46,16 +46,42 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
return v === MIXED ? MIXED : Math.round(v * 100)
})
const blendModeValue = computed(() => {
const v = merged('blendMode')
return v === MIXED ? MIXED : v
})
const visibilityState = computed<'visible' | 'hidden' | 'mixed'>(() => {
const v = merged('visible')
if (v === MIXED) return 'mixed'
return v ? 'visible' : 'hidden'
})
return { hasCornerRadius, independentCorners, cornerRadiusValue, opacityPercent, visibilityState }
return {
hasCornerRadius,
independentCorners,
cornerRadiusValue,
opacityPercent,
blendModeValue,
visibilityState
}
}
export function createAppearanceActions({ editor, node, nodes, isMulti }: AppearanceActionOptions) {
function setBlendMode(value: BlendMode) {
const selected = node.value
const targets = isMulti.value ? nodes.value : []
if (!isMulti.value && selected) targets.push(selected)
const changed = targets.filter((target) => target.blendMode !== value)
if (changed.length === 0) return
editor.undo.runBatch('Change blend mode', () => {
for (const target of changed) {
editor.updateNodeWithUndo(target.id, { blendMode: value }, 'Change blend mode')
}
})
}
function toggleVisibility() {
if (isMulti.value) {
const liveNodes = nodes.value
@ -135,5 +161,11 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear
}
}
return { toggleVisibility, toggleIndependentCorners, updateCornerProp, commitCornerProp }
return {
setBlendMode,
toggleVisibility,
toggleIndependentCorners,
updateCornerProp,
commitCornerProp
}
}

View file

@ -0,0 +1,23 @@
import { computed } from 'vue'
import type { MaskType } from '@open-pencil/scene-graph'
import { useEditor } from '#vue/editor/context'
import { useSelectionState } from '#vue/editor/selection-state/use'
/** Headless state and actions for the selected mask node. */
export function useMask() {
const editor = useEditor()
const { selectedNode } = useSelectionState()
const active = computed(() => selectedNode.value?.isMask === true)
const maskType = computed(() => selectedNode.value?.maskType ?? 'ALPHA')
function setMaskType(value: MaskType) {
const node = selectedNode.value
if (!node?.isMask || node.maskType === value) return
editor.updateNodeWithUndo(node.id, { maskType: value }, 'Change mask type')
}
return { active, maskType, setMaskType }
}

View file

@ -33,6 +33,11 @@ export const EDITOR_COMMAND_METADATA = {
'selection.goToMainComponent': {},
'selection.createInstance': {},
'selection.wrapInAutoLayout': { shortcut: 'SHIFT+A', keybinding: 'Shift+KeyA' },
'selection.toggleMask': {
shortcut: 'MOD+ALT+M',
keybinding: ['Control+Meta+KeyM', '$mod+Alt+KeyM'],
contextTestId: 'context-toggle-mask'
},
'selection.bringToFront': {
shortcut: ']',
keybinding: 'BracketRight',

View file

@ -1,8 +1,28 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import type { EditorCommandMapOptions } from './context'
import type { EditorCommand, EditorCommandId } from './types'
type SelectionCommandId = Extract<EditorCommandId, `selection.${string}`>
function selectedMaskTarget(nodes: SceneNode[], editor: EditorCommandMapOptions['editor']) {
if (nodes.length === 0) return null
if (nodes.length === 1) return nodes[0]
const [first] = nodes
const byId = new Map(nodes.map((node) => [node.id, node]))
const selectedParents = new Set(nodes.map((node) => node.parentId))
if (selectedParents.size !== 1) return first
const parentId = first.parentId
if (!parentId) return first
for (const sibling of editor.graph.getChildren(parentId)) {
const selected = byId.get(sibling.id)
if (selected) return selected
}
return first
}
export function createSelectionCommands({
editor,
selection,
@ -111,6 +131,23 @@ export function createSelectionCommands({
enabled: capabilities.canWrapInAutoLayout,
run: () => editor.wrapInAutoLayout()
},
'selection.toggleMask': {
id: 'selection.toggleMask',
get label() {
const target = selectedMaskTarget(editor.getSelectedNodes(), editor)
return target?.isMask ? t.value.removeMask : t.value.useAsMask
},
enabled: capabilities.canToggleMask,
run: () => {
const target = selectedMaskTarget(editor.getSelectedNodes(), editor)
if (!target) return
editor.updateNodeWithUndo(
target.id,
{ isMask: !target.isMask },
target.isMask ? 'Remove mask' : 'Use as mask'
)
}
},
'selection.bringToFront': {
id: 'selection.bringToFront',
get label() {

View file

@ -15,6 +15,7 @@ export type EditorCommandId =
| 'selection.detachInstance'
| 'selection.goToMainComponent'
| 'selection.wrapInAutoLayout'
| 'selection.toggleMask'
| 'selection.bringToFront'
| 'selection.sendToBack'
| 'selection.toggleVisibility'

View file

@ -36,6 +36,7 @@ const CANVAS_MENU_GROUPS = [
'selection.frameSelection',
'selection.ungroupWhenGroup',
'selection.wrapInAutoLayout',
'selection.toggleMask',
'selection.flatten',
'selection.outlineText',
'selection.outlineStroke'

View file

@ -37,6 +37,7 @@ export function useSelectionCapabilities() {
canCreateComponentSet: selection.canCreateComponentSet,
canDetachInstance: computed(() => selection.isInstance.value),
canWrapInAutoLayout: computed(() => hasSelection.value),
canToggleMask: computed(() => hasSelection.value),
canBringToFront: computed(() => hasSelection.value),
canSendToBack: computed(() => hasSelection.value),
canToggleVisibility: computed(() => hasSelection.value),

View file

@ -31,5 +31,7 @@
"flipVertical": "Vertikal spiegeln",
"flattenSelection": "Abflachen",
"outlineText": "Text in Pfade umwandeln",
"outlineStroke": "Kontur in Pfad umwandeln"
"outlineStroke": "Kontur in Pfad umwandeln",
"useAsMask": "Als Maske verwenden",
"removeMask": "Maske entfernen"
}

View file

@ -152,5 +152,28 @@
"exportScale": "Exportmaßstab",
"exportFormat": "Exportformat",
"gap": "Abstand",
"searchFonts": "Schriftarten suchen..."
"searchFonts": "Schriftarten suchen...",
"blendMode": "Blendmodus",
"mask": "Maske",
"maskType": "Maskentyp",
"maskTypeAlpha": "Alpha",
"maskTypeVector": "Vektor",
"maskTypeLuminance": "Luminanz",
"blendModePassThrough": "Durchreichen",
"blendModeNormal": "Normal",
"blendModeDarken": "Abdunkeln",
"blendModeMultiply": "Multiplizieren",
"blendModeColorBurn": "Farbig nachbelichten",
"blendModeLighten": "Aufhellen",
"blendModeScreen": "Negativ multiplizieren",
"blendModeColorDodge": "Farbig abwedeln",
"blendModeOverlay": "Überlagern",
"blendModeSoftLight": "Weiches Licht",
"blendModeHardLight": "Hartes Licht",
"blendModeDifference": "Differenz",
"blendModeExclusion": "Ausschluss",
"blendModeHue": "Farbton",
"blendModeSaturation": "Sättigung",
"blendModeColor": "Farbe",
"blendModeLuminosity": "Luminanz"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "Voltear verticalmente",
"flattenSelection": "Aplanar",
"outlineText": "Contornear texto",
"outlineStroke": "Cortornear trazo"
"outlineStroke": "Cortornear trazo",
"useAsMask": "Usar como máscara",
"removeMask": "Quitar máscara"
}

View file

@ -152,5 +152,28 @@
"expandEffectSettings": "Expandir ajustes del efecto",
"collapseEffectSettings": "Contraer ajustes del efecto",
"toggleExportPreview": "Alternar vista previa de exportación",
"searchFonts": "Buscar fuentes..."
"searchFonts": "Buscar fuentes...",
"blendMode": "Modo de fusión",
"mask": "Máscara",
"maskType": "Tipo de máscara",
"maskTypeAlpha": "Alfa",
"maskTypeVector": "Vectorial",
"maskTypeLuminance": "Luminancia",
"blendModePassThrough": "Pasar a través",
"blendModeNormal": "Normal",
"blendModeDarken": "Oscurecer",
"blendModeMultiply": "Multiplicar",
"blendModeColorBurn": "Subexponer color",
"blendModeLighten": "Aclarar",
"blendModeScreen": "Trama",
"blendModeColorDodge": "Sobreexponer color",
"blendModeOverlay": "Superponer",
"blendModeSoftLight": "Luz suave",
"blendModeHardLight": "Luz fuerte",
"blendModeDifference": "Diferencia",
"blendModeExclusion": "Exclusión",
"blendModeHue": "Tono",
"blendModeSaturation": "Saturación",
"blendModeColor": "Color",
"blendModeLuminosity": "Luminosidad"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "Retourner verticalement",
"flattenSelection": "Aplatir",
"outlineText": "Vectoriser le texte",
"outlineStroke": "Vectoriser le contour"
"outlineStroke": "Vectoriser le contour",
"useAsMask": "Utiliser comme masque",
"removeMask": "Retirer le masque"
}

View file

@ -152,5 +152,28 @@
"exportScale": "Échelle dexport",
"exportFormat": "Format dexport",
"gap": "Espacement",
"searchFonts": "Rechercher des polices..."
"searchFonts": "Rechercher des polices...",
"blendMode": "Mode de fusion",
"mask": "Masque",
"maskType": "Type de masque",
"maskTypeAlpha": "Alpha",
"maskTypeVector": "Vectoriel",
"maskTypeLuminance": "Luminance",
"blendModePassThrough": "Traverser",
"blendModeNormal": "Normal",
"blendModeDarken": "Obscurcir",
"blendModeMultiply": "Produit",
"blendModeColorBurn": "Densité couleur +",
"blendModeLighten": "Éclaircir",
"blendModeScreen": "Écran",
"blendModeColorDodge": "Densité couleur -",
"blendModeOverlay": "Incrustation",
"blendModeSoftLight": "Lumière tamisée",
"blendModeHardLight": "Lumière crue",
"blendModeDifference": "Différence",
"blendModeExclusion": "Exclusion",
"blendModeHue": "Teinte",
"blendModeSaturation": "Saturation",
"blendModeColor": "Couleur",
"blendModeLuminosity": "Luminosité"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "Rifletti verticalmente",
"flattenSelection": "Appiattisci",
"outlineText": "Converti testo in tracciato",
"outlineStroke": "Converti traccia in contorno"
"outlineStroke": "Converti traccia in contorno",
"useAsMask": "Usa come maschera",
"removeMask": "Rimuovi maschera"
}

View file

@ -152,5 +152,28 @@
"exportScale": "Scala esportazione",
"exportFormat": "Formato esportazione",
"gap": "Spazio",
"searchFonts": "Cerca font..."
"searchFonts": "Cerca font...",
"blendMode": "Metodo di fusione",
"mask": "Maschera",
"maskType": "Tipo di maschera",
"maskTypeAlpha": "Alfa",
"maskTypeVector": "Vettore",
"maskTypeLuminance": "Luminanza",
"blendModePassThrough": "Attraversa",
"blendModeNormal": "Normale",
"blendModeDarken": "Scurisci",
"blendModeMultiply": "Moltiplica",
"blendModeColorBurn": "Brucia colore",
"blendModeLighten": "Schiarisci",
"blendModeScreen": "Scolora",
"blendModeColorDodge": "Scherma colore",
"blendModeOverlay": "Sovrapponi",
"blendModeSoftLight": "Luce soffusa",
"blendModeHardLight": "Luce intensa",
"blendModeDifference": "Differenza",
"blendModeExclusion": "Esclusione",
"blendModeHue": "Tonalità",
"blendModeSaturation": "Saturazione",
"blendModeColor": "Colore",
"blendModeLuminosity": "Luminosità"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "垂直方向に反転",
"flattenSelection": "フラット化",
"outlineText": "テキストのアウトライン化",
"outlineStroke": "線の境界線をプレビュー (パスのアウトライン化)"
"outlineStroke": "線の境界線をプレビュー (パスのアウトライン化)",
"useAsMask": "マスクとして使用",
"removeMask": "マスクを解除"
}

View file

@ -152,5 +152,28 @@
"expandEffectSettings": "エフェクト設定を展開",
"collapseEffectSettings": "エフェクト設定を折りたたむ",
"toggleExportPreview": "エクスポートプレビューの切り替え",
"searchFonts": "フォントを検索..."
"searchFonts": "フォントを検索...",
"blendMode": "ブレンドモード",
"mask": "マスク",
"maskType": "マスクの種類",
"maskTypeAlpha": "アルファ",
"maskTypeVector": "ベクター",
"maskTypeLuminance": "輝度",
"blendModePassThrough": "通過",
"blendModeNormal": "通常",
"blendModeDarken": "比較(暗)",
"blendModeMultiply": "乗算",
"blendModeColorBurn": "焼き込みカラー",
"blendModeLighten": "比較(明)",
"blendModeScreen": "スクリーン",
"blendModeColorDodge": "覆い焼きカラー",
"blendModeOverlay": "オーバーレイ",
"blendModeSoftLight": "ソフトライト",
"blendModeHardLight": "ハードライト",
"blendModeDifference": "差の絶対値",
"blendModeExclusion": "除外",
"blendModeHue": "色相",
"blendModeSaturation": "彩度",
"blendModeColor": "カラー",
"blendModeLuminosity": "輝度"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "Odbij pionowo",
"flattenSelection": "Spłaszcz",
"outlineText": "Kontur tekstu",
"outlineStroke": "Kontur obrysu"
"outlineStroke": "Kontur obrysu",
"useAsMask": "Użyj jako maski",
"removeMask": "Usuń maskę"
}

View file

@ -152,5 +152,28 @@
"exportScale": "Skala eksportu",
"exportFormat": "Format eksportu",
"gap": "Odstęp",
"searchFonts": "Szukaj fontów..."
"searchFonts": "Szukaj fontów...",
"blendMode": "Tryb mieszania",
"mask": "Maska",
"maskType": "Typ maski",
"maskTypeAlpha": "Alfa",
"maskTypeVector": "Wektorowa",
"maskTypeLuminance": "Luminancja",
"blendModePassThrough": "Przepuszczanie",
"blendModeNormal": "Normalny",
"blendModeDarken": "Ciemniej",
"blendModeMultiply": "Mnożenie",
"blendModeColorBurn": "Ściemnianie koloru",
"blendModeLighten": "Jaśniej",
"blendModeScreen": "Ekran",
"blendModeColorDodge": "Rozjaśnianie koloru",
"blendModeOverlay": "Nakładka",
"blendModeSoftLight": "Łagodne światło",
"blendModeHardLight": "Ostre światło",
"blendModeDifference": "Różnica",
"blendModeExclusion": "Wykluczenie",
"blendModeHue": "Barwa",
"blendModeSaturation": "Nasycenie",
"blendModeColor": "Kolor",
"blendModeLuminosity": "Jasność"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "Отразить по вертикали",
"flattenSelection": "Сгладить",
"outlineText": "Преобразовать текст в контуры",
"outlineStroke": "Преобразовать обводку в контур"
"outlineStroke": "Преобразовать обводку в контур",
"useAsMask": "Использовать как маску",
"removeMask": "Убрать маску"
}

View file

@ -152,5 +152,28 @@
"exportScale": "Масштаб экспорта",
"exportFormat": "Формат экспорта",
"gap": "Интервал",
"searchFonts": "Поиск шрифтов..."
"searchFonts": "Поиск шрифтов...",
"blendMode": "Режим наложения",
"mask": "Маска",
"maskType": "Тип маски",
"maskTypeAlpha": "Альфа-канал",
"maskTypeVector": "Вектор",
"maskTypeLuminance": "Яркость",
"blendModePassThrough": "Сквозной",
"blendModeNormal": "Обычный",
"blendModeDarken": "Затемнение",
"blendModeMultiply": "Умножение",
"blendModeColorBurn": "Затемнение основы",
"blendModeLighten": "Осветление",
"blendModeScreen": "Экран",
"blendModeColorDodge": "Осветление основы",
"blendModeOverlay": "Перекрытие",
"blendModeSoftLight": "Мягкий свет",
"blendModeHardLight": "Жёсткий свет",
"blendModeDifference": "Разница",
"blendModeExclusion": "Исключение",
"blendModeHue": "Тон",
"blendModeSaturation": "Насыщенность",
"blendModeColor": "Цвет",
"blendModeLuminosity": "Яркость"
}

View file

@ -31,5 +31,7 @@
"flipVertical": "垂直翻转",
"flattenSelection": "扁平化",
"outlineText": "文本转轮廓",
"outlineStroke": "描边转轮廓"
"outlineStroke": "描边转轮廓",
"useAsMask": "用作蒙版",
"removeMask": "取消蒙版"
}

View file

@ -152,5 +152,28 @@
"exportScale": "导出缩放",
"exportFormat": "导出格式",
"gap": "间距",
"searchFonts": "搜索字体..."
"searchFonts": "搜索字体...",
"blendMode": "混合模式",
"mask": "蒙版",
"maskType": "蒙版类型",
"maskTypeAlpha": "Alpha",
"maskTypeVector": "矢量",
"maskTypeLuminance": "亮度",
"blendModePassThrough": "穿透",
"blendModeNormal": "正常",
"blendModeDarken": "变暗",
"blendModeMultiply": "正片叠底",
"blendModeColorBurn": "颜色加深",
"blendModeLighten": "变亮",
"blendModeScreen": "滤色",
"blendModeColorDodge": "颜色减淡",
"blendModeOverlay": "叠加",
"blendModeSoftLight": "柔光",
"blendModeHardLight": "强光",
"blendModeDifference": "差值",
"blendModeExclusion": "排除",
"blendModeHue": "色相",
"blendModeSaturation": "饱和度",
"blendModeColor": "颜色",
"blendModeLuminosity": "明度"
}

View file

@ -16,6 +16,8 @@ export const commandMessageDefaults = {
detachInstance: 'Detach instance',
goToMainComponent: 'Go to main component',
addAutoLayout: 'Add auto layout',
useAsMask: 'Use as mask',
removeMask: 'Remove mask',
bringToFront: 'Bring to front',
sendToBack: 'Send to back',
showHide: 'Show/Hide',

View file

@ -31,6 +31,7 @@ export const panelMessageDefaults = {
width: 'Width',
height: 'Height',
opacity: 'Opacity',
blendMode: 'Blend mode',
radius: 'Radius',
spread: 'Spread',
@ -43,6 +44,7 @@ export const panelMessageDefaults = {
fill: 'Fill',
stroke: 'Stroke',
effects: 'Effects',
mask: 'Mask',
export: 'Export',
typography: 'Typography',
variables: 'Variables',
@ -68,6 +70,29 @@ export const panelMessageDefaults = {
backgroundBlur: 'Background blur',
foregroundBlur: 'Foreground blur',
maskType: 'Mask type',
maskTypeAlpha: 'Alpha',
maskTypeVector: 'Vector',
maskTypeLuminance: 'Luminance',
blendModePassThrough: 'Pass through',
blendModeNormal: 'Normal',
blendModeDarken: 'Darken',
blendModeMultiply: 'Multiply',
blendModeColorBurn: 'Color burn',
blendModeLighten: 'Lighten',
blendModeScreen: 'Screen',
blendModeColorDodge: 'Color dodge',
blendModeOverlay: 'Overlay',
blendModeSoftLight: 'Soft light',
blendModeHardLight: 'Hard light',
blendModeDifference: 'Difference',
blendModeExclusion: 'Exclusion',
blendModeHue: 'Hue',
blendModeSaturation: 'Saturation',
blendModeColor: 'Color',
blendModeLuminosity: 'Luminosity',
strokeType: 'Stroke type',
strokeWeight: 'Stroke weight',

View file

@ -75,6 +75,7 @@ export { usePosition } from '#vue/controls/position/use'
export { useLayout } from '#vue/controls/layout/use'
export type { SizeLimitProp } from '#vue/controls/layout/helpers'
export { useAppearance } from '#vue/controls/appearance/use'
export { useMask } from '#vue/controls/mask/use'
export { useTypography } from '#vue/controls/typography/use'
export type { UseTypographyOptions } from '#vue/controls/typography/use'
export { useExport } from '#vue/document/export/use'

View file

@ -77,6 +77,7 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) {
'selection.createComponent',
'selection.detachInstance',
'selection.createComponentSet',
'selection.toggleMask',
'selection.toggleVisibility',
'selection.toggleLock',
'selection.flipHorizontal',

View file

@ -4,14 +4,15 @@ import { computed, ref } from 'vue'
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
import VariablesDialog from './variables/VariablesDialog.vue'
import BooleanOperationsControl from './properties/BooleanOperationsControl.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutSection from './properties/LayoutSection/LayoutSection.vue'
import MaskSection from './properties/MaskSection.vue'
import PageSection from './properties/PageSection.vue'
import PositionSection from './properties/PositionSection.vue'
import SelectionActionsControl from './properties/SelectionActionsControl.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
@ -45,9 +46,7 @@ const { panels } = useI18n()
<span class="text-xs font-semibold">{{
panels.layersCount({ count: String(multiCount) })
}}</span>
<div class="ml-auto flex items-center">
<BooleanOperationsControl v-if="showBooleanOperations" />
</div>
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
</div>
<PositionSection />
<AppearanceSection />
@ -71,6 +70,7 @@ const { panels } = useI18n()
node.type
}}</span>
<span class="text-xs font-semibold">{{ node.name }}</span>
<SelectionActionsControl />
</div>
<!-- Component actions -->
@ -99,6 +99,7 @@ const { panels } = useI18n()
<PositionSection />
<LayoutSection />
<AppearanceSection />
<MaskSection />
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />

View file

@ -1,15 +1,45 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAppearance, useI18n } from '@open-pencil/vue'
import { MIXED, useAppearance, useI18n } from '@open-pencil/vue'
import ScrubInput from '@/components/inputs/ScrubInput.vue'
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import type { BlendMode } from '@open-pencil/scene-graph'
const { panels } = useI18n()
type BlendModeSelectValue = BlendMode | 'MIXED'
const blendModeOptions = computed<Array<{ value: BlendModeSelectValue; label: string }>>(() => {
const options: Array<{ value: BlendModeSelectValue; label: string }> = [
{ value: 'PASS_THROUGH', label: panels.value.blendModePassThrough },
{ value: 'NORMAL', label: panels.value.blendModeNormal },
{ value: 'DARKEN', label: panels.value.blendModeDarken },
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
{ value: 'SCREEN', label: panels.value.blendModeScreen },
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
{ value: 'HUE', label: panels.value.blendModeHue },
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
{ value: 'COLOR', label: panels.value.blendModeColor },
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
]
return blendModeValue.value === MIXED
? [{ value: 'MIXED', label: panels.value.mixed }, ...options]
: options
})
const {
node,
isMulti,
@ -18,7 +48,9 @@ const {
independentCorners,
cornerRadiusValue,
opacityPercent,
blendModeValue,
visibilityState,
setBlendMode,
updateProp,
commitProp,
toggleVisibility,
@ -45,6 +77,13 @@ function onToggleCorners() {
manualExpanded.value = !showIndependentCorners.value
toggleIndependentCorners()
}
const blendModeSelectValue = computed<BlendModeSelectValue>({
get: () => (blendModeValue.value === MIXED ? 'MIXED' : blendModeValue.value),
set: (value) => {
if (value !== 'MIXED') setBlendMode(value)
}
})
</script>
<template>
@ -62,7 +101,17 @@ function onToggleCorners() {
</IconButton>
</template>
<div class="flex gap-1.5">
<div class="grid grid-cols-[minmax(0,3fr)_minmax(0,2fr)] gap-1.5">
<Tip :label="panels.blendMode">
<AppSelect
v-model="blendModeSelectValue"
class="w-full"
:label="panels.blendMode"
:options="blendModeOptions"
data-test-id="appearance-blend-mode"
/>
</Tip>
<Tip :label="panels.opacity">
<VariableScrubInput
v-if="node"
@ -93,48 +142,48 @@ function onToggleCorners() {
</template>
</ScrubInput>
</Tip>
</div>
<template v-if="hasCornerRadius">
<Tip :label="panels.radius">
<VariableScrubInput
v-if="!showIndependentCorners && node"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else-if="!showIndependentCorners"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</ScrubInput>
</Tip>
<IconButton
:label="panels.independentCornerRadii"
size="md"
class="size-[26px] shrink-0"
:active="showIndependentCorners"
data-test-id="independent-corners-toggle"
@click="onToggleCorners"
<div v-if="hasCornerRadius" class="mt-1.5 flex gap-1.5">
<Tip :label="panels.radius">
<VariableScrubInput
v-if="!showIndependentCorners && node"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</template>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else-if="!showIndependentCorners"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</ScrubInput>
</Tip>
<IconButton
:label="panels.independentCornerRadii"
size="md"
class="size-[26px] shrink-0"
:active="showIndependentCorners"
data-test-id="independent-corners-toggle"
@click="onToggleCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</div>
<div

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useMask } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import PanelSection from '@/components/ui/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import type { MaskType } from '@open-pencil/scene-graph'
const { panels } = useI18n()
const { active, maskType, setMaskType } = useMask()
const maskTypeOptions = computed<Array<{ value: MaskType; label: string }>>(() => [
{ value: 'ALPHA', label: panels.value.maskTypeAlpha },
{ value: 'VECTOR', label: panels.value.maskTypeVector },
{ value: 'LUMINANCE', label: panels.value.maskTypeLuminance }
])
const selectedMaskType = computed<MaskType>({
get: () => maskType.value,
set: setMaskType
})
</script>
<template>
<PanelSection v-if="active" :label="panels.mask" data-test-id="mask-section">
<Tip :label="panels.maskType">
<AppSelect
class="w-full"
:label="panels.maskType"
v-model="selectedMaskType"
:options="maskTypeOptions"
data-test-id="mask-type-select"
/>
</Tip>
</PanelSection>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { useEditorCommands } from '@open-pencil/vue'
import BooleanOperationsControl from '@/components/properties/BooleanOperationsControl.vue'
import IconButton from '@/components/ui/IconButton.vue'
const { showBooleanOperations = false } = defineProps<{
showBooleanOperations?: boolean
}>()
const { getCommand, runCommand } = useEditorCommands()
const maskCommand = getCommand('selection.toggleMask')
</script>
<template>
<div class="ml-auto flex items-center gap-1">
<IconButton
:label="maskCommand.label"
:disabled="!maskCommand.enabled.value"
data-test-id="selection-toggle-mask"
@click="runCommand('selection.toggleMask')"
>
<icon-lucide-shapes class="size-3.5" />
</IconButton>
<BooleanOperationsControl v-if="showBooleanOperations" />
</div>
</template>

View file

@ -15,6 +15,7 @@ import { useSelectUI } from '@/components/ui/select'
import type { SelectUi } from '@/components/ui/select'
interface AppSelectUi extends SelectUi {
value?: string
viewport?: string
indicator?: string
}
@ -37,6 +38,7 @@ const select = useSelectUI({
content: ui?.content ?? 'max-h-56',
item: ui?.item ?? 'rounded py-1.5 pr-2 pl-6 text-xs'
})
const value = ui?.value ?? 'min-w-0 flex-1 truncate text-left'
const viewport = ui?.viewport ?? 'p-0.5'
const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center justify-center'
</script>
@ -44,7 +46,7 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger v-bind="$attrs" :class="select.trigger" :aria-label="label">
<SelectValue :placeholder="placeholder" />
<SelectValue :placeholder="placeholder" :class="value" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
</SelectTrigger>
<SelectPortal>

View file

@ -39,6 +39,9 @@ function getNode(id: string) {
effects: n.effects,
opacity: n.opacity,
visible: n.visible,
blendMode: n.blendMode,
isMask: n.isMask,
maskType: n.maskType,
x: n.x,
y: n.y,
width: n.width,
@ -56,6 +59,14 @@ function getSelectedId() {
})
}
function getSelectedIds() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return [...store.state.selectedIds]
})
}
test('selecting a rectangle shows design panel with type and name', async () => {
await editor.canvas.drawRect(100, 100, 120, 80)
await editor.canvas.waitForRender()
@ -152,6 +163,83 @@ test('adding a second fill shows two fill items', async () => {
expect(expectDefined(node, 'node node').fills.length).toBe(2)
})
test('blend mode select updates the selected layer', async () => {
const id = await getSelectedId()
const blendModeSelect = editor.page.getByTestId('appearance-blend-mode')
await expect(blendModeSelect).toBeVisible()
await blendModeSelect.click()
await editor.page.getByRole('option', { name: 'Multiply' }).click()
await editor.canvas.waitForRender()
const node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node').blendMode).toBe('MULTIPLY')
})
test('multi-select blend mode change is one undo step', async () => {
await editor.canvas.drawRect(300, 100, 60, 60)
await editor.canvas.drawRect(400, 100, 60, 60)
await editor.canvas.selectAll()
await editor.canvas.waitForRender()
const ids = await getSelectedIds()
expect(ids.length).toBeGreaterThanOrEqual(2)
const previousBlendModes = await Promise.all(
ids.map(async (id) => expectDefined(await getNode(id), 'selected node').blendMode)
)
const blendModeSelect = editor.page.getByTestId('appearance-blend-mode')
await blendModeSelect.click()
await editor.page.getByRole('option', { name: 'Multiply' }).click()
await editor.canvas.waitForRender()
for (const id of ids) {
expect(expectDefined(await getNode(id), 'selected node').blendMode).toBe('MULTIPLY')
}
await editor.canvas.undo()
const afterUndo = await Promise.all(
ids.map(async (id) => expectDefined(await getNode(id), 'selected node').blendMode)
)
expect(afterUndo).toEqual(previousBlendModes)
await editor.canvas.redo()
const afterRedo = await Promise.all(
ids.map(async (id) => expectDefined(await getNode(id), 'selected node').blendMode)
)
expect(afterRedo).toEqual(ids.map(() => 'MULTIPLY'))
})
test('mask action toggles mask section and mask type control', async () => {
await editor.canvas.drawRect(500, 100, 60, 60)
await editor.canvas.waitForRender()
const id = await getSelectedId()
const maskAction = editor.page.getByTestId('selection-toggle-mask')
await expect(maskAction).toBeVisible()
await expect(editor.page.getByTestId('mask-section')).toHaveCount(0)
await maskAction.click()
await editor.canvas.waitForRender()
let node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node').isMask).toBe(true)
await expect(editor.page.getByTestId('mask-section')).toBeVisible()
const maskTypeSelect = editor.page.getByTestId('mask-type-select')
await maskTypeSelect.click()
await editor.page.getByRole('option', { name: 'Luminance' }).click()
await editor.canvas.waitForRender()
node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node').maskType).toBe('LUMINANCE')
await maskAction.click()
await editor.canvas.waitForRender()
node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node').isMask).toBe(false)
await expect(editor.page.getByTestId('mask-section')).toHaveCount(0)
})
test('visibility toggle in appearance section works', async () => {
const visBtn = editor.page.getByTestId('appearance-visibility')
await expect(visBtn).toBeVisible()

View file

@ -47,6 +47,7 @@ describe('buildCanvasContextMenu', () => {
'selection.group',
'selection.frameSelection',
'selection.wrapInAutoLayout',
'selection.toggleMask',
'selection.flatten',
'selection.outlineText',
'selection.outlineStroke',