Fix property panel visibility toggles, independent corners/stroke sides, extract shared ColorStyleRow
- Fix fill/stroke/effect/appearance visibility eye toggle (stale clone reads) - Fix independent corner radii toggle not showing 4 inputs - Fix stroke sides toggle: replace dropdown with direct toggle like corners - Copy uniform weight to all sides when expanding stroke sides - Batch multi-selection visibility toggles into single undo entry - Extract ColorStyleRow shared by FillSection and StrokeSection - Replace useFillVariableBinding/useStrokeVariableBinding with useColorVariableBinding(kind) - AppearanceSection uses useAppearance() directly instead of slot wrapper - Add sceneVersion dependency to useNodeProps and PropertyListRoot computeds - Add E2E tests for visibility toggles and corner/stroke side toggles
This commit is contained in:
parent
ae13bb1cbd
commit
c1d7fc80b0
|
|
@ -15,7 +15,7 @@ These APIs are public, but they are more specialized than the main component and
|
|||
|
||||
## Picker and editor internals
|
||||
|
||||
- [useFillVariableBinding](./use-fill-variable-binding)
|
||||
- [useColorVariableBinding](./use-color-variable-binding)
|
||||
- [useFillPicker](./use-fill-picker)
|
||||
- [useGradientStops](./use-gradient-stops)
|
||||
- [useFontPicker](./use-font-picker)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
title: useColorVariableBinding
|
||||
description: Variable-binding helper for fill and stroke color editors.
|
||||
---
|
||||
|
||||
# useColorVariableBinding
|
||||
|
||||
`useColorVariableBinding(kind)` exposes search, binding, and unbinding helpers for color variables used by fill and stroke editors.
|
||||
|
||||
Use it when building color UIs that need to connect fills or strokes to design variables.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { useColorVariableBinding } from '@open-pencil/vue'
|
||||
|
||||
const fillBinding = useColorVariableBinding('fills')
|
||||
const strokeBinding = useColorVariableBinding('strokes')
|
||||
```
|
||||
|
||||
## Related APIs
|
||||
|
||||
- [useFillControls](../composables/use-fill-controls)
|
||||
- [useStrokeControls](../composables/use-stroke-controls)
|
||||
- [FillPickerRoot](../components/fill-picker-root)
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
title: useFillVariableBinding
|
||||
description: Variable-binding helper for fill color editors.
|
||||
---
|
||||
|
||||
# useFillVariableBinding
|
||||
|
||||
`useFillVariableBinding()` exposes search, binding, and unbinding helpers for color variables used by fill editors.
|
||||
|
||||
Use it when building fill UIs that need to connect solid fills to design variables.
|
||||
|
||||
## Related APIs
|
||||
|
||||
- [useFillControls](../composables/use-fill-controls)
|
||||
- [FillPickerRoot](../components/fill-picker-root)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
---
|
||||
title: useFillControls
|
||||
description: Fill-panel composable with default fill behavior and variable binding support.
|
||||
description: Fill-panel composable with default fill behavior.
|
||||
---
|
||||
|
||||
# useFillControls
|
||||
|
||||
`useFillControls()` is the fill-property composable used by fill editing UIs.
|
||||
|
||||
It builds on variable-binding support and adds a reusable default fill value.
|
||||
It adds a reusable default fill value.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -19,11 +19,9 @@ const fills = useFillControls()
|
|||
|
||||
## What it gives you
|
||||
|
||||
Along with fill binding behavior, it exposes:
|
||||
It exposes:
|
||||
|
||||
- `defaultFill`
|
||||
- fill variable-binding helpers
|
||||
- selection-aware fill editing state
|
||||
|
||||
## Practical examples
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ These exports are intentionally public, but they are lower-level or more special
|
|||
|
||||
- `useNodeProps()`
|
||||
- `useSceneComputed()`
|
||||
- `useFillVariableBinding()`
|
||||
- `useColorVariableBinding()`
|
||||
- `useFillPicker()`
|
||||
- `useGradientStops()`
|
||||
- `useFontPicker()`
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { computed } from 'vue'
|
|||
|
||||
import { useEditor } from '@open-pencil/vue/context/editorContext'
|
||||
import { useNodeProps } from '@open-pencil/vue/controls/useNodeProps'
|
||||
import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed'
|
||||
import { providePropertyList } from './context'
|
||||
|
||||
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
|
||||
|
|
@ -26,16 +27,21 @@ const emit = defineEmits<{
|
|||
const editor = useEditor()
|
||||
const { isArrayMixed } = useNodeProps()
|
||||
|
||||
const selectedNodes = computed(() => editor.getSelectedNodes())
|
||||
const activeNode = computed<SceneNode | null>(
|
||||
() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
|
||||
)
|
||||
const selectedNodes = useSceneComputed(() => {
|
||||
void editor.state.sceneVersion
|
||||
return editor.getSelectedNodes()
|
||||
})
|
||||
const activeNode = useSceneComputed<SceneNode | null>(() => {
|
||||
void editor.state.sceneVersion
|
||||
return editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
|
||||
})
|
||||
const isMulti = computed(() => selectedNodes.value.length > 1)
|
||||
const active = computed(() => selectedNodes.value.length > 0)
|
||||
|
||||
const isMixed = computed(() => isArrayMixed(propKey))
|
||||
|
||||
const items = computed(() => {
|
||||
const items = useSceneComputed(() => {
|
||||
void editor.state.sceneVersion
|
||||
if (isMixed.value) return []
|
||||
return (activeNode.value?.[propKey] ?? []) as ArrayItemType[]
|
||||
})
|
||||
|
|
@ -90,10 +96,17 @@ function patch(index: number, changes: Record<string, unknown>) {
|
|||
|
||||
function toggleVisibility(index: number) {
|
||||
emit('toggleVisibility', index)
|
||||
for (const n of targetNodes()) {
|
||||
const arr = n[propKey] as Array<{ visible: boolean }>
|
||||
const nodes = targetNodes()
|
||||
if (nodes.length === 0) return
|
||||
if (nodes.length > 1) {
|
||||
editor.undo.beginBatch(`Toggle ${propKey} visibility`)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const liveNode = editor.getNode(n.id)
|
||||
if (!liveNode) continue
|
||||
const arr = liveNode[propKey] as Array<{ visible: boolean }>
|
||||
if (!arr[index]) continue
|
||||
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
|
||||
const newArr = [...liveNode[propKey]] as Array<{ visible: boolean }>
|
||||
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
|
||||
editor.updateNodeWithUndo(
|
||||
n.id,
|
||||
|
|
@ -101,6 +114,9 @@ function toggleVisibility(index: number) {
|
|||
`Toggle ${propKey} visibility`
|
||||
)
|
||||
}
|
||||
if (nodes.length > 1) {
|
||||
editor.undo.commitBatch()
|
||||
}
|
||||
}
|
||||
|
||||
providePropertyList({
|
||||
|
|
|
|||
|
|
@ -51,15 +51,24 @@ export function useAppearance() {
|
|||
|
||||
function toggleVisibility() {
|
||||
if (isMulti.value) {
|
||||
const allVisible = nodes.value.every((n) => n.visible)
|
||||
for (const n of nodes.value) {
|
||||
const liveNodes = nodes.value
|
||||
.map((n) => editor.getNode(n.id))
|
||||
.filter((n): n is SceneNode => n != null)
|
||||
if (liveNodes.length === 0) return
|
||||
const allVisible = liveNodes.every((n) => n.visible)
|
||||
editor.undo.beginBatch('Toggle visibility')
|
||||
for (const n of liveNodes) {
|
||||
editor.updateNodeWithUndo(n.id, { visible: !allVisible }, 'Toggle visibility')
|
||||
}
|
||||
} else {
|
||||
const n = node.value
|
||||
if (!n) return
|
||||
editor.updateNodeWithUndo(n.id, { visible: !n.visible }, 'Toggle visibility')
|
||||
editor.undo.commitBatch()
|
||||
return
|
||||
}
|
||||
|
||||
const selected = node.value
|
||||
if (!selected) return
|
||||
const liveNode = editor.getNode(selected.id)
|
||||
if (!liveNode) return
|
||||
editor.updateNodeWithUndo(liveNode.id, { visible: !liveNode.visible }, 'Toggle visibility')
|
||||
}
|
||||
|
||||
function toggleIndependentCorners() {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ import { useEditor } from '@open-pencil/vue/context/editorContext'
|
|||
|
||||
import type { Variable } from '@open-pencil/core'
|
||||
|
||||
/**
|
||||
* Returns helpers for binding fill colors to color variables.
|
||||
*
|
||||
* This composable is used by fill editing UIs that need variable search,
|
||||
* binding, and unbinding behavior.
|
||||
*/
|
||||
export function useFillVariableBinding() {
|
||||
type ColorBindingKind = 'fills' | 'strokes'
|
||||
|
||||
export function useColorVariableBinding(kind: ColorBindingKind) {
|
||||
const store = useEditor()
|
||||
const colorVariables = computed(() => store.getVariablesByType('COLOR'))
|
||||
const searchTerm = ref('')
|
||||
|
|
@ -21,19 +17,23 @@ export function useFillVariableBinding() {
|
|||
return colorVariables.value.filter((v) => contains(v.name, searchTerm.value))
|
||||
})
|
||||
|
||||
function bindingPath(index: number) {
|
||||
return `${kind}/${index}/color`
|
||||
}
|
||||
|
||||
function getBoundVariable(nodeId: string, index: number): Variable | undefined {
|
||||
const n = store.getNode(nodeId)
|
||||
if (!n) return undefined
|
||||
const varId = n.boundVariables[`fills/${index}/color`]
|
||||
const varId = n.boundVariables[bindingPath(index)]
|
||||
return varId ? store.getVariable(varId) : undefined
|
||||
}
|
||||
|
||||
function bindFillVariable(nodeId: string, index: number, variableId: string) {
|
||||
store.bindVariable(nodeId, `fills/${index}/color`, variableId)
|
||||
function bindVariable(nodeId: string, index: number, variableId: string) {
|
||||
store.bindVariable(nodeId, bindingPath(index), variableId)
|
||||
}
|
||||
|
||||
function unbindFillVariable(nodeId: string, index: number) {
|
||||
store.unbindVariable(nodeId, `fills/${index}/color`)
|
||||
function unbindVariable(nodeId: string, index: number) {
|
||||
store.unbindVariable(nodeId, bindingPath(index))
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -42,7 +42,7 @@ export function useFillVariableBinding() {
|
|||
searchTerm,
|
||||
filteredVariables,
|
||||
getBoundVariable,
|
||||
bindFillVariable,
|
||||
unbindFillVariable
|
||||
bindVariable,
|
||||
unbindVariable
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { DEFAULT_SHAPE_FILL } from '@open-pencil/core'
|
||||
|
||||
import { useFillVariableBinding } from './useFillVariableBinding'
|
||||
import { useColorVariableBinding } from './useColorVariableBinding'
|
||||
|
||||
/**
|
||||
* Returns fill-related panel helpers and a reusable default fill value.
|
||||
|
|
@ -9,7 +9,7 @@ import { useFillVariableBinding } from './useFillVariableBinding'
|
|||
* fill editing UIs.
|
||||
*/
|
||||
export function useFillControls() {
|
||||
const ctx = useFillVariableBinding()
|
||||
const ctx = useColorVariableBinding('fills')
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,14 @@ type ArrayItem = Fill | Stroke | Effect | Record<string, unknown>
|
|||
*/
|
||||
export function useNodeProps() {
|
||||
const store = useEditor()
|
||||
const node = useSceneComputed(() => store.getSelectedNode() ?? null)
|
||||
const nodes = useSceneComputed(() => store.getSelectedNodes())
|
||||
const node = useSceneComputed(() => {
|
||||
void store.state.sceneVersion
|
||||
return store.getSelectedNode() ?? null
|
||||
})
|
||||
const nodes = useSceneComputed(() => {
|
||||
void store.state.sceneVersion
|
||||
return store.getSelectedNodes()
|
||||
})
|
||||
const isMulti = computed(() => nodes.value.length > 1)
|
||||
const active = computed(() => node.value || isMulti.value)
|
||||
const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export { useTypography } from './controls/useTypography'
|
|||
export type { UseTypographyOptions } from './controls/useTypography'
|
||||
export { useExport } from './controls/useExport'
|
||||
export { useFillControls } from './controls/useFillControls'
|
||||
export { useFillVariableBinding } from './controls/useFillVariableBinding'
|
||||
export { useColorVariableBinding } from './controls/useColorVariableBinding'
|
||||
export { useEffectsControls } from './controls/useEffectsControls'
|
||||
export { useStrokeControls } from './controls/useStrokeControls'
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ const { panels } = useI18n()
|
|||
swatch-class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
|
||||
@update="emit('update', $event)"
|
||||
>
|
||||
<template #trigger="{ style }">
|
||||
<button
|
||||
data-test-id="fill-picker-swatch"
|
||||
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
|
||||
:style="style"
|
||||
/>
|
||||
</template>
|
||||
<template #default="{ fill: currentFill, category, toSolid, toGradient, toImage, update }">
|
||||
<div class="mb-2 flex items-center gap-0.5">
|
||||
<Tip :label="panels.solid">
|
||||
|
|
|
|||
|
|
@ -1,179 +1,172 @@
|
|||
<script setup lang="ts">
|
||||
import { AppearanceControlsRoot, useI18n } from '@open-pencil/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAppearance, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { sectionWrapper } from '@/components/ui/section'
|
||||
|
||||
const { panels } = useI18n()
|
||||
const {
|
||||
node,
|
||||
isMulti,
|
||||
active,
|
||||
hasCornerRadius,
|
||||
independentCorners,
|
||||
cornerRadiusValue,
|
||||
opacityPercent,
|
||||
visibilityState,
|
||||
updateProp,
|
||||
commitProp,
|
||||
toggleVisibility,
|
||||
toggleIndependentCorners,
|
||||
updateCornerProp,
|
||||
commitCornerProp
|
||||
} = useAppearance()
|
||||
|
||||
const manualExpanded = ref<boolean | null>(null)
|
||||
|
||||
const showIndependentCorners = computed(() => {
|
||||
if (manualExpanded.value !== null) return manualExpanded.value
|
||||
if (independentCorners.value === true) return true
|
||||
const n = node.value
|
||||
if (!n) return false
|
||||
return !(
|
||||
n.topLeftRadius === n.topRightRadius &&
|
||||
n.topLeftRadius === n.bottomRightRadius &&
|
||||
n.topLeftRadius === n.bottomLeftRadius
|
||||
)
|
||||
})
|
||||
|
||||
function onToggleCorners() {
|
||||
manualExpanded.value = !showIndependentCorners.value
|
||||
toggleIndependentCorners()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppearanceControlsRoot
|
||||
v-slot="{
|
||||
node,
|
||||
isMulti,
|
||||
active,
|
||||
hasCornerRadius,
|
||||
independentCorners,
|
||||
cornerRadiusValue,
|
||||
opacityPercent,
|
||||
visibilityState,
|
||||
updateProp,
|
||||
commitProp,
|
||||
toggleVisibility,
|
||||
toggleIndependentCorners,
|
||||
updateCornerProp,
|
||||
commitCornerProp
|
||||
}"
|
||||
>
|
||||
<div v-if="active" data-test-id="appearance-section" :class="sectionWrapper()">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<label class="text-[11px] text-muted">{{ panels.appearance }}</label>
|
||||
<Tip :label="panels.toggleVisibility">
|
||||
<div v-if="active" data-test-id="appearance-section" :class="sectionWrapper()">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<label class="text-[11px] text-muted">{{ panels.appearance }}</label>
|
||||
<Tip :label="panels.toggleVisibility">
|
||||
<button
|
||||
data-test-id="appearance-visibility"
|
||||
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-muted hover:bg-hover hover:text-surface"
|
||||
:class="{ 'text-accent': visibilityState === 'hidden' }"
|
||||
@click="toggleVisibility"
|
||||
>
|
||||
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
|
||||
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput
|
||||
suffix="%"
|
||||
:model-value="opacityPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-blend class="size-3" />
|
||||
</template>
|
||||
</ScrubInput>
|
||||
|
||||
<template v-if="hasCornerRadius">
|
||||
<ScrubInput
|
||||
v-if="!showIndependentCorners"
|
||||
:model-value="cornerRadiusValue"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('cornerRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-radius class="size-3" />
|
||||
</template>
|
||||
</ScrubInput>
|
||||
|
||||
<Tip :label="panels.independentCornerRadii">
|
||||
<button
|
||||
data-test-id="appearance-visibility"
|
||||
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-muted hover:bg-hover hover:text-surface"
|
||||
:class="{ 'text-accent': visibilityState === 'hidden' }"
|
||||
@click="toggleVisibility"
|
||||
data-test-id="independent-corners-toggle"
|
||||
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
|
||||
:class="{ '!border-accent !text-accent': showIndependentCorners }"
|
||||
@click="onToggleCorners"
|
||||
>
|
||||
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
|
||||
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M1 4V2.5A1.5 1.5 0 0 1 2.5 1H4" />
|
||||
<path d="M8 1h1.5A2.5 2.5 0 0 1 11 3.5V5" />
|
||||
<path d="M11 8v1a2 2 0 0 1-2 2H8" />
|
||||
<path d="M4 11H3a2 2 0 0 1-2-2V8" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput
|
||||
suffix="%"
|
||||
:model-value="opacityPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-blend class="size-3" />
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<template v-if="hasCornerRadius">
|
||||
<ScrubInput
|
||||
v-if="independentCorners !== true"
|
||||
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-radius class="size-3" />
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<Tip :label="panels.independentCornerRadii">
|
||||
<button
|
||||
data-test-id="independent-corners-toggle"
|
||||
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
|
||||
:class="{ '!border-accent !text-accent': independentCorners === true }"
|
||||
@click="toggleIndependentCorners"
|
||||
>
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M1 4V2.5A1.5 1.5 0 0 1 2.5 1H4" />
|
||||
<path d="M8 1h1.5A2.5 2.5 0 0 1 11 3.5V5" />
|
||||
<path d="M11 8v1a2 2 0 0 1-2 2H8" />
|
||||
<path d="M4 11H3a2 2 0 0 1-2-2V8" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasCornerRadius && independentCorners === true && !isMulti"
|
||||
class="mt-1.5 grid grid-cols-2 gap-1.5"
|
||||
>
|
||||
<ScrubInput
|
||||
data-test-id="corner-tl-input"
|
||||
:model-value="node!.topLeftRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('topLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M1 11V4a3 3 0 0 1 3-3h7" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
data-test-id="corner-tr-input"
|
||||
:model-value="node!.topRightRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('topRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M11 11V4a3 3 0 0 0-3-3H1" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
data-test-id="corner-bl-input"
|
||||
:model-value="node!.bottomLeftRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M1 1v7a3 3 0 0 0 3 3h7" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
data-test-id="corner-br-input"
|
||||
:model-value="node!.bottomRightRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M11 1v7a3 3 0 0 1-3 3H1" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppearanceControlsRoot>
|
||||
|
||||
<div
|
||||
v-if="hasCornerRadius && showIndependentCorners && !isMulti && node"
|
||||
data-test-id="independent-corners-grid"
|
||||
class="mt-1.5 grid grid-cols-2 gap-1.5"
|
||||
>
|
||||
<ScrubInput
|
||||
:model-value="node.topLeftRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('topLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M1 11V4a3 3 0 0 1 3-3h7" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
:model-value="node.topRightRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('topRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 11V4a3 3 0 0 0-3-3H1" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
:model-value="node.bottomLeftRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M1 1v7a3 3 0 0 0 3 3h7" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
:model-value="node.bottomRightRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 1v7a3 3 0 0 1-3 3H1" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
149
src/components/properties/ColorStyleRow.vue
Normal file
149
src/components/properties/ColorStyleRow.vue
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { iconButton } from '@/components/ui/icon-button'
|
||||
|
||||
import { colorToCSS } from '@open-pencil/core'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import type { Variable } from '@open-pencil/core'
|
||||
|
||||
type BindingApi = {
|
||||
store: {
|
||||
resolveColorVariable: (id: string) => unknown
|
||||
}
|
||||
colorVariables: { value: Variable[] }
|
||||
filteredVariables: { value: Variable[] }
|
||||
searchTerm: { value: string }
|
||||
getBoundVariable: (nodeId: string, index: number) => Variable | undefined
|
||||
bindVariable: (nodeId: string, index: number, variableId: string) => void
|
||||
unbindVariable: (nodeId: string, index: number) => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
item: { opacity: number; visible: boolean }
|
||||
index: number
|
||||
activeNodeId?: string | null
|
||||
bindingApi: BindingApi
|
||||
visibilityTestId: string
|
||||
unbindTestId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
patch: [changes: Record<string, unknown>]
|
||||
toggleVisibility: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const { panels, dialogs } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group flex items-center gap-1.5 py-0.5">
|
||||
<div class="min-w-0 flex flex-1 items-center gap-1.5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<ScrubInput
|
||||
class="w-12 shrink-0"
|
||||
suffix="%"
|
||||
:model-value="Math.round(item.opacity * 100)"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="emit('patch', { opacity: Math.max(0, Math.min(1, $event / 100)) })"
|
||||
/>
|
||||
|
||||
<PopoverRoot
|
||||
v-if="
|
||||
activeNodeId &&
|
||||
bindingApi.colorVariables.value.length > 0 &&
|
||||
!bindingApi.getBoundVariable(activeNodeId, index)
|
||||
"
|
||||
>
|
||||
<Tip :label="panels.applyVariable">
|
||||
<PopoverTrigger
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
>
|
||||
<icon-lucide-link class="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
</Tip>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
:side-offset="8"
|
||||
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
|
||||
>
|
||||
<ComboboxRoot
|
||||
@update:model-value="
|
||||
activeNodeId && bindingApi.bindVariable(activeNodeId, index, ($event as Variable).id)
|
||||
"
|
||||
>
|
||||
<ComboboxInput
|
||||
:model-value="bindingApi.searchTerm.value"
|
||||
:placeholder="dialogs.search"
|
||||
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
|
||||
@update:model-value="bindingApi.searchTerm.value = String($event)"
|
||||
/>
|
||||
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
|
||||
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted"
|
||||
>{{ panels.noVariablesFound }}</ComboboxEmpty
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="v in bindingApi.filteredVariables.value"
|
||||
:key="v.id"
|
||||
:value="v"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
|
||||
>
|
||||
<div
|
||||
class="size-3 shrink-0 rounded-sm border border-border"
|
||||
:style="{
|
||||
background: bindingApi.store.resolveColorVariable(v.id)
|
||||
? colorToCSS(bindingApi.store.resolveColorVariable(v.id) as never)
|
||||
: '#000'
|
||||
}"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
|
||||
<Tip
|
||||
v-else-if="activeNodeId && bindingApi.getBoundVariable(activeNodeId, index)"
|
||||
:label="panels.detachVariable"
|
||||
>
|
||||
<button
|
||||
:data-test-id="unbindTestId"
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
|
||||
@click="bindingApi.unbindVariable(activeNodeId, index)"
|
||||
>
|
||||
<icon-lucide-unlink class="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
|
||||
<button
|
||||
:data-test-id="visibilityTestId"
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
@click="emit('toggleVisibility')"
|
||||
>
|
||||
<icon-lucide-eye v-if="item.visible" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<button :class="iconButton({ ui: { base: 'shrink-0' } })" @click="emit('remove')">−</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -36,8 +36,8 @@ const { panels } = useI18n()
|
|||
|
||||
<div
|
||||
v-for="(effect, i) in items as Effect[]"
|
||||
:key="i"
|
||||
data-test-id="effects-item"
|
||||
:key="`${i}:${effect.visible ? 'visible' : 'hidden'}`"
|
||||
data-test-id="effect-item"
|
||||
:data-test-index="i"
|
||||
>
|
||||
<div class="group flex items-center gap-1.5 py-0.5">
|
||||
|
|
@ -64,6 +64,7 @@ const { panels } = useI18n()
|
|||
/>
|
||||
|
||||
<button
|
||||
:data-test-id="`effect-visibility-${i}`"
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
@click="toggleVisibility(i)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
|
||||
import { colorToHexRaw } from '@open-pencil/core'
|
||||
import { PropertyListRoot, useFillControls, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import FillPicker from '@/components/FillPicker.vue'
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
|
||||
import { iconButton } from '@/components/ui/icon-button'
|
||||
import { sectionLabel, sectionWrapper } from '@/components/ui/section'
|
||||
|
||||
import type { Fill, Variable } from '@open-pencil/core'
|
||||
import type { Fill } from '@open-pencil/core'
|
||||
|
||||
const fillCtx = useFillControls()
|
||||
const { panels, dialogs } = useI18n()
|
||||
const { panels } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -44,12 +31,20 @@ const { panels, dialogs } = useI18n()
|
|||
</button>
|
||||
</div>
|
||||
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
|
||||
<div
|
||||
<ColorStyleRow
|
||||
v-for="(fill, i) in items as Fill[]"
|
||||
:key="i"
|
||||
:key="`${i}:${fill.visible ? 'visible' : 'hidden'}`"
|
||||
:item="fill"
|
||||
:index="i"
|
||||
:active-node-id="activeNode?.id ?? null"
|
||||
:binding-api="fillCtx"
|
||||
:visibility-test-id="`fill-visibility-${i}`"
|
||||
unbind-test-id="fill-unbind-variable"
|
||||
data-test-id="fill-item"
|
||||
:data-test-index="i"
|
||||
class="group flex items-center gap-1.5 py-0.5"
|
||||
@patch="patch(i, $event)"
|
||||
@toggle-visibility="toggleVisibility(i)"
|
||||
@remove="remove(i)"
|
||||
>
|
||||
<FillPicker :fill="fill" @update="update(i, $event)" />
|
||||
|
||||
|
|
@ -59,15 +54,6 @@ const { panels, dialogs } = useI18n()
|
|||
>
|
||||
{{ fillCtx.getBoundVariable(activeNode.id, i)!.name }}
|
||||
</span>
|
||||
<Tip :label="panels.detachVariable">
|
||||
<button
|
||||
data-test-id="fill-unbind-variable"
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
|
||||
@click="fillCtx.unbindFillVariable(activeNode.id, i)"
|
||||
>
|
||||
<icon-lucide-unlink class="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="min-w-0 flex-1 font-mono text-xs text-surface">
|
||||
|
|
@ -78,83 +64,7 @@ const { panels, dialogs } = useI18n()
|
|||
<template v-else>{{ fill.type }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<ScrubInput
|
||||
class="w-12"
|
||||
suffix="%"
|
||||
:model-value="Math.round(fill.opacity * 100)"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="patch(i, { opacity: Math.max(0, Math.min(1, $event / 100)) })"
|
||||
/>
|
||||
|
||||
<PopoverRoot
|
||||
v-if="
|
||||
fillCtx.colorVariables.value.length > 0 &&
|
||||
fill.type === 'SOLID' &&
|
||||
activeNode &&
|
||||
!fillCtx.getBoundVariable(activeNode.id, i)
|
||||
"
|
||||
>
|
||||
<Tip :label="panels.applyVariable">
|
||||
<PopoverTrigger
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
>
|
||||
<icon-lucide-link class="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
</Tip>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
:side-offset="8"
|
||||
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
|
||||
>
|
||||
<ComboboxRoot
|
||||
@update:model-value="
|
||||
activeNode && fillCtx.bindFillVariable(activeNode.id, i, ($event as Variable).id)
|
||||
"
|
||||
>
|
||||
<ComboboxInput
|
||||
:model-value="fillCtx.searchTerm.value"
|
||||
:placeholder="dialogs.search"
|
||||
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
|
||||
@update:model-value="fillCtx.searchTerm.value = String($event)"
|
||||
/>
|
||||
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
|
||||
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted"
|
||||
>{{ panels.noVariablesFound }}</ComboboxEmpty
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="v in fillCtx.filteredVariables.value"
|
||||
:key="v.id"
|
||||
:value="v"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
|
||||
>
|
||||
<div
|
||||
class="size-3 shrink-0 rounded-sm border border-border"
|
||||
:style="{
|
||||
background: fillCtx.store.resolveColorVariable(v.id)
|
||||
? colorToCSS(fillCtx.store.resolveColorVariable(v.id)!)
|
||||
: '#000'
|
||||
}"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
|
||||
<button
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
@click="toggleVisibility(i)"
|
||||
>
|
||||
<icon-lucide-eye v-if="fill.visible" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else class="size-3.5" />
|
||||
</button>
|
||||
<button :class="iconButton()" @click="remove(i)">−</button>
|
||||
</div>
|
||||
</ColorStyleRow>
|
||||
</div>
|
||||
</PropertyListRoot>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PropertyListRoot, useStrokeControls, useI18n } from '@open-pencil/vue'
|
||||
import { PropertyListRoot, useColorVariableBinding, useStrokeControls, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import ColorInput from '@/components/ColorInput.vue'
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { iconButton } from '@/components/ui/icon-button'
|
||||
import { menu, useMenuUI } from '@/components/ui/menu'
|
||||
import { sectionLabel, sectionWrapper } from '@/components/ui/section'
|
||||
|
||||
import type { SceneNode, Stroke } from '@open-pencil/core'
|
||||
|
||||
const strokeCtx = useStrokeControls()
|
||||
const strokeVarCtx = useColorVariableBinding('strokes')
|
||||
const { panels } = useI18n()
|
||||
const sideMenuCls = useMenuUI({
|
||||
content: 'min-w-[140px] rounded-md p-0.5',
|
||||
item: 'relative px-2'
|
||||
})
|
||||
|
||||
const expandedSides = ref(false)
|
||||
|
||||
function onToggleSides(activeNode: SceneNode) {
|
||||
const next = !expandedSides.value
|
||||
expandedSides.value = next
|
||||
if (next && !activeNode.independentStrokeWeights) {
|
||||
const weight = activeNode.strokes[0]?.weight ?? 1
|
||||
strokeCtx.selectSide('CUSTOM', { ...activeNode, borderTopWeight: weight, borderRightWeight: weight, borderBottomWeight: weight, borderLeftWeight: weight } as SceneNode)
|
||||
} else if (!next && activeNode.independentStrokeWeights) {
|
||||
strokeCtx.selectSide('ALL', activeNode)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -47,31 +51,26 @@ const sideMenuCls = useMenuUI({
|
|||
|
||||
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
|
||||
|
||||
<div
|
||||
<ColorStyleRow
|
||||
v-for="(stroke, i) in items as Stroke[]"
|
||||
:key="i"
|
||||
:key="`${i}:${stroke.visible ? 'visible' : 'hidden'}`"
|
||||
:item="stroke"
|
||||
:index="i"
|
||||
:active-node-id="activeNode?.id ?? null"
|
||||
:binding-api="strokeVarCtx"
|
||||
:visibility-test-id="`stroke-visibility-${i}`"
|
||||
unbind-test-id="stroke-unbind-variable"
|
||||
data-test-id="stroke-item"
|
||||
:data-test-index="i"
|
||||
class="group flex items-center gap-1.5 py-0.5"
|
||||
@patch="patch(i, $event)"
|
||||
@toggle-visibility="toggleVisibility(i)"
|
||||
@remove="remove(i)"
|
||||
>
|
||||
<ColorInput
|
||||
class="min-w-0 flex-1"
|
||||
:color="stroke.color"
|
||||
editable
|
||||
@update="patch(i, { color: $event })"
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
@click="toggleVisibility(i)"
|
||||
>
|
||||
<icon-lucide-eye v-if="stroke.visible" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else class="size-3.5" />
|
||||
</button>
|
||||
<button :class="iconButton({ ui: { base: 'shrink-0' } })" @click="remove(i)">−</button>
|
||||
</div>
|
||||
<ColorInput class="min-w-0 flex-1" :color="stroke.color" editable @update="patch(i, { color: $event })" />
|
||||
</ColorStyleRow>
|
||||
|
||||
<div
|
||||
v-if="!isMixed && activeNode && activeNode.strokes.length > 0"
|
||||
v-if="!isMixed && (items as unknown[]).length > 0"
|
||||
class="mt-1 flex items-center gap-1.5"
|
||||
>
|
||||
<AppSelect
|
||||
|
|
@ -81,7 +80,7 @@ const sideMenuCls = useMenuUI({
|
|||
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
|
||||
/>
|
||||
<ScrubInput
|
||||
v-if="!activeNode.independentStrokeWeights"
|
||||
v-if="!expandedSides"
|
||||
class="flex-1"
|
||||
:model-value="activeNode.strokes[0]?.weight ?? 1"
|
||||
:min="0"
|
||||
|
|
@ -101,80 +100,25 @@ const sideMenuCls = useMenuUI({
|
|||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<DropdownMenuRoot v-model:open="strokeCtx.sideMenuOpen.value">
|
||||
<Tip :label="panels.strokeSides">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
|
||||
:class="{ '!border-accent !text-accent': activeNode.independentStrokeWeights }"
|
||||
>
|
||||
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="1" y="1" width="5" height="5" rx="1" />
|
||||
<rect x="8" y="1" width="5" height="5" rx="1" />
|
||||
<rect x="1" y="8" width="5" height="5" rx="1" />
|
||||
<rect x="8" y="8" width="5" height="5" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent :side-offset="4" align="end" :class="sideMenuCls.content">
|
||||
<DropdownMenuItem
|
||||
v-for="opt in strokeCtx.sideOptions"
|
||||
:key="opt.value"
|
||||
:class="menu({ justify: 'start' }).item({ class: sideMenuCls.item })"
|
||||
@click="strokeCtx.selectSide(opt.value, activeNode)"
|
||||
>
|
||||
<icon-lucide-check
|
||||
v-if="strokeCtx.currentSides(activeNode) === opt.value"
|
||||
class="absolute left-2 size-3 text-accent"
|
||||
/>
|
||||
<span class="flex items-center gap-2 pl-5">
|
||||
<svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<template v-if="opt.value === 'ALL'">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1" />
|
||||
</template>
|
||||
<template v-else-if="opt.value === 'CUSTOM'">
|
||||
<line x1="4" y1="7" x2="10" y2="7" />
|
||||
<line x1="7" y1="4" x2="7" y2="10" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="1"
|
||||
stroke-opacity="0.3"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<line v-if="opt.value === 'TOP'" x1="1" y1="1" x2="13" y2="1" />
|
||||
<line v-else-if="opt.value === 'BOTTOM'" x1="1" y1="13" x2="13" y2="13" />
|
||||
<line v-else-if="opt.value === 'LEFT'" x1="1" y1="1" x2="1" y2="13" />
|
||||
<line v-else-if="opt.value === 'RIGHT'" x1="13" y1="1" x2="13" y2="13" />
|
||||
</template>
|
||||
</svg>
|
||||
<span>{{ opt.label }}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
<Tip :label="panels.strokeSides">
|
||||
<button
|
||||
data-test-id="stroke-sides-toggle"
|
||||
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
|
||||
:class="{ '!border-accent !text-accent': expandedSides }"
|
||||
@click="onToggleSides(activeNode)"
|
||||
>
|
||||
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="1" y="1" width="5" height="5" rx="1" />
|
||||
<rect x="8" y="1" width="5" height="5" rx="1" />
|
||||
<rect x="1" y="8" width="5" height="5" rx="1" />
|
||||
<rect x="8" y="8" width="5" height="5" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!isMixed &&
|
||||
activeNode &&
|
||||
activeNode.strokes.length > 0 &&
|
||||
activeNode.independentStrokeWeights
|
||||
"
|
||||
v-if="!isMixed && (items as unknown[]).length > 0 && expandedSides"
|
||||
class="mt-1.5 grid grid-cols-2 gap-1.5"
|
||||
>
|
||||
<ScrubInput
|
||||
|
|
|
|||
92
tests/e2e/corner-stroke-toggles.spec.ts
Normal file
92
tests/e2e/corner-stroke-toggles.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '../helpers/canvas'
|
||||
|
||||
let page: Page
|
||||
let canvas: CanvasHelper
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await page.goto('/')
|
||||
canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close()
|
||||
})
|
||||
|
||||
async function getSelectedNodeFlags() {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__OPEN_PENCIL_STORE__!
|
||||
const id = [...store.state.selectedIds][0]
|
||||
if (!id) return null
|
||||
const n = store.graph.getNode(id)
|
||||
if (!n) return null
|
||||
return {
|
||||
type: n.type,
|
||||
independentCorners: n.independentCorners,
|
||||
independentStrokeWeights: n.independentStrokeWeights
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function drawFrame(x: number, y: number, w: number, h: number) {
|
||||
await canvas.pressKey('f')
|
||||
await canvas.drag(x, y, x + w, y + h)
|
||||
await canvas.waitForRender()
|
||||
}
|
||||
|
||||
test('independent corners toggle shows per-corner inputs', async () => {
|
||||
await drawFrame(120, 120, 120, 80)
|
||||
await canvas.waitForRender()
|
||||
|
||||
const flags = await getSelectedNodeFlags()
|
||||
expect(flags!.type).toBe('FRAME')
|
||||
expect(flags!.independentCorners).toBe(false)
|
||||
|
||||
const toggle = page.locator('[data-test-id="independent-corners-toggle"]')
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
expect((await getSelectedNodeFlags())!.independentCorners).toBe(true)
|
||||
const grid = page.locator('[data-test-id="independent-corners-grid"]')
|
||||
await expect(grid).toBeVisible()
|
||||
const cornerInputs = grid.locator('[data-test-id="scrub-input"]')
|
||||
expect(await cornerInputs.count()).toBe(4)
|
||||
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(grid).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('stroke sides toggle shows per-side weight inputs', async () => {
|
||||
await drawFrame(300, 50, 120, 80)
|
||||
await canvas.waitForRender()
|
||||
|
||||
const addStroke = page.locator('[data-test-id="stroke-section-add"]')
|
||||
await expect(addStroke).toBeVisible()
|
||||
await addStroke.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const toggle = page.locator('[data-test-id="stroke-sides-toggle"]')
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const sectionInputsBefore = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count()
|
||||
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const sectionInputsAfter = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count()
|
||||
expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore)
|
||||
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const sectionInputsFinal = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count()
|
||||
expect(sectionInputsFinal).toBe(sectionInputsBefore)
|
||||
})
|
||||
|
|
@ -130,7 +130,7 @@ test('adding an effect creates effect item', async () => {
|
|||
await addBtn.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const effectItems = effectsSection().locator('[data-test-id="effects-item"]')
|
||||
const effectItems = effectsSection().locator('[data-test-id="effect-item"]')
|
||||
await expect(effectItems.first()).toBeVisible()
|
||||
|
||||
const id = await getSelectedId()
|
||||
|
|
@ -172,6 +172,78 @@ test('visibility toggle in appearance section works', async () => {
|
|||
expect(restored!.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('fill stroke and effect visibility toggles update on repeated clicks and support undo redo', async () => {
|
||||
const id = await getSelectedId()
|
||||
expect(id).toBeTruthy()
|
||||
|
||||
const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
|
||||
await expect(fillButton).toBeVisible()
|
||||
|
||||
const initial = await getNode(id!)
|
||||
expect(initial!.fills[0]?.visible).toBe(true)
|
||||
|
||||
await fillButton.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(fillButton.locator('icon-lucide-eye-off')).toBeVisible()
|
||||
expect((await getNode(id!))!.fills[0]?.visible).toBe(false)
|
||||
|
||||
await fillButton.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(fillButton.locator('icon-lucide-eye')).toBeVisible()
|
||||
expect((await getNode(id!))!.fills[0]?.visible).toBe(true)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getNode(id!))!.fills[0]?.visible).toBe(false)
|
||||
await canvas.redo()
|
||||
expect((await getNode(id!))!.fills[0]?.visible).toBe(true)
|
||||
|
||||
const strokeAddButton = strokeSection().locator('[data-test-id="stroke-section-add"]')
|
||||
await strokeAddButton.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
|
||||
await expect(strokeButton).toBeVisible()
|
||||
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
|
||||
|
||||
await strokeButton.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(strokeButton.locator('icon-lucide-eye-off')).toBeVisible()
|
||||
expect((await getNode(id!))!.strokes[0]?.visible).toBe(false)
|
||||
|
||||
await strokeButton.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(strokeButton.locator('icon-lucide-eye')).toBeVisible()
|
||||
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getNode(id!))!.strokes[0]?.visible).toBe(false)
|
||||
await canvas.redo()
|
||||
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
|
||||
|
||||
const effectAddButton = effectsSection().locator('[data-test-id="effects-section-add"]')
|
||||
await effectAddButton.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const effectButton = page.locator('[data-test-id="effect-visibility-0"]')
|
||||
await expect(effectButton).toBeVisible()
|
||||
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
|
||||
|
||||
await effectButton.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(effectButton.locator('icon-lucide-eye-off')).toBeVisible()
|
||||
expect((await getNode(id!))!.effects[0]?.visible).toBe(false)
|
||||
|
||||
await effectButton.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(effectButton.locator('icon-lucide-eye')).toBeVisible()
|
||||
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getNode(id!))!.effects[0]?.visible).toBe(false)
|
||||
await canvas.redo()
|
||||
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('deselecting shows empty design panel', async () => {
|
||||
await page.keyboard.press('Escape')
|
||||
await canvas.waitForRender()
|
||||
|
|
|
|||
100
tests/e2e/property-visibility.spec.ts
Normal file
100
tests/e2e/property-visibility.spec.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '../helpers/canvas'
|
||||
|
||||
let page: Page
|
||||
let canvas: CanvasHelper
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await page.goto('/')
|
||||
canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close()
|
||||
})
|
||||
|
||||
function getSelectedNode() {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__OPEN_PENCIL_STORE__!
|
||||
const id = [...store.state.selectedIds][0]
|
||||
if (!id) return null
|
||||
const n = store.graph.getNode(id)
|
||||
if (!n) return null
|
||||
return {
|
||||
fills: n.fills,
|
||||
strokes: n.strokes,
|
||||
visible: n.visible
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('fill visibility supports repeat click and undo redo', async () => {
|
||||
await canvas.drawRect(120, 120, 120, 80)
|
||||
await canvas.waitForRender()
|
||||
|
||||
const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
|
||||
await expect(fillButton).toBeVisible()
|
||||
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
|
||||
|
||||
await fillButton.click()
|
||||
await canvas.waitForRender()
|
||||
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false)
|
||||
|
||||
await fillButton.click()
|
||||
await canvas.waitForRender()
|
||||
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false)
|
||||
|
||||
await canvas.redo()
|
||||
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('stroke visibility supports repeat click and undo redo', async () => {
|
||||
await page.locator('[data-test-id="stroke-section-add"]').click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
|
||||
await expect(strokeButton).toBeVisible()
|
||||
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
|
||||
|
||||
await strokeButton.click()
|
||||
await canvas.waitForRender()
|
||||
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false)
|
||||
|
||||
await strokeButton.click()
|
||||
await canvas.waitForRender()
|
||||
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false)
|
||||
|
||||
await canvas.redo()
|
||||
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('appearance visibility supports repeat click and undo redo in one step', async () => {
|
||||
const visibilityButton = page.locator('[data-test-id="appearance-visibility"]')
|
||||
await expect(visibilityButton).toBeVisible()
|
||||
expect((await getSelectedNode())!.visible).toBe(true)
|
||||
|
||||
await visibilityButton.click()
|
||||
await canvas.waitForRender()
|
||||
expect((await getSelectedNode())!.visible).toBe(false)
|
||||
|
||||
await visibilityButton.click()
|
||||
await canvas.waitForRender()
|
||||
expect((await getSelectedNode())!.visible).toBe(true)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getSelectedNode())!.visible).toBe(false)
|
||||
|
||||
await canvas.undo()
|
||||
expect((await getSelectedNode())!.visible).toBe(true)
|
||||
})
|
||||
Loading…
Reference in a new issue