Phase 1: establish SDK component pattern with 3 exemplars

New Reka UI-style headless components following the pattern:
  ComponentName/context.ts — InjectionKey, provide/inject
  ComponentName/Root.vue — state management, slot props
  ComponentName/Item.vue — per-item logic, slot props
  ComponentName/index.ts — barrel export

ScrubInput (new headless):
  ScrubInputRoot — scrub state, pointer capture, edit/commit logic
  ScrubInputField — input element for direct editing
  ScrubInputDisplay — read-only display with slot for value/suffix
  App ScrubInput.vue composes these with Tailwind styling

Toolbar (new headless):
  ToolbarRoot — active tool, tool list, flyout state
  ToolbarItem — per-tool active state and select handler

PageList (refactored):
  PageListRoot — pages, currentPageId, CRUD actions via slots

Old components kept as backward-compatible aliases.
This commit is contained in:
Danila Poyarkov 2026-03-17 09:12:27 +03:00
parent bc10c3c64e
commit d4c102c6f4
14 changed files with 386 additions and 131 deletions

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../context'
const editor = useEditor()
const pages = computed(() => {
void editor.state.sceneVersion
return editor.graph.getPages()
})
const currentPageId = computed(() => editor.state.currentPageId)
</script>
<template>
<slot
:pages="pages"
:current-page-id="currentPageId"
:switch-page="editor.switchPage"
:add-page="editor.addPage"
:delete-page="editor.deletePage"
:rename-page="editor.renamePage"
/>
</template>

View file

@ -0,0 +1 @@
export { default as PageListRoot } from './PageListRoot.vue'

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { useScrubInput } from './context'
const ctx = useScrubInput()
</script>
<template>
<span v-if="!ctx.editing.value" v-bind="$attrs">
<slot
:value="ctx.displayValue.value"
:is-mixed="ctx.isMixed.value"
:placeholder="''"
>
{{ ctx.isMixed.value ? '' : ctx.displayValue.value }}
</slot>
</span>
</template>
<script lang="ts">
export default { inheritAttrs: false }
</script>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { useScrubInput } from './context'
const ctx = useScrubInput()
</script>
<template>
<input
v-if="ctx.editing.value"
:ref="(el) => { ctx.inputRef.value = el as HTMLInputElement | null }"
type="number"
:value="ctx.isMixed.value ? '' : ctx.displayValue.value"
v-bind="$attrs"
@blur="ctx.commitEdit"
@keydown="ctx.onKeydown"
/>
</template>
<script lang="ts">
export default { inheritAttrs: false }
</script>

View file

@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import { useEventListener } from '@vueuse/core'
import { provideScrubInput } from './context'
const props = withDefaults(defineProps<{
modelValue: number | symbol
min?: number
max?: number
step?: number
sensitivity?: number
placeholder?: string
}>(), {
min: -Infinity,
max: Infinity,
step: 1,
sensitivity: 1,
placeholder: 'Mixed'
})
const emit = defineEmits<{
'update:modelValue': [value: number]
commit: [value: number, previous: number]
}>()
const editing = ref(false)
const scrubbing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const isMixed = computed(() => typeof props.modelValue === 'symbol')
const numericValue = computed(() => isMixed.value ? 0 : (props.modelValue as number))
const displayValue = computed(() => isMixed.value ? '' : String(Math.round(numericValue.value)))
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
function startScrub(e: PointerEvent) {
e.preventDefault()
const startX = e.clientX
let lastX = startX
let accumulated = numericValue.value
const valueBeforeScrub = numericValue.value
let hasMoved = false
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
const dx = ev.clientX - lastX
lastX = ev.clientX
if (!hasMoved && Math.abs(ev.clientX - startX) > 2) {
hasMoved = true
scrubbing.value = true
document.body.style.cursor = 'ew-resize'
}
if (hasMoved) {
accumulated += dx * props.step * props.sensitivity
const clamped = Math.round(Math.min(props.max, Math.max(props.min, accumulated)))
if (clamped !== props.modelValue) emit('update:modelValue', clamped)
}
})
stopUp = useEventListener(document, 'pointerup', () => {
scrubbing.value = false
document.body.style.cursor = ''
stopMove?.()
stopUp?.()
if (hasMoved) {
if (typeof props.modelValue === 'number' && props.modelValue !== valueBeforeScrub) {
emit('commit', props.modelValue, valueBeforeScrub)
}
} else {
startEdit()
}
})
}
function startEdit() {
editing.value = true
requestAnimationFrame(() => inputRef.value?.select())
}
function commitEdit(e: Event) {
const val = +(e.target as HTMLInputElement).value
const previous = numericValue.value
if (!Number.isNaN(val)) {
const clamped = Math.min(props.max, Math.max(props.min, val))
emit('update:modelValue', clamped)
if (clamped !== previous) emit('commit', clamped, previous)
}
editing.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') commitEdit(e)
else if (e.key === 'Escape') editing.value = false
}
const ctx = {
modelValue: toRef(props, 'modelValue'),
displayValue,
isMixed,
editing,
scrubbing,
inputRef,
startScrub,
startEdit,
commitEdit,
onKeydown
}
provideScrubInput(ctx)
</script>
<template>
<slot
:model-value="props.modelValue"
:display-value="displayValue"
:is-mixed="isMixed"
:editing="editing"
:scrubbing="scrubbing"
:start-scrub="startScrub"
:start-edit="startEdit"
:commit-edit="commitEdit"
:on-keydown="onKeydown"
:placeholder="props.placeholder"
/>
</template>

