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 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 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 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 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.
|
- 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.
|
- 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)
|
- [usePosition](./use-position)
|
||||||
- [useLayout](./use-layout)
|
- [useLayout](./use-layout)
|
||||||
- [useAppearance](./use-appearance)
|
- [useAppearance](./use-appearance)
|
||||||
|
- [useMask](./use-mask)
|
||||||
- [useTypography](./use-typography)
|
- [useTypography](./use-typography)
|
||||||
- [useExport](./use-export)
|
- [useExport](./use-export)
|
||||||
- [useFillControls](./use-fill-controls)
|
- [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()`
|
- `usePosition()`
|
||||||
- `useLayout()`
|
- `useLayout()`
|
||||||
- `useAppearance()`
|
- `useAppearance()`
|
||||||
|
- `useMask()`
|
||||||
- `useTypography()`
|
- `useTypography()`
|
||||||
- `useExport()`
|
- `useExport()`
|
||||||
- `useFillControls()`
|
- `useFillControls()`
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { computed } from 'vue'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
|
|
||||||
import type { Editor } from '@open-pencil/core/editor'
|
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'
|
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)
|
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 visibilityState = computed<'visible' | 'hidden' | 'mixed'>(() => {
|
||||||
const v = merged('visible')
|
const v = merged('visible')
|
||||||
if (v === MIXED) return 'mixed'
|
if (v === MIXED) return 'mixed'
|
||||||
return v ? 'visible' : 'hidden'
|
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) {
|
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() {
|
function toggleVisibility() {
|
||||||
if (isMulti.value) {
|
if (isMulti.value) {
|
||||||
const liveNodes = nodes.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.goToMainComponent': {},
|
||||||
'selection.createInstance': {},
|
'selection.createInstance': {},
|
||||||
'selection.wrapInAutoLayout': { shortcut: 'SHIFT+A', keybinding: 'Shift+KeyA' },
|
'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': {
|
'selection.bringToFront': {
|
||||||
shortcut: ']',
|
shortcut: ']',
|
||||||
keybinding: 'BracketRight',
|
keybinding: 'BracketRight',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
|
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||||
|
|
||||||
import type { EditorCommandMapOptions } from './context'
|
import type { EditorCommandMapOptions } from './context'
|
||||||
import type { EditorCommand, EditorCommandId } from './types'
|
import type { EditorCommand, EditorCommandId } from './types'
|
||||||
|
|
||||||
type SelectionCommandId = Extract<EditorCommandId, `selection.${string}`>
|
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({
|
export function createSelectionCommands({
|
||||||
editor,
|
editor,
|
||||||
selection,
|
selection,
|
||||||
|
|
@ -111,6 +131,23 @@ export function createSelectionCommands({
|
||||||
enabled: capabilities.canWrapInAutoLayout,
|
enabled: capabilities.canWrapInAutoLayout,
|
||||||
run: () => editor.wrapInAutoLayout()
|
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': {
|
'selection.bringToFront': {
|
||||||
id: 'selection.bringToFront',
|
id: 'selection.bringToFront',
|
||||||
get label() {
|
get label() {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export type EditorCommandId =
|
||||||
| 'selection.detachInstance'
|
| 'selection.detachInstance'
|
||||||
| 'selection.goToMainComponent'
|
| 'selection.goToMainComponent'
|
||||||
| 'selection.wrapInAutoLayout'
|
| 'selection.wrapInAutoLayout'
|
||||||
|
| 'selection.toggleMask'
|
||||||
| 'selection.bringToFront'
|
| 'selection.bringToFront'
|
||||||
| 'selection.sendToBack'
|
| 'selection.sendToBack'
|
||||||
| 'selection.toggleVisibility'
|
| 'selection.toggleVisibility'
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const CANVAS_MENU_GROUPS = [
|
||||||
'selection.frameSelection',
|
'selection.frameSelection',
|
||||||
'selection.ungroupWhenGroup',
|
'selection.ungroupWhenGroup',
|
||||||
'selection.wrapInAutoLayout',
|
'selection.wrapInAutoLayout',
|
||||||
|
'selection.toggleMask',
|
||||||
'selection.flatten',
|
'selection.flatten',
|
||||||
'selection.outlineText',
|
'selection.outlineText',
|
||||||
'selection.outlineStroke'
|
'selection.outlineStroke'
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function useSelectionCapabilities() {
|
||||||
canCreateComponentSet: selection.canCreateComponentSet,
|
canCreateComponentSet: selection.canCreateComponentSet,
|
||||||
canDetachInstance: computed(() => selection.isInstance.value),
|
canDetachInstance: computed(() => selection.isInstance.value),
|
||||||
canWrapInAutoLayout: computed(() => hasSelection.value),
|
canWrapInAutoLayout: computed(() => hasSelection.value),
|
||||||
|
canToggleMask: computed(() => hasSelection.value),
|
||||||
canBringToFront: computed(() => hasSelection.value),
|
canBringToFront: computed(() => hasSelection.value),
|
||||||
canSendToBack: computed(() => hasSelection.value),
|
canSendToBack: computed(() => hasSelection.value),
|
||||||
canToggleVisibility: computed(() => hasSelection.value),
|
canToggleVisibility: computed(() => hasSelection.value),
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,7 @@
|
||||||
"flipVertical": "Vertikal spiegeln",
|
"flipVertical": "Vertikal spiegeln",
|
||||||
"flattenSelection": "Abflachen",
|
"flattenSelection": "Abflachen",
|
||||||
"outlineText": "Text in Pfade umwandeln",
|
"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",
|
"exportScale": "Exportmaßstab",
|
||||||
"exportFormat": "Exportformat",
|
"exportFormat": "Exportformat",
|
||||||
"gap": "Abstand",
|
"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",
|
"flipVertical": "Voltear verticalmente",
|
||||||
"flattenSelection": "Aplanar",
|
"flattenSelection": "Aplanar",
|
||||||
"outlineText": "Contornear texto",
|
"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",
|
"expandEffectSettings": "Expandir ajustes del efecto",
|
||||||
"collapseEffectSettings": "Contraer ajustes del efecto",
|
"collapseEffectSettings": "Contraer ajustes del efecto",
|
||||||
"toggleExportPreview": "Alternar vista previa de exportación",
|
"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",
|
"flipVertical": "Retourner verticalement",
|
||||||
"flattenSelection": "Aplatir",
|
"flattenSelection": "Aplatir",
|
||||||
"outlineText": "Vectoriser le texte",
|
"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",
|
"exportScale": "Échelle d’export",
|
||||||
"exportFormat": "Format d’export",
|
"exportFormat": "Format d’export",
|
||||||
"gap": "Espacement",
|
"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",
|
"flipVertical": "Rifletti verticalmente",
|
||||||
"flattenSelection": "Appiattisci",
|
"flattenSelection": "Appiattisci",
|
||||||
"outlineText": "Converti testo in tracciato",
|
"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",
|
"exportScale": "Scala esportazione",
|
||||||
"exportFormat": "Formato esportazione",
|
"exportFormat": "Formato esportazione",
|
||||||
"gap": "Spazio",
|
"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": "垂直方向に反転",
|
"flipVertical": "垂直方向に反転",
|
||||||
"flattenSelection": "フラット化",
|
"flattenSelection": "フラット化",
|
||||||
"outlineText": "テキストのアウトライン化",
|
"outlineText": "テキストのアウトライン化",
|
||||||
"outlineStroke": "線の境界線をプレビュー (パスのアウトライン化)"
|
"outlineStroke": "線の境界線をプレビュー (パスのアウトライン化)",
|
||||||
|
"useAsMask": "マスクとして使用",
|
||||||
|
"removeMask": "マスクを解除"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,5 +152,28 @@
|
||||||
"expandEffectSettings": "エフェクト設定を展開",
|
"expandEffectSettings": "エフェクト設定を展開",
|
||||||
"collapseEffectSettings": "エフェクト設定を折りたたむ",
|
"collapseEffectSettings": "エフェクト設定を折りたたむ",
|
||||||
"toggleExportPreview": "エクスポートプレビューの切り替え",
|
"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",
|
"flipVertical": "Odbij pionowo",
|
||||||
"flattenSelection": "Spłaszcz",
|
"flattenSelection": "Spłaszcz",
|
||||||
"outlineText": "Kontur tekstu",
|
"outlineText": "Kontur tekstu",
|
||||||
"outlineStroke": "Kontur obrysu"
|
"outlineStroke": "Kontur obrysu",
|
||||||
|
"useAsMask": "Użyj jako maski",
|
||||||
|
"removeMask": "Usuń maskę"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,5 +152,28 @@
|
||||||
"exportScale": "Skala eksportu",
|
"exportScale": "Skala eksportu",
|
||||||
"exportFormat": "Format eksportu",
|
"exportFormat": "Format eksportu",
|
||||||
"gap": "Odstęp",
|
"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": "Отразить по вертикали",
|
"flipVertical": "Отразить по вертикали",
|
||||||
"flattenSelection": "Сгладить",
|
"flattenSelection": "Сгладить",
|
||||||
"outlineText": "Преобразовать текст в контуры",
|
"outlineText": "Преобразовать текст в контуры",
|
||||||
"outlineStroke": "Преобразовать обводку в контур"
|
"outlineStroke": "Преобразовать обводку в контур",
|
||||||
|
"useAsMask": "Использовать как маску",
|
||||||
|
"removeMask": "Убрать маску"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,5 +152,28 @@
|
||||||
"exportScale": "Масштаб экспорта",
|
"exportScale": "Масштаб экспорта",
|
||||||
"exportFormat": "Формат экспорта",
|
"exportFormat": "Формат экспорта",
|
||||||
"gap": "Интервал",
|
"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": "垂直翻转",
|
"flipVertical": "垂直翻转",
|
||||||
"flattenSelection": "扁平化",
|
"flattenSelection": "扁平化",
|
||||||
"outlineText": "文本转轮廓",
|
"outlineText": "文本转轮廓",
|
||||||
"outlineStroke": "描边转轮廓"
|
"outlineStroke": "描边转轮廓",
|
||||||
|
"useAsMask": "用作蒙版",
|
||||||
|
"removeMask": "取消蒙版"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,5 +152,28 @@
|
||||||
"exportScale": "导出缩放",
|
"exportScale": "导出缩放",
|
||||||
"exportFormat": "导出格式",
|
"exportFormat": "导出格式",
|
||||||
"gap": "间距",
|
"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',
|
detachInstance: 'Detach instance',
|
||||||
goToMainComponent: 'Go to main component',
|
goToMainComponent: 'Go to main component',
|
||||||
addAutoLayout: 'Add auto layout',
|
addAutoLayout: 'Add auto layout',
|
||||||
|
useAsMask: 'Use as mask',
|
||||||
|
removeMask: 'Remove mask',
|
||||||
bringToFront: 'Bring to front',
|
bringToFront: 'Bring to front',
|
||||||
sendToBack: 'Send to back',
|
sendToBack: 'Send to back',
|
||||||
showHide: 'Show/Hide',
|
showHide: 'Show/Hide',
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const panelMessageDefaults = {
|
||||||
width: 'Width',
|
width: 'Width',
|
||||||
height: 'Height',
|
height: 'Height',
|
||||||
opacity: 'Opacity',
|
opacity: 'Opacity',
|
||||||
|
blendMode: 'Blend mode',
|
||||||
radius: 'Radius',
|
radius: 'Radius',
|
||||||
spread: 'Spread',
|
spread: 'Spread',
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ export const panelMessageDefaults = {
|
||||||
fill: 'Fill',
|
fill: 'Fill',
|
||||||
stroke: 'Stroke',
|
stroke: 'Stroke',
|
||||||
effects: 'Effects',
|
effects: 'Effects',
|
||||||
|
mask: 'Mask',
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
typography: 'Typography',
|
typography: 'Typography',
|
||||||
variables: 'Variables',
|
variables: 'Variables',
|
||||||
|
|
@ -68,6 +70,29 @@ export const panelMessageDefaults = {
|
||||||
backgroundBlur: 'Background blur',
|
backgroundBlur: 'Background blur',
|
||||||
foregroundBlur: 'Foreground 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',
|
strokeType: 'Stroke type',
|
||||||
strokeWeight: 'Stroke weight',
|
strokeWeight: 'Stroke weight',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export { usePosition } from '#vue/controls/position/use'
|
||||||
export { useLayout } from '#vue/controls/layout/use'
|
export { useLayout } from '#vue/controls/layout/use'
|
||||||
export type { SizeLimitProp } from '#vue/controls/layout/helpers'
|
export type { SizeLimitProp } from '#vue/controls/layout/helpers'
|
||||||
export { useAppearance } from '#vue/controls/appearance/use'
|
export { useAppearance } from '#vue/controls/appearance/use'
|
||||||
|
export { useMask } from '#vue/controls/mask/use'
|
||||||
export { useTypography } from '#vue/controls/typography/use'
|
export { useTypography } from '#vue/controls/typography/use'
|
||||||
export type { UseTypographyOptions } from '#vue/controls/typography/use'
|
export type { UseTypographyOptions } from '#vue/controls/typography/use'
|
||||||
export { useExport } from '#vue/document/export/use'
|
export { useExport } from '#vue/document/export/use'
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) {
|
||||||
'selection.createComponent',
|
'selection.createComponent',
|
||||||
'selection.detachInstance',
|
'selection.detachInstance',
|
||||||
'selection.createComponentSet',
|
'selection.createComponentSet',
|
||||||
|
'selection.toggleMask',
|
||||||
'selection.toggleVisibility',
|
'selection.toggleVisibility',
|
||||||
'selection.toggleLock',
|
'selection.toggleLock',
|
||||||
'selection.flipHorizontal',
|
'selection.flipHorizontal',
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,15 @@ import { computed, ref } from 'vue'
|
||||||
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
|
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
|
||||||
|
|
||||||
import VariablesDialog from './variables/VariablesDialog.vue'
|
import VariablesDialog from './variables/VariablesDialog.vue'
|
||||||
import BooleanOperationsControl from './properties/BooleanOperationsControl.vue'
|
|
||||||
import AppearanceSection from './properties/AppearanceSection.vue'
|
import AppearanceSection from './properties/AppearanceSection.vue'
|
||||||
import EffectsSection from './properties/EffectsSection.vue'
|
import EffectsSection from './properties/EffectsSection.vue'
|
||||||
import ExportSection from './properties/ExportSection.vue'
|
import ExportSection from './properties/ExportSection.vue'
|
||||||
import FillSection from './properties/FillSection.vue'
|
import FillSection from './properties/FillSection.vue'
|
||||||
import LayoutSection from './properties/LayoutSection/LayoutSection.vue'
|
import LayoutSection from './properties/LayoutSection/LayoutSection.vue'
|
||||||
|
import MaskSection from './properties/MaskSection.vue'
|
||||||
import PageSection from './properties/PageSection.vue'
|
import PageSection from './properties/PageSection.vue'
|
||||||
import PositionSection from './properties/PositionSection.vue'
|
import PositionSection from './properties/PositionSection.vue'
|
||||||
|
import SelectionActionsControl from './properties/SelectionActionsControl.vue'
|
||||||
import StrokeSection from './properties/StrokeSection.vue'
|
import StrokeSection from './properties/StrokeSection.vue'
|
||||||
import TypographySection from './properties/TypographySection.vue'
|
import TypographySection from './properties/TypographySection.vue'
|
||||||
import VariablesSection from './properties/VariablesSection.vue'
|
import VariablesSection from './properties/VariablesSection.vue'
|
||||||
|
|
@ -45,9 +46,7 @@ const { panels } = useI18n()
|
||||||
<span class="text-xs font-semibold">{{
|
<span class="text-xs font-semibold">{{
|
||||||
panels.layersCount({ count: String(multiCount) })
|
panels.layersCount({ count: String(multiCount) })
|
||||||
}}</span>
|
}}</span>
|
||||||
<div class="ml-auto flex items-center">
|
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
|
||||||
<BooleanOperationsControl v-if="showBooleanOperations" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<PositionSection />
|
<PositionSection />
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|
@ -71,6 +70,7 @@ const { panels } = useI18n()
|
||||||
node.type
|
node.type
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="text-xs font-semibold">{{ node.name }}</span>
|
<span class="text-xs font-semibold">{{ node.name }}</span>
|
||||||
|
<SelectionActionsControl />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Component actions -->
|
<!-- Component actions -->
|
||||||
|
|
@ -99,6 +99,7 @@ const { panels } = useI18n()
|
||||||
<PositionSection />
|
<PositionSection />
|
||||||
<LayoutSection />
|
<LayoutSection />
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
<MaskSection />
|
||||||
<TypographySection v-if="node.type === 'TEXT'" />
|
<TypographySection v-if="node.type === 'TEXT'" />
|
||||||
<FillSection />
|
<FillSection />
|
||||||
<StrokeSection />
|
<StrokeSection />
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,45 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
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 ScrubInput from '@/components/inputs/ScrubInput.vue'
|
||||||
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
|
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
|
||||||
|
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||||
import IconButton from '@/components/ui/IconButton.vue'
|
import IconButton from '@/components/ui/IconButton.vue'
|
||||||
import PanelSection from '@/components/ui/PanelSection.vue'
|
import PanelSection from '@/components/ui/PanelSection.vue'
|
||||||
import Tip from '@/components/ui/Tip.vue'
|
import Tip from '@/components/ui/Tip.vue'
|
||||||
|
|
||||||
|
import type { BlendMode } from '@open-pencil/scene-graph'
|
||||||
|
|
||||||
const { panels } = useI18n()
|
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 {
|
const {
|
||||||
node,
|
node,
|
||||||
isMulti,
|
isMulti,
|
||||||
|
|
@ -18,7 +48,9 @@ const {
|
||||||
independentCorners,
|
independentCorners,
|
||||||
cornerRadiusValue,
|
cornerRadiusValue,
|
||||||
opacityPercent,
|
opacityPercent,
|
||||||
|
blendModeValue,
|
||||||
visibilityState,
|
visibilityState,
|
||||||
|
setBlendMode,
|
||||||
updateProp,
|
updateProp,
|
||||||
commitProp,
|
commitProp,
|
||||||
toggleVisibility,
|
toggleVisibility,
|
||||||
|
|
@ -45,6 +77,13 @@ function onToggleCorners() {
|
||||||
manualExpanded.value = !showIndependentCorners.value
|
manualExpanded.value = !showIndependentCorners.value
|
||||||
toggleIndependentCorners()
|
toggleIndependentCorners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blendModeSelectValue = computed<BlendModeSelectValue>({
|
||||||
|
get: () => (blendModeValue.value === MIXED ? 'MIXED' : blendModeValue.value),
|
||||||
|
set: (value) => {
|
||||||
|
if (value !== 'MIXED') setBlendMode(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -62,7 +101,17 @@ function onToggleCorners() {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</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">
|
<Tip :label="panels.opacity">
|
||||||
<VariableScrubInput
|
<VariableScrubInput
|
||||||
v-if="node"
|
v-if="node"
|
||||||
|
|
@ -93,8 +142,9 @@ function onToggleCorners() {
|
||||||
</template>
|
</template>
|
||||||
</ScrubInput>
|
</ScrubInput>
|
||||||
</Tip>
|
</Tip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="hasCornerRadius">
|
<div v-if="hasCornerRadius" class="mt-1.5 flex gap-1.5">
|
||||||
<Tip :label="panels.radius">
|
<Tip :label="panels.radius">
|
||||||
<VariableScrubInput
|
<VariableScrubInput
|
||||||
v-if="!showIndependentCorners && node"
|
v-if="!showIndependentCorners && node"
|
||||||
|
|
@ -134,7 +184,6 @@ function onToggleCorners() {
|
||||||
>
|
>
|
||||||
<icon-lucide-square-round-corner class="size-3" />
|
<icon-lucide-square-round-corner class="size-3" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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'
|
import type { SelectUi } from '@/components/ui/select'
|
||||||
|
|
||||||
interface AppSelectUi extends SelectUi {
|
interface AppSelectUi extends SelectUi {
|
||||||
|
value?: string
|
||||||
viewport?: string
|
viewport?: string
|
||||||
indicator?: string
|
indicator?: string
|
||||||
}
|
}
|
||||||
|
|
@ -37,6 +38,7 @@ const select = useSelectUI({
|
||||||
content: ui?.content ?? 'max-h-56',
|
content: ui?.content ?? 'max-h-56',
|
||||||
item: ui?.item ?? 'rounded py-1.5 pr-2 pl-6 text-xs'
|
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 viewport = ui?.viewport ?? 'p-0.5'
|
||||||
const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center justify-center'
|
const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center justify-center'
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -44,7 +46,7 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j
|
||||||
<template>
|
<template>
|
||||||
<SelectRoot v-model="modelValue">
|
<SelectRoot v-model="modelValue">
|
||||||
<SelectTrigger v-bind="$attrs" :class="select.trigger" :aria-label="label">
|
<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" />
|
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectPortal>
|
<SelectPortal>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ function getNode(id: string) {
|
||||||
effects: n.effects,
|
effects: n.effects,
|
||||||
opacity: n.opacity,
|
opacity: n.opacity,
|
||||||
visible: n.visible,
|
visible: n.visible,
|
||||||
|
blendMode: n.blendMode,
|
||||||
|
isMask: n.isMask,
|
||||||
|
maskType: n.maskType,
|
||||||
x: n.x,
|
x: n.x,
|
||||||
y: n.y,
|
y: n.y,
|
||||||
width: n.width,
|
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 () => {
|
test('selecting a rectangle shows design panel with type and name', async () => {
|
||||||
await editor.canvas.drawRect(100, 100, 120, 80)
|
await editor.canvas.drawRect(100, 100, 120, 80)
|
||||||
await editor.canvas.waitForRender()
|
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)
|
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 () => {
|
test('visibility toggle in appearance section works', async () => {
|
||||||
const visBtn = editor.page.getByTestId('appearance-visibility')
|
const visBtn = editor.page.getByTestId('appearance-visibility')
|
||||||
await expect(visBtn).toBeVisible()
|
await expect(visBtn).toBeVisible()
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ describe('buildCanvasContextMenu', () => {
|
||||||
'selection.group',
|
'selection.group',
|
||||||
'selection.frameSelection',
|
'selection.frameSelection',
|
||||||
'selection.wrapInAutoLayout',
|
'selection.wrapInAutoLayout',
|
||||||
|
'selection.toggleMask',
|
||||||
'selection.flatten',
|
'selection.flatten',
|
||||||
'selection.outlineText',
|
'selection.outlineText',
|
||||||
'selection.outlineStroke',
|
'selection.outlineStroke',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue