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:
parent
1750199b9c
commit
0aae91f4f2
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
28
packages/docs/programmable/sdk/api/composables/use-mask.md
Normal file
28
packages/docs/programmable/sdk/api/composables/use-mask.md
Normal 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](../)
|
||||
|
|
@ -115,6 +115,7 @@ These are the main APIs most SDK consumers should start with.
|
|||
- `usePosition()`
|
||||
- `useLayout()`
|
||||
- `useAppearance()`
|
||||
- `useMask()`
|
||||
- `useTypography()`
|
||||
- `useExport()`
|
||||
- `useFillControls()`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
packages/vue/src/controls/mask/use.ts
Normal file
23
packages/vue/src/controls/mask/use.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type EditorCommandId =
|
|||
| 'selection.detachInstance'
|
||||
| 'selection.goToMainComponent'
|
||||
| 'selection.wrapInAutoLayout'
|
||||
| 'selection.toggleMask'
|
||||
| 'selection.bringToFront'
|
||||
| 'selection.sendToBack'
|
||||
| 'selection.toggleVisibility'
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const CANVAS_MENU_GROUPS = [
|
|||
'selection.frameSelection',
|
||||
'selection.ungroupWhenGroup',
|
||||
'selection.wrapInAutoLayout',
|
||||
'selection.toggleMask',
|
||||
'selection.flatten',
|
||||
'selection.outlineText',
|
||||
'selection.outlineStroke'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,5 +152,28 @@
|
|||
"exportScale": "Échelle d’export",
|
||||
"exportFormat": "Format d’export",
|
||||
"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é"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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à"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,7 @@
|
|||
"flipVertical": "垂直方向に反転",
|
||||
"flattenSelection": "フラット化",
|
||||
"outlineText": "テキストのアウトライン化",
|
||||
"outlineStroke": "線の境界線をプレビュー (パスのアウトライン化)"
|
||||
"outlineStroke": "線の境界線をプレビュー (パスのアウトライン化)",
|
||||
"useAsMask": "マスクとして使用",
|
||||
"removeMask": "マスクを解除"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "輝度"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ę"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ść"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,7 @@
|
|||
"flipVertical": "Отразить по вертикали",
|
||||
"flattenSelection": "Сгладить",
|
||||
"outlineText": "Преобразовать текст в контуры",
|
||||
"outlineStroke": "Преобразовать обводку в контур"
|
||||
"outlineStroke": "Преобразовать обводку в контур",
|
||||
"useAsMask": "Использовать как маску",
|
||||
"removeMask": "Убрать маску"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Яркость"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,7 @@
|
|||
"flipVertical": "垂直翻转",
|
||||
"flattenSelection": "扁平化",
|
||||
"outlineText": "文本转轮廓",
|
||||
"outlineStroke": "描边转轮廓"
|
||||
"outlineStroke": "描边转轮廓",
|
||||
"useAsMask": "用作蒙版",
|
||||
"removeMask": "取消蒙版"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "明度"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) {
|
|||
'selection.createComponent',
|
||||
'selection.detachInstance',
|
||||
'selection.createComponentSet',
|
||||
'selection.toggleMask',
|
||||
'selection.toggleVisibility',
|
||||
'selection.toggleLock',
|
||||
'selection.flipHorizontal',
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
src/components/properties/MaskSection.vue
Normal file
39
src/components/properties/MaskSection.vue
Normal 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>
|
||||
27
src/components/properties/SelectionActionsControl.vue
Normal file
27
src/components/properties/SelectionActionsControl.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ describe('buildCanvasContextMenu', () => {
|
|||
'selection.group',
|
||||
'selection.frameSelection',
|
||||
'selection.wrapInAutoLayout',
|
||||
'selection.toggleMask',
|
||||
'selection.flatten',
|
||||
'selection.outlineText',
|
||||
'selection.outlineStroke',
|
||||
|
|
|
|||
Loading…
Reference in a new issue