View file

@ -0,0 +1,27 @@
import { type InjectionKey, inject, provide } from 'vue'
import type { Ref } from 'vue'
export interface ScrubInputContext {
modelValue: Ref<number | symbol>
displayValue: Ref<string>
isMixed: Ref<boolean>
editing: Ref<boolean>
scrubbing: Ref<boolean>
inputRef: Ref<HTMLInputElement | null>
startScrub: (e: PointerEvent) => void
startEdit: () => void
commitEdit: (e: Event) => void
onKeydown: (e: KeyboardEvent) => void
}
export const SCRUB_INPUT_KEY: InjectionKey<ScrubInputContext> = Symbol('scrub-input')
export function provideScrubInput(ctx: ScrubInputContext) {
provide(SCRUB_INPUT_KEY, ctx)
}
export function useScrubInput(): ScrubInputContext {
const ctx = inject(SCRUB_INPUT_KEY)
if (!ctx) throw new Error('[open-pencil] useScrubInput() called outside <ScrubInputRoot>')
return ctx
}

View file

@ -0,0 +1,5 @@
export { default as ScrubInputRoot } from './ScrubInputRoot.vue'
export { default as ScrubInputField } from './ScrubInputField.vue'
export { default as ScrubInputDisplay } from './ScrubInputDisplay.vue'
export { useScrubInput } from './context'
export type { ScrubInputContext } from './context'

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useToolbar } from './context'
import type { Tool } from '@open-pencil/core/editor'
const { tool } = defineProps<{
tool: Tool
}>()
const { activeTool, setTool } = useToolbar()
const isActive = computed(() => activeTool.value === tool)
</script>
<template>
<slot
:active="isActive"
:select="() => setTool(tool)"
:tool="tool"
/>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { EDITOR_TOOLS } from '@open-pencil/core/editor'
import { useEditor } from '../context'
import { provideToolbar } from './context'
import type { EditorToolDef, Tool } from '@open-pencil/core/editor'
const { tools = EDITOR_TOOLS } = defineProps<{
tools?: EditorToolDef[]
}>()
const editor = useEditor()
const activeTool = computed(() => editor.state.activeTool)
const expandedFlyout = ref<Tool | null>(null)
function setTool(tool: Tool) {
editor.setTool(tool)
expandedFlyout.value = null
}
function toggleFlyout(tool: Tool) {
expandedFlyout.value = expandedFlyout.value === tool ? null : tool
}
function closeFlyout() {
expandedFlyout.value = null
}
provideToolbar({
editor,
tools,
activeTool,
expandedFlyout,
setTool,
toggleFlyout,
closeFlyout
})
</script>
<template>
<slot
:tools="tools"
:active-tool="activeTool"
:expanded-flyout="expandedFlyout"
:set-tool="setTool"
:toggle-flyout="toggleFlyout"
:close-flyout="closeFlyout"
/>
</template>

View file

@ -0,0 +1,26 @@
import { type InjectionKey, inject, provide } from 'vue'
import type { Editor, EditorToolDef, Tool } from '@open-pencil/core/editor'
import type { ComputedRef, Ref } from 'vue'
export interface ToolbarContext {
editor: Editor
tools: EditorToolDef[]
activeTool: ComputedRef<Tool>
expandedFlyout: Ref<Tool | null>
setTool: (tool: Tool) => void
toggleFlyout: (tool: Tool) => void
closeFlyout: () => void
}
export const TOOLBAR_KEY: InjectionKey<ToolbarContext> = Symbol('toolbar')
export function provideToolbar(ctx: ToolbarContext) {
provide(TOOLBAR_KEY, ctx)
}
export function useToolbar(): ToolbarContext {
const ctx = inject(TOOLBAR_KEY)
if (!ctx) throw new Error('[open-pencil] useToolbar() called outside <ToolbarRoot>')
return ctx
}

View file

@ -0,0 +1,4 @@
export { default as ToolbarRoot } from './ToolbarRoot.vue'
export { default as ToolbarItem } from './ToolbarItem.vue'
export { useToolbar } from './context'
export type { ToolbarContext } from './context'

View file

@ -4,7 +4,6 @@ import { computed } from 'vue'
import { useEditor } from '../context'
const editor = useEditor()
const activeTool = computed(() => editor.state.activeTool)
</script>

View file

@ -18,8 +18,20 @@ export { useNodeFontStatus } from './composables/use-font-status'
export { toast } from './toast'
export type { Toast, ToastVariant } from './toast'
export {
ScrubInputRoot,
ScrubInputField,
ScrubInputDisplay,
useScrubInput
} from './ScrubInput'
export type { ScrubInputContext } from './ScrubInput'
export { ToolbarRoot, ToolbarItem, useToolbar } from './Toolbar'
export type { ToolbarContext } from './Toolbar'
export { default as OpenPencilProvider } from './components/OpenPencilProvider.vue'
export { default as OpenPencilCanvas } from './components/OpenPencilCanvas.vue'
export { PageListRoot } from './PageList'
export { default as PageList } from './components/PageList.vue'
export { default as LayerTree } from './components/LayerTree.vue'
export { default as ToolSelector } from './components/ToolSelector.vue'

View file

@ -1,18 +1,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useEventListener } from '@vueuse/core'
import { ScrubInputRoot, ScrubInputField, ScrubInputDisplay } from '@open-pencil/vue'
const {
modelValue,
min = -Infinity,
max = Infinity,
step = 1,
icon,
label,
suffix,
sensitivity = 1,
placeholder = 'Mixed'
} = defineProps<{
const { modelValue, min, max, step, icon, label, suffix, sensitivity, placeholder } = defineProps<{
modelValue: number | symbol
min?: number
max?: number
@ -24,130 +13,56 @@ const {
placeholder?: string
}>()
const isMixed = computed(() => typeof modelValue === 'symbol')
const emit = defineEmits<{
'update:modelValue': [value: number]
commit: [value: number, previous: number]
}>()
const editing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const scrubbing = ref(false)
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
const numericValue = computed(() => (isMixed.value ? 0 : (modelValue as number)))
const displayValue = computed(() => (isMixed.value ? '' : Math.round(numericValue.value)))
function startScrub(e: PointerEvent) {
e.preventDefault()
const startX = e.clientX
let lastX = startX
let accumulated = numericValue.value
const valueBeforeScrub = numericValue.value
let hasMoved = false
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
const dx = ev.clientX - lastX
lastX = ev.clientX
if (!hasMoved && Math.abs(ev.clientX - startX) > 2) {
hasMoved = true
scrubbing.value = true
document.body.style.cursor = 'ew-resize'
}
if (hasMoved) {
accumulated += dx * step * sensitivity
const clamped = Math.round(Math.min(max, Math.max(min, accumulated)))
if (clamped !== modelValue) {
emit('update:modelValue', clamped)
}
}
})
stopUp = useEventListener(document, 'pointerup', () => {
scrubbing.value = false
document.body.style.cursor = ''
stopMove?.()
stopUp?.()
if (hasMoved) {
if (typeof modelValue === 'number' && modelValue !== valueBeforeScrub) {
emit('commit', modelValue, valueBeforeScrub)
}
} else {
startEdit()
}
})
}
function startEdit() {
editing.value = true
requestAnimationFrame(() => {
inputRef.value?.select()
})
}
function commitEdit(e: Event) {
const val = +(e.target as HTMLInputElement).value
const previous = numericValue.value
if (!Number.isNaN(val)) {
const clamped = Math.min(max, Math.max(min, val))
emit('update:modelValue', clamped)
if (clamped !== previous) {
emit('commit', clamped, previous)
}
}
editing.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
commitEdit(e)
} else if (e.key === 'Escape') {
editing.value = false
}
}
</script>
<template>
<div
data-test-id="scrub-input"
class="flex h-[26px] min-w-0 flex-1 items-center rounded border border-border bg-input focus-within:border-accent"
:style="{ cursor: editing ? 'auto' : 'ew-resize' }"
@pointerdown="!editing && startScrub($event)"
<ScrubInputRoot
v-slot="{ editing, startScrub, placeholder: ph }"
:model-value="modelValue"
:min="min"
:max="max"
:step="step"
:sensitivity="sensitivity"
:placeholder="placeholder"
@update:model-value="emit('update:modelValue', $event)"
@commit="(val: number, prev: number) => emit('commit', val, prev)"
>
<span
class="flex shrink-0 items-center justify-center self-stretch px-[5px] text-muted select-none [&>*]:pointer-events-none"
<div
data-test-id="scrub-input"
class="flex h-[26px] min-w-0 flex-1 items-center rounded border border-border bg-input focus-within:border-accent"
:style="{ cursor: editing ? 'auto' : 'ew-resize' }"
@pointerdown="!editing && startScrub($event)"
>
<slot name="icon">
<span v-if="icon" class="text-[11px] leading-none">{{ icon }}</span>
</slot>
<span v-if="label" class="text-[11px] leading-none">{{ label }}</span>
</span>
<input
v-if="editing"
ref="inputRef"
type="number"
data-test-id="scrub-input-field"
class="min-w-0 flex-1 cursor-text border-none bg-transparent pr-1.5 font-[inherit] text-xs text-surface outline-none"
:value="isMixed ? '' : displayValue"
:placeholder="placeholder"
:min="min === -Infinity ? undefined : min"
:max="max === Infinity ? undefined : max"
:step="step"
@blur="commitEdit"
@keydown="onKeydown"
/>
<span
v-else
class="flex flex-1 items-center truncate overflow-hidden pr-1.5 text-xs select-none"
>
<span v-if="isMixed" class="flex-1 text-muted">{{ placeholder }}</span>
<template v-else>
<span class="flex-1 text-surface">{{ displayValue }}</span>
<span v-if="suffix" class="shrink-0 text-muted">{{ suffix }}</span>
</template>
</span>
</div>
<span
class="flex shrink-0 items-center justify-center self-stretch px-[5px] text-muted select-none [&>*]:pointer-events-none"
>
<slot name="icon">
<span v-if="icon" class="text-[11px] leading-none">{{ icon }}</span>
</slot>
<span v-if="label" class="text-[11px] leading-none">{{ label }}</span>
</span>
<ScrubInputField
data-test-id="scrub-input-field"
class="min-w-0 flex-1 cursor-text border-none bg-transparent pr-1.5 font-[inherit] text-xs text-surface outline-none"
:min="min === -Infinity ? undefined : min"
:max="max === Infinity ? undefined : max"
:step="step"
/>
<ScrubInputDisplay
class="flex flex-1 items-center truncate overflow-hidden pr-1.5 text-xs select-none"
>
<template #default="{ value, isMixed: mixed }">
<span v-if="mixed" class="flex-1 text-muted">{{ ph }}</span>
<template v-else>
<span class="flex-1 text-surface">{{ value }}</span>
<span v-if="suffix" class="shrink-0 text-muted">{{ suffix }}</span>
</template>
</template>
</ScrubInputDisplay>
</div>
</ScrubInputRoot>
</template>