Fix all 135 vue-tsc errors, promote to check gate

vue-tsc now runs as part of 'bun run check' — zero errors.

Fixes by category:
- TS18047 (73): null guards in LayoutSection, TypographySection
- TS18048 (17): collab injection guards in CollabPanel, MobileHud
- TS2339 (12): Uint8Array.fromBase64/toBase64 declarations (ES2024)
- TS2741 (10): AppMenu discriminated union for separators
- TS6133 (7): remove unused declarations
- TS2322 (6): FontPicker AcceptableValue, Toolbar motion-v variants
- TS2345 (3): FillSection updateArrayItem signature, ScrubInput guard
- TS2314 (2): ChatMessage UIMessagePart generic args
- Misc (5): PropertiesPanel dead comparison, VariablesDialog onSubmit,
  NodeContextMenuContent document access, LayerTree arg count
This commit is contained in:
Danila Poyarkov 2026-03-16 22:21:21 +03:00
parent 4a25b88438
commit 4b49d69601
24 changed files with 436 additions and 396 deletions

View file

@ -23,7 +23,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.105",
"ai": "^6.0.116",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
@ -162,7 +162,7 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.43", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg=="],
@ -1156,7 +1156,7 @@
"agentfmt": ["agentfmt@0.1.3", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-BVSZr8ovigxjYewuBx42Ep4RJj00HURaQYUQ+id41sgb7r0PMUSEWsnD/+h96xj6t6v2WVyPuGPH0K6RtjftKQ=="],
"ai": ["ai@6.0.105", "", { "dependencies": { "@ai-sdk/gateway": "3.0.59", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw=="],
"ai": ["ai@6.0.116", "", { "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@ -2536,14 +2536,14 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
"@ai-sdk/valibot/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
"@ai-sdk/vue/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
"@ai-sdk/vue/ai": ["ai@6.0.105", "", { "dependencies": { "@ai-sdk/gateway": "3.0.59", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@ -2618,8 +2618,6 @@
"@waku/utils/chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
"babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"c12/rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
@ -2682,6 +2680,8 @@
"workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
"@ai-sdk/vue/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="],
"@libp2p/crypto/@libp2p/interface/@multiformats/multiaddr": ["@multiformats/multiaddr@13.0.1", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" } }, "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g=="],
"@multiformats/dns/@libp2p/interface/@multiformats/multiaddr": ["@multiformats/multiaddr@13.0.1", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" } }, "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g=="],

View file

@ -14,8 +14,7 @@
"tauri": "tauri",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/cli/src/ packages/mcp/src/",
"format": "oxfmt --write src/",
"check": "bun run lint && tsgo --noEmit",
"check:vue": "vue-tsc --noEmit",
"check": "bun run lint && tsgo --noEmit && vue-tsc --noEmit",
"test": "playwright test --project=openpencil",
"test:update": "playwright test --project=openpencil --update-snapshots",
"test:figma": "playwright test --project=figma",
@ -48,7 +47,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.105",
"ai": "^6.0.116",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
@ -65,12 +64,12 @@
"valibot": "^1.2.0",
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"vue-stream-markdown": "^0.6.5",
"y-indexeddb": "^9.0.12",
"y-protocols": "^1.0.7",
"yjs": "^13.6.29",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2",
"zod": "^4.3.6",
"vue-stream-markdown": "^0.6.5"
"zod": "^4.3.6"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.14.1",

View file

@ -9,3 +9,11 @@ interface FontData {
interface Window {
queryLocalFonts?(): Promise<FontData[]>
}
interface Uint8ArrayConstructor {
fromBase64(base64: string, options?: { alphabet?: 'base64' | 'base64url' }): Uint8Array
}
interface Uint8Array {
toBase64(options?: { alphabet?: 'base64' | 'base64url' }): string
}

View file

@ -4,10 +4,10 @@ import { isFontLoaded, DEFAULT_FONT_FAMILY } from '@open-pencil/core'
import type { SceneNode } from '@open-pencil/core'
export function useNodeFontStatus(node: () => SceneNode) {
export function useNodeFontStatus(node: () => SceneNode | null | undefined) {
const missingFonts = computed(() => {
const n = node()
if (n.type !== 'TEXT') return []
if (!n || n.type !== 'TEXT') return []
const families = new Set<string>()
families.add(n.fontFamily || DEFAULT_FONT_FAMILY)

View file

@ -2,7 +2,7 @@ import { computed } from 'vue'
import { useEditor } from '../context'
import type { SceneNode } from '@open-pencil/core'
import type { Fill, SceneNode, Stroke } from '@open-pencil/core'
export const MIXED = Symbol('mixed')
export type MixedValue<T> = T | typeof MIXED
@ -52,7 +52,7 @@ export function useNodeProps() {
function updateArrayItem(
key: ArrayPropKey,
index: number,
patch: Record<string, unknown>,
patch: Record<string, unknown> | Fill | Stroke,
label: string
) {
for (const n of targetNodes()) {

View file

@ -16,7 +16,7 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useInlineRename } from '@/composables/use-inline-rename'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
@ -47,18 +47,24 @@ function commitRename(input: HTMLInputElement) {
const isMac = navigator.platform.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl+'
interface MenuItem {
interface MenuAction {
separator?: false
label: string
shortcut?: string
action?: () => void
separator?: boolean
disabled?: boolean
checked?: boolean
onCheckedChange?: (checked: boolean) => void
sub?: MenuItem[]
sub?: MenuEntry[]
}
const fileMenu: MenuItem[] = [
interface MenuSeparator {
separator: true
}
type MenuEntry = MenuAction | MenuSeparator
const fileMenu: MenuEntry[] = [
{
label: 'New',
shortcut: `${mod}N`,
@ -89,7 +95,7 @@ const fileMenu: MenuItem[] = [
}
]
const editMenu: MenuItem[] = [
const editMenu: MenuEntry[] = [
{ label: 'Undo', shortcut: `${mod}Z`, action: () => store.undoAction() },
{ label: 'Redo', shortcut: `${mod}⇧Z`, action: () => store.redoAction() },
{ separator: true },
@ -101,7 +107,7 @@ const editMenu: MenuItem[] = [
{ label: 'Select all', shortcut: `${mod}A`, action: () => store.selectAll() }
]
const viewMenu: MenuItem[] = [
const viewMenu: MenuEntry[] = [
{ label: 'Zoom to 100%', shortcut: `${mod}0`, action: () => store.zoomTo100() },
{ label: 'Zoom to fit', shortcut: `${mod}1`, action: () => store.zoomToFit() },
{ label: 'Zoom to selection', shortcut: `${mod}2`, action: () => store.zoomToSelection() },
@ -127,7 +133,7 @@ const viewMenu: MenuItem[] = [
}
]
const objectMenu: MenuItem[] = [
const objectMenu: MenuEntry[] = [
{ label: 'Group', shortcut: `${mod}G`, action: () => store.groupSelected() },
{ label: 'Ungroup', shortcut: `${mod}⇧G`, action: () => store.ungroupSelected() },
{ separator: true },
@ -146,13 +152,13 @@ const objectMenu: MenuItem[] = [
{ label: 'Send to back', shortcut: '[', action: () => store.sendToBack() }
]
const textMenu: MenuItem[] = [
const textMenu: MenuEntry[] = [
{ label: 'Bold', shortcut: `${mod}B` },
{ label: 'Italic', shortcut: `${mod}I` },
{ label: 'Underline', shortcut: `${mod}U` }
]
const arrangeMenu: MenuItem[] = [
const arrangeMenu: MenuEntry[] = [
{ label: 'Add auto layout', shortcut: '⇧A', action: () => store.wrapInAutoLayout() },
{ separator: true },
{ label: 'Align left', shortcut: '⌥A' },

View file

@ -14,7 +14,7 @@ import {
} from 'reka-ui'
import { colorToCSS } from '@open-pencil/core'
import { useCollabInjected } from '@/composables/use-collab'
import { DEFAULT_COLLAB_STATE, useCollabInjected } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import { initials } from '@/utils/text'
@ -23,14 +23,14 @@ const router = useRouter()
const collab = useCollabInjected()
const joinInput = ref('')
const nameDraft = ref(collab.state.value.localName)
const nameDraft = ref(collab?.state.value.localName ?? '')
const copied = ref(false)
const pendingRoomId = (route.params.roomId as string) || null
const popoverOpen = ref(!!pendingRoomId)
const state = computed(() => collab.state.value)
const peers = computed(() => collab.remotePeers.value)
const followingPeer = computed(() => collab.followingPeer.value)
const state = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
const peers = computed(() => collab?.remotePeers.value ?? [])
const followingPeer = computed(() => collab?.followingPeer.value ?? null)
const shareUrl = computed(() => {
if (!state.value.roomId) return ''
@ -50,7 +50,7 @@ function copyLink() {
}
function onShare() {
if (!nameDraft.value.trim()) return
if (!collab || !nameDraft.value.trim()) return
collab.setLocalName(nameDraft.value.trim())
const roomId = collab.shareCurrentDoc()
router.push(`/share/${roomId}`)
@ -60,6 +60,7 @@ function onShare() {
}
function onJoin() {
if (!collab) return
const roomId = pendingRoomId || joinInput.value.trim().replace(/.*\/share\//, '')
if (!roomId || !nameDraft.value.trim()) return
collab.setLocalName(nameDraft.value.trim())
@ -69,6 +70,7 @@ function onJoin() {
}
function onDisconnect() {
if (!collab) return
collab.disconnect()
router.push('/')
}
@ -110,7 +112,7 @@ function onDisconnect() {
: 'border-panel'
"
:style="{ background: colorToCSS(peer.color) }"
@click="collab.followPeer(followingPeer === peer.clientId ? null : peer.clientId)"
@click="collab?.followPeer(followingPeer === peer.clientId ? null : peer.clientId)"
>
{{ initials(peer.name) }}
</div>

View file

@ -455,7 +455,7 @@ function setScaleMode(mode: string) {
<button
class="flex h-7 w-full cursor-pointer items-center justify-center gap-1 rounded border border-border bg-input text-xs text-surface hover:bg-hover"
data-test-id="fill-picker-choose-image"
@click="pickImage"
@click="() => pickImage()"
>
<icon-lucide-image class="size-3" />
{{ fill.imageHash ? 'Replace' : 'Choose image' }}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import type { ListboxFilter } from 'reka-ui'
import type { AcceptableValue, ListboxFilter } from 'reka-ui'
import {
ListboxContent,
ListboxItem,
@ -12,7 +12,7 @@ import {
useFilter
} from 'reka-ui'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
import { selectItem, selectTrigger } from '@/components/ui/select'
import { panelSurface } from '@/components/ui/surface'
import { listFamilies } from '@/engine/fonts'
@ -41,7 +41,8 @@ watch(open, (isOpen) => {
}
})
function onSelect(val: string) {
function onSelect(val: AcceptableValue) {
if (typeof val !== 'string') return
modelValue.value = val
emit('select', val)
open.value = false

View file

@ -145,7 +145,7 @@ function syncCanvasScope(nodeId: string) {
return
}
const parent = store.graph.getNode(parentId)
parentId = parent?.parentId
parentId = parent?.parentId ?? null
}
store.state.enteredContainerId = null
}
@ -414,7 +414,7 @@ function updateDropTarget(ev: PointerEvent) {
]"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
@pointerdown.prevent="onPointerDown($event, item.value.id)"
@dblclick="rename.start(item.value.id, item.value.name, '[data-layer-edit]')"
@dblclick="rename.start(item.value.id, item.value.name)"
>
<span
v-if="item.hasChildren"

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import {
PopoverRoot,
PopoverTrigger,
@ -21,7 +21,7 @@ import IconZoomIn from '~icons/lucide/zoom-in'
import { menuContent, menuItem } from '@/components/ui/menu'
import { openFileDialog } from '@/composables/use-menu'
import { useCollabInjected } from '@/composables/use-collab'
import { DEFAULT_COLLAB_STATE, useCollabInjected } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import { useEditorStore } from '@/stores/editor'
import { colorToCSS } from '@open-pencil/core'
@ -30,30 +30,24 @@ import { initials } from '@/utils/text'
import type { Component } from 'vue'
const route = useRoute()
const router = useRouter()
const collab = useCollabInjected()
const store = useEditorStore()
const collabState = computed(() => collab.state.value)
const collabPeers = computed(() => collab.remotePeers.value)
const followingPeer = computed(() => collab.followingPeer.value)
const pendingRoomId = (route.params.roomId as string) || null
const collabState = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
const collabPeers = computed(() => collab?.remotePeers.value ?? [])
const followingPeer = computed(() => collab?.followingPeer.value ?? null)
function onShare() {
if (!collab) return
const roomId = collab.shareCurrentDoc()
router.push(`/share/${roomId}`)
navigator.clipboard.writeText(`${window.location.origin}/share/${roomId}`)
toast.show('Link copied to clipboard')
}
function onJoin() {
if (!pendingRoomId) return
collab.connect(pendingRoomId)
router.push(`/share/${pendingRoomId}`)
}
function onDisconnect() {
if (!collab) return
collab.disconnect()
router.push('/')
}
@ -164,7 +158,7 @@ const onlineCount = computed(() => collabPeers.value.length + 1)
v-for="peer in collabPeers"
:key="peer.clientId"
class="flex cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 select-none active:bg-hover"
@click="collab.followPeer(followingPeer === peer.clientId ? null : peer.clientId)"
@click="collab?.followPeer(followingPeer === peer.clientId ? null : peer.clientId)"
>
<div
class="flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"

View file

@ -100,6 +100,10 @@ function copyAsJSX() {
toast.show('Copied as JSX')
}
function execCommand(command: string) {
window.document.execCommand(command)
}
const itemClass = menuItem()
const componentItemClass = menuItem({ tone: 'component' })
const menuClass = menuContent({
@ -114,7 +118,7 @@ const separatorClass = menuSeparator({ class: 'my-1' })
data-test-id="context-copy"
:class="itemClass"
:disabled="!hasSelection"
@select="document.execCommand('copy')"
@select="execCommand('copy')"
>
<span>Copy</span>
<span class="text-[11px] text-muted">C</span>
@ -123,16 +127,12 @@ const separatorClass = menuSeparator({ class: 'my-1' })
data-test-id="context-cut"
:class="itemClass"
:disabled="!hasSelection"
@select="document.execCommand('cut')"
@select="execCommand('cut')"
>
<span>Cut</span>
<span class="text-[11px] text-muted">X</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-paste"
:class="itemClass"
@select="document.execCommand('paste')"
>
<ContextMenuItem data-test-id="context-paste" :class="itemClass" @select="execCommand('paste')">
<span>Paste here</span>
<span class="text-[11px] text-muted">V</span>
</ContextMenuItem>

View file

@ -72,7 +72,7 @@ function startScrub(e: PointerEvent) {
stopMove?.()
stopUp?.()
if (hasMoved) {
if (modelValue !== valueBeforeScrub) {
if (typeof modelValue === 'number' && modelValue !== valueBeforeScrub) {
emit('commit', modelValue, valueBeforeScrub)
}
} else {

View file

@ -115,9 +115,9 @@ function onActionTap(item: ActionItem) {
const slideDirection = ref(1)
const slideVariants = {
initial: (dir: number) => ({ opacity: 0, x: dir * 20 }),
initial: (dir: unknown) => ({ opacity: 0, x: (dir as number) * 20 }),
animate: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir * -20 })
exit: (dir: unknown) => ({ opacity: 0, x: (dir as number) * -20 })
}
function goPrev() {

View file

@ -289,7 +289,7 @@ const columns = computed<ColumnDef<Variable>[]>(() => {
{
defaultValue: shortName(v),
class: 'min-w-0 flex-1',
onSubmit: (val: string) => commitNameEdit(v, val)
onSubmit: (val: string | null | undefined) => val && commitNameEdit(v, val)
},
() =>
h(EditableArea, { class: 'flex' }, () => [
@ -328,7 +328,7 @@ const columns = computed<ColumnDef<Variable>[]>(() => {
{
defaultValue: formatModeValue(v, mode.modeId),
class: 'min-w-0 flex-1',
onSubmit: (val: string) => commitValueEdit(v, mode.modeId, val)
onSubmit: (val: string | null | undefined) => val && commitValueEdit(v, mode.modeId, val)
},
() =>
h(EditableArea, { class: 'flex' }, () => [

View file

@ -4,11 +4,11 @@ import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui
import { Markdown } from 'vue-stream-markdown'
import 'vue-stream-markdown/index.css'
import type { UIMessage, UIMessagePart } from 'ai'
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai'
const { message } = defineProps<{ message: UIMessage }>()
type ToolPart = Extract<UIMessagePart, { toolCallId: string }>
type ToolPart = Extract<UIMessagePart<UIDataTypes, UITools>, { toolCallId: string }>
function toolDisplayName(part: ToolPart): string {
return getToolName(part)
@ -32,7 +32,7 @@ function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
return 'pending'
}
function partKey(part: UIMessagePart, index: number): string {
function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): string {
if ('toolCallId' in part) return part.toolCallId
return `part-${index}`
}

View file

@ -119,7 +119,7 @@ function updateCornerProp(key: string, value: number) {
}
}
function commitCornerProp(key: string, value: number, previous: number) {
function commitCornerProp(key: string, _value: number, previous: number) {
if (isMulti.value) {
for (const n of nodes.value) {
store.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)

View file

@ -19,11 +19,10 @@ import { useNodeProps } from '@/composables/use-node-props'
import { useMultiProps } from '@/composables/use-multi-props'
import { DEFAULT_SHAPE_FILL } from '@/constants'
import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import type { Fill, Variable, Color } from '@open-pencil/core'
import type { Fill, Variable } from '@open-pencil/core'
const { store } = useNodeProps()
const {
nodes,
isMulti,
active,
activeNode,

View file

@ -18,37 +18,39 @@ const { store, node, updateProp, commitProp } = useNodeProps()
const showIndividualPadding = ref(false)
const isInAutoLayout = computed(() => {
if (!node.value.parentId) return false
const parent = store.graph.getNode(node.value.parentId)
const n = node.value
if (!n?.parentId) return false
const parent = store.graph.getNode(n.parentId)
return parent ? parent.layoutMode !== 'NONE' : false
})
const isGrid = computed(() => node.value.layoutMode === 'GRID')
const isGrid = computed(() => node.value?.layoutMode === 'GRID')
const isFlex = computed(
() => node.value.layoutMode === 'HORIZONTAL' || node.value.layoutMode === 'VERTICAL'
() => node.value?.layoutMode === 'HORIZONTAL' || node.value?.layoutMode === 'VERTICAL'
)
const widthSizing = computed<LayoutSizing>(() => {
const n = node.value
if (!n) return 'FIXED'
if (isFlex.value) {
return node.value.layoutMode === 'HORIZONTAL'
? node.value.primaryAxisSizing
: node.value.counterAxisSizing
return n.layoutMode === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing
}
if (isInAutoLayout.value && node.value.layoutGrow > 0) return 'FILL'
if (isInAutoLayout.value && n.layoutGrow > 0) return 'FILL'
return 'FIXED'
})
const heightSizing = computed<LayoutSizing>(() => {
const n = node.value
if (!n) return 'FIXED'
if (isFlex.value) {
return node.value.layoutMode === 'VERTICAL'
? node.value.primaryAxisSizing
: node.value.counterAxisSizing
return n.layoutMode === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing
}
if (isInAutoLayout.value && node.value.layoutAlignSelf === 'STRETCH') return 'FILL'
if (isInAutoLayout.value && n.layoutAlignSelf === 'STRETCH') return 'FILL'
return 'FIXED'
})
function setWidthSizing(sizing: LayoutSizing) {
if (!node.value) return
if (isFlex.value) {
if (node.value.layoutMode === 'HORIZONTAL') updateProp('primaryAxisSizing', sizing)
else updateProp('counterAxisSizing', sizing)
@ -58,6 +60,7 @@ function setWidthSizing(sizing: LayoutSizing) {
}
function setHeightSizing(sizing: LayoutSizing) {
if (!node.value) return
if (isFlex.value) {
if (node.value.layoutMode === 'VERTICAL') updateProp('primaryAxisSizing', sizing)
else updateProp('counterAxisSizing', sizing)
@ -67,14 +70,17 @@ function setHeightSizing(sizing: LayoutSizing) {
}
function hasUniformPadding() {
const n = node.value
if (!n) return true
return (
node.value.paddingTop === node.value.paddingRight &&
node.value.paddingRight === node.value.paddingBottom &&
node.value.paddingBottom === node.value.paddingLeft
n.paddingTop === n.paddingRight &&
n.paddingRight === n.paddingBottom &&
n.paddingBottom === n.paddingLeft
)
}
function setUniformPadding(v: number) {
if (!node.value) return
store.updateNode(node.value.id, {
paddingTop: v,
paddingRight: v,
@ -84,6 +90,7 @@ function setUniformPadding(v: number) {
}
function commitUniformPadding(_value: number, previous: number) {
if (!node.value) return
store.commitNodeUpdate(
node.value.id,
{
@ -137,10 +144,11 @@ const ALIGN_VERTICAL: AlignCell[] = [
]
const alignGrid = computed(() =>
node.value.layoutMode === 'VERTICAL' ? ALIGN_VERTICAL : ALIGN_HORIZONTAL
node.value?.layoutMode === 'VERTICAL' ? ALIGN_VERTICAL : ALIGN_HORIZONTAL
)
function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
if (!node.value) return
store.updateNodeWithUndo(
node.value.id,
{ primaryAxisAlign: primary, counterAxisAlign: counter },
@ -161,17 +169,20 @@ function updateGridTrack(
index: number,
updates: Partial<GridTrack>
) {
if (!node.value) return
const tracks = [...node.value[prop]]
tracks[index] = { ...tracks[index], ...updates }
store.updateNodeWithUndo(node.value.id, { [prop]: tracks }, 'Change grid track')
}
function addTrack(prop: 'gridTemplateColumns' | 'gridTemplateRows') {
if (!node.value) return
const tracks = [...node.value[prop], { sizing: 'FR' as const, value: 1 }]
store.updateNodeWithUndo(node.value.id, { [prop]: tracks }, 'Add grid track')
}
function removeTrack(prop: 'gridTemplateColumns' | 'gridTemplateRows', index: number) {
if (!node.value) return
const tracks = node.value[prop].filter((_: GridTrack, i: number) => i !== index)
store.updateNodeWithUndo(node.value.id, { [prop]: tracks }, 'Remove grid track')
}
@ -221,317 +232,325 @@ function trackLabel(track: GridTrack): string {
</div>
</div>
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1.5 block text-[11px] text-muted">Auto layout</label>
<button
v-if="node.layoutMode === 'NONE'"
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-add-auto"
title="Add auto layout (Shift+A)"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
+
</button>
<button
v-else
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-remove-auto"
title="Remove auto layout"
@click="store.setLayoutMode(node.id, 'NONE')"
>
</button>
</div>
<template v-if="node.layoutMode !== 'NONE'">
<!-- Flow direction buttons -->
<div class="mt-1.5 flex gap-0.5">
<template v-if="node?.type === 'FRAME'">
<div class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1.5 block text-[11px] text-muted">Auto layout</label>
<button
data-test-id="layout-direction-horizontal"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.layoutMode === 'HORIZONTAL'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Horizontal layout"
@click="store.setLayoutMode(node.id, 'HORIZONTAL')"
>
<icon-lucide-arrow-right class="size-3.5" />
</button>
<button
data-test-id="layout-direction-vertical"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.layoutMode === 'VERTICAL'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Vertical layout"
v-if="node.layoutMode === 'NONE'"
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-add-auto"
title="Add auto layout (Shift+A)"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
<icon-lucide-arrow-down class="size-3.5" />
+
</button>
<button
data-test-id="layout-direction-grid"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
isGrid
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Grid layout"
@click="store.setLayoutMode(node.id, 'GRID')"
v-else
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-remove-auto"
title="Remove auto layout"
@click="store.setLayoutMode(node.id, 'NONE')"
>
<icon-lucide-layout-grid class="size-3.5" />
</button>
<button
v-if="isFlex"
data-test-id="layout-direction-wrap"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.layoutWrap === 'WRAP'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Wrap"
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
<icon-lucide-wrap-text class="size-3.5" />
</button>
</div>
<!-- Grid-specific controls -->
<template v-if="isGrid">
<!-- Column tracks -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">Columns</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"
title="Add column"
@click="addTrack('gridTemplateColumns')"
>
+
</button>
</div>
<div class="flex flex-col gap-1">
<div
v-for="(col, i) in node.gridTemplateColumns"
:key="i"
class="flex items-center gap-1"
>
<ScrubInput
v-if="col.sizing !== 'AUTO'"
class="flex-1"
:icon="`C${i + 1}`"
:model-value="col.value"
:min="col.sizing === 'FR' ? 1 : 0"
:step="col.sizing === 'FR' ? 1 : 1"
:suffix="col.sizing === 'FR' ? 'fr' : 'px'"
@update:model-value="updateGridTrack('gridTemplateColumns', i, { value: $event })"
/>
<span v-else class="flex-1 px-1 text-xs text-muted">{{ trackLabel(col) }}</span>
<AppSelect
:model-value="col.sizing"
:options="TRACK_SIZING_OPTIONS"
@update:model-value="
updateGridTrack('gridTemplateColumns', i, {
sizing: $event,
value: $event === 'FR' ? 1 : $event === 'FIXED' ? 100 : 0
})
"
/>
<button
v-if="node.gridTemplateColumns.length > 1"
class="cursor-pointer rounded border-none bg-transparent px-0.5 text-xs text-muted hover:text-surface"
title="Remove column"
@click="removeTrack('gridTemplateColumns', i)"
>
×
</button>
</div>
</div>
</div>
<!-- Row tracks -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">Rows</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"
title="Add row"
@click="addTrack('gridTemplateRows')"
>
+
</button>
</div>
<div class="flex flex-col gap-1">
<div v-for="(row, i) in node.gridTemplateRows" :key="i" class="flex items-center gap-1">
<ScrubInput
v-if="row.sizing !== 'AUTO'"
class="flex-1"
:icon="`R${i + 1}`"
:model-value="row.value"
:min="row.sizing === 'FR' ? 1 : 0"
:step="row.sizing === 'FR' ? 1 : 1"
:suffix="row.sizing === 'FR' ? 'fr' : 'px'"
@update:model-value="updateGridTrack('gridTemplateRows', i, { value: $event })"
/>
<span v-else class="flex-1 px-1 text-xs text-muted">{{ trackLabel(row) }}</span>
<AppSelect
:model-value="row.sizing"
:options="TRACK_SIZING_OPTIONS"
@update:model-value="
updateGridTrack('gridTemplateRows', i, {
sizing: $event,
value: $event === 'FR' ? 1 : $event === 'FIXED' ? 100 : 0
})
"
/>
<button
v-if="node.gridTemplateRows.length > 1"
class="cursor-pointer rounded border-none bg-transparent px-0.5 text-xs text-muted hover:text-surface"
title="Remove row"
@click="removeTrack('gridTemplateRows', i)"
>
×
</button>
</div>
</div>
</div>
<!-- Grid gaps -->
<div class="mt-2 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="↔"
:model-value="Math.round(node.gridColumnGap)"
:min="0"
@update:model-value="updateProp('gridColumnGap', $event)"
@commit="(v: number, p: number) => commitProp('gridColumnGap', v, p)"
/>
<ScrubInput
icon="↕"
:model-value="Math.round(node.gridRowGap)"
:min="0"
@update:model-value="updateProp('gridRowGap', $event)"
@commit="(v: number, p: number) => commitProp('gridRowGap', v, p)"
/>
</div>
</template>
<!-- Flex-specific controls -->
<template v-if="isFlex">
<div class="mt-2 flex items-center gap-1.5">
<ScrubInput
data-test-id="layout-gap-input"
class="flex-1"
:icon="node.layoutMode === 'VERTICAL' ? '↕' : '↔'"
:model-value="Math.round(node.itemSpacing)"
:min="0"
@update:model-value="updateProp('itemSpacing', $event)"
@commit="(v: number, p: number) => commitProp('itemSpacing', v, p)"
/>
<template v-if="node.layoutMode !== 'NONE'">
<!-- Flow direction buttons -->
<div class="mt-1.5 flex gap-0.5">
<button
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-transparent text-muted hover:bg-hover hover:text-surface"
:title="
showIndividualPadding || !hasUniformPadding() ? 'Uniform padding' : 'Per-side padding'
"
@click="showIndividualPadding = !showIndividualPadding"
>
<icon-lucide-minus
v-if="showIndividualPadding || !hasUniformPadding()"
class="size-3"
/>
<icon-lucide-plus v-else class="size-3" />
</button>
</div>
<div v-if="!showIndividualPadding && hasUniformPadding()" class="mt-1.5">
<ScrubInput
data-test-id="layout-uniform-padding-input"
icon="☐"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="setUniformPadding"
@commit="commitUniformPadding"
/>
</div>
</template>
<!-- Per-side padding -->
<template v-if="isGrid || (isFlex && (showIndividualPadding || !hasUniformPadding()))">
<div class="mt-1.5 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="T"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="updateProp('paddingTop', $event)"
@commit="(v: number, p: number) => commitProp('paddingTop', v, p)"
/>
<ScrubInput
icon="R"
:model-value="Math.round(node.paddingRight)"
:min="0"
@update:model-value="updateProp('paddingRight', $event)"
@commit="(v: number, p: number) => commitProp('paddingRight', v, p)"
/>
<ScrubInput
icon="B"
:model-value="Math.round(node.paddingBottom)"
:min="0"
@update:model-value="updateProp('paddingBottom', $event)"
@commit="(v: number, p: number) => commitProp('paddingBottom', v, p)"
/>
<ScrubInput
icon="L"
:model-value="Math.round(node.paddingLeft)"
:min="0"
@update:model-value="updateProp('paddingLeft', $event)"
@commit="(v: number, p: number) => commitProp('paddingLeft', v, p)"
/>
</div>
</template>
<!-- Alignment (flex only) -->
<div v-if="isFlex" class="mt-2">
<label class="mb-1 block text-[11px] text-muted">Alignment</label>
<div data-test-id="layout-alignment-grid" class="grid w-fit grid-cols-3 gap-0.5">
<button
v-for="cell in alignGrid"
:key="`${cell.primary}-${cell.counter}`"
class="flex size-6 cursor-pointer items-center justify-center rounded border text-[11px]"
data-test-id="layout-direction-horizontal"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.primaryAxisAlign === cell.primary && node.counterAxisAlign === cell.counter
node.layoutMode === 'HORIZONTAL'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
@click="setAlignment(cell.primary, cell.counter)"
title="Horizontal layout"
@click="store.setLayoutMode(node.id, 'HORIZONTAL')"
>
<span class="size-1.5 rounded-full bg-current" />
<icon-lucide-arrow-right class="size-3.5" />
</button>
<button
data-test-id="layout-direction-vertical"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.layoutMode === 'VERTICAL'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Vertical layout"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
<icon-lucide-arrow-down class="size-3.5" />
</button>
<button
data-test-id="layout-direction-grid"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
isGrid
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Grid layout"
@click="store.setLayoutMode(node.id, 'GRID')"
>
<icon-lucide-layout-grid class="size-3.5" />
</button>
<button
v-if="isFlex"
data-test-id="layout-direction-wrap"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.layoutWrap === 'WRAP'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Wrap"
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
<icon-lucide-wrap-text class="size-3.5" />
</button>
</div>
</div>
</template>
</div>
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
<label class="flex cursor-pointer items-center gap-2 text-xs text-surface">
<input
type="checkbox"
data-test-id="clip-content-checkbox"
class="accent-accent"
:checked="node.clipsContent"
@change="
store.updateNodeWithUndo(
node.id,
{ clipsContent: !node.clipsContent },
'Toggle clip content'
)
"
/>
Clip content
</label>
</div>
<!-- Grid-specific controls -->
<template v-if="isGrid">
<!-- Column tracks -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">Columns</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"
title="Add column"
@click="addTrack('gridTemplateColumns')"
>
+
</button>
</div>
<div class="flex flex-col gap-1">
<div
v-for="(col, i) in node.gridTemplateColumns"
:key="i"
class="flex items-center gap-1"
>
<ScrubInput
v-if="col.sizing !== 'AUTO'"
class="flex-1"
:icon="`C${i + 1}`"
:model-value="col.value"
:min="col.sizing === 'FR' ? 1 : 0"
:step="col.sizing === 'FR' ? 1 : 1"
:suffix="col.sizing === 'FR' ? 'fr' : 'px'"
@update:model-value="updateGridTrack('gridTemplateColumns', i, { value: $event })"
/>
<span v-else class="flex-1 px-1 text-xs text-muted">{{ trackLabel(col) }}</span>
<AppSelect
:model-value="col.sizing"
:options="TRACK_SIZING_OPTIONS"
@update:model-value="
updateGridTrack('gridTemplateColumns', i, {
sizing: $event,
value: $event === 'FR' ? 1 : $event === 'FIXED' ? 100 : 0
})
"
/>
<button
v-if="node.gridTemplateColumns.length > 1"
class="cursor-pointer rounded border-none bg-transparent px-0.5 text-xs text-muted hover:text-surface"
title="Remove column"
@click="removeTrack('gridTemplateColumns', i)"
>
×
</button>
</div>
</div>
</div>
<!-- Row tracks -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">Rows</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"
title="Add row"
@click="addTrack('gridTemplateRows')"
>
+
</button>
</div>
<div class="flex flex-col gap-1">
<div
v-for="(row, i) in node.gridTemplateRows"
:key="i"
class="flex items-center gap-1"
>
<ScrubInput
v-if="row.sizing !== 'AUTO'"
class="flex-1"
:icon="`R${i + 1}`"
:model-value="row.value"
:min="row.sizing === 'FR' ? 1 : 0"
:step="row.sizing === 'FR' ? 1 : 1"
:suffix="row.sizing === 'FR' ? 'fr' : 'px'"
@update:model-value="updateGridTrack('gridTemplateRows', i, { value: $event })"
/>
<span v-else class="flex-1 px-1 text-xs text-muted">{{ trackLabel(row) }}</span>
<AppSelect
:model-value="row.sizing"
:options="TRACK_SIZING_OPTIONS"
@update:model-value="
updateGridTrack('gridTemplateRows', i, {
sizing: $event,
value: $event === 'FR' ? 1 : $event === 'FIXED' ? 100 : 0
})
"
/>
<button
v-if="node.gridTemplateRows.length > 1"
class="cursor-pointer rounded border-none bg-transparent px-0.5 text-xs text-muted hover:text-surface"
title="Remove row"
@click="removeTrack('gridTemplateRows', i)"
>
×
</button>
</div>
</div>
</div>
<!-- Grid gaps -->
<div class="mt-2 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="↔"
:model-value="Math.round(node.gridColumnGap)"
:min="0"
@update:model-value="updateProp('gridColumnGap', $event)"
@commit="(v: number, p: number) => commitProp('gridColumnGap', v, p)"
/>
<ScrubInput
icon="↕"
:model-value="Math.round(node.gridRowGap)"
:min="0"
@update:model-value="updateProp('gridRowGap', $event)"
@commit="(v: number, p: number) => commitProp('gridRowGap', v, p)"
/>
</div>
</template>
<!-- Flex-specific controls -->
<template v-if="isFlex">
<div class="mt-2 flex items-center gap-1.5">
<ScrubInput
data-test-id="layout-gap-input"
class="flex-1"
:icon="node.layoutMode === 'VERTICAL' ? '↕' : '↔'"
:model-value="Math.round(node.itemSpacing)"
:min="0"
@update:model-value="updateProp('itemSpacing', $event)"
@commit="(v: number, p: number) => commitProp('itemSpacing', v, p)"
/>
<button
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-transparent text-muted hover:bg-hover hover:text-surface"
:title="
showIndividualPadding || !hasUniformPadding()
? 'Uniform padding'
: 'Per-side padding'
"
@click="showIndividualPadding = !showIndividualPadding"
>
<icon-lucide-minus
v-if="showIndividualPadding || !hasUniformPadding()"
class="size-3"
/>
<icon-lucide-plus v-else class="size-3" />
</button>
</div>
<div v-if="!showIndividualPadding && hasUniformPadding()" class="mt-1.5">
<ScrubInput
data-test-id="layout-uniform-padding-input"
icon="☐"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="setUniformPadding"
@commit="commitUniformPadding"
/>
</div>
</template>
<!-- Per-side padding -->
<template v-if="isGrid || (isFlex && (showIndividualPadding || !hasUniformPadding()))">
<div class="mt-1.5 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="T"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="updateProp('paddingTop', $event)"
@commit="(v: number, p: number) => commitProp('paddingTop', v, p)"
/>
<ScrubInput
icon="R"
:model-value="Math.round(node.paddingRight)"
:min="0"
@update:model-value="updateProp('paddingRight', $event)"
@commit="(v: number, p: number) => commitProp('paddingRight', v, p)"
/>
<ScrubInput
icon="B"
:model-value="Math.round(node.paddingBottom)"
:min="0"
@update:model-value="updateProp('paddingBottom', $event)"
@commit="(v: number, p: number) => commitProp('paddingBottom', v, p)"
/>
<ScrubInput
icon="L"
:model-value="Math.round(node.paddingLeft)"
:min="0"
@update:model-value="updateProp('paddingLeft', $event)"
@commit="(v: number, p: number) => commitProp('paddingLeft', v, p)"
/>
</div>
</template>
<!-- Alignment (flex only) -->
<div v-if="isFlex" class="mt-2">
<label class="mb-1 block text-[11px] text-muted">Alignment</label>
<div data-test-id="layout-alignment-grid" class="grid w-fit grid-cols-3 gap-0.5">
<button
v-for="cell in alignGrid"
:key="`${cell.primary}-${cell.counter}`"
class="flex size-6 cursor-pointer items-center justify-center rounded border text-[11px]"
:class="
node.primaryAxisAlign === cell.primary && node.counterAxisAlign === cell.counter
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
@click="setAlignment(cell.primary, cell.counter)"
>
<span class="size-1.5 rounded-full bg-current" />
</button>
</div>
</div>
</template>
</div>
<div class="border-b border-border px-3 py-2">
<label class="flex cursor-pointer items-center gap-2 text-xs text-surface">
<input
type="checkbox"
data-test-id="clip-content-checkbox"
class="accent-accent"
:checked="node.clipsContent"
@change="
store.updateNodeWithUndo(
node.id,
{ clipsContent: !node.clipsContent },
'Toggle clip content'
)
"
/>
Clip content
</label>
</div>
</template>
</template>

View file

@ -21,7 +21,6 @@ type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM'
const { store } = useNodeProps()
const {
nodes,
isMulti,
active,
activeNode,
@ -76,15 +75,6 @@ function updateWeight(index: number, weight: number) {
updateArrayItem('strokes', index, { weight }, 'Change stroke')
}
function updateOpacity(index: number, opacity: number) {
updateArrayItem(
'strokes',
index,
{ opacity: Math.max(0, Math.min(1, opacity / 100)) },
'Change stroke'
)
}
function updateAlign(align: Stroke['align']) {
for (const n of targetNodes()) {
const strokes = n.strokes.map((s) => ({ ...s, align }))

View file

@ -24,17 +24,19 @@ const WEIGHTS = [
]
const currentWeightLabel = computed(
() => WEIGHTS.find((w) => w.value === node.value.fontWeight)?.label ?? 'Regular'
() => WEIGHTS.find((w) => w.value === node.value?.fontWeight)?.label ?? 'Regular'
)
type TextAlign = 'LEFT' | 'CENTER' | 'RIGHT'
async function selectFamily(family: string) {
if (!node.value) return
await loadFont(family, currentWeightLabel.value)
store.updateNodeWithUndo(node.value.id, { fontFamily: family }, 'Change font')
}
async function selectWeight(weight: number) {
if (!node.value) return
const label = WEIGHTS.find((w) => w.value === weight)?.label ?? 'Regular'
await loadFont(node.value.fontFamily, label)
store.updateNodeWithUndo(node.value.id, { fontWeight: weight }, 'Change font weight')
@ -42,21 +44,24 @@ async function selectWeight(weight: number) {
}
function setAlign(align: TextAlign) {
if (!node.value) return
store.updateNodeWithUndo(node.value.id, { textAlignHorizontal: align }, 'Change text alignment')
store.requestRender()
}
function toggleBold() {
const n = node.value
selectWeight(n.fontWeight >= 700 ? 400 : 700)
if (!node.value) return
selectWeight(node.value.fontWeight >= 700 ? 400 : 700)
}
function toggleItalic() {
if (!node.value) return
store.updateNodeWithUndo(node.value.id, { italic: !node.value.italic }, 'Toggle italic')
store.requestRender()
}
function toggleDecoration(deco: 'UNDERLINE' | 'STRIKETHROUGH') {
if (!node.value) return
const current = node.value.textDecoration
store.updateNodeWithUndo(
node.value.id,
@ -67,6 +72,7 @@ function toggleDecoration(deco: 'UNDERLINE' | 'STRIKETHROUGH') {
}
onMounted(async () => {
if (!node.value) return
await loadFont(node.value.fontFamily, currentWeightLabel.value)
})
</script>

View file

@ -60,7 +60,7 @@ const customAPIType = useLocalStorage<'completions' | 'responses'>(
const maxOutputTokens = useLocalStorage(`${STORAGE_PREFIX}ai-max-output-tokens`, 16384)
const pexelsApiKey = useLocalStorage(`${STORAGE_PREFIX}pexels-api-key`, '')
const unsplashAccessKey = useLocalStorage(`${STORAGE_PREFIX}unsplash-access-key`, '')
const activeTab = ref<'design' | 'ai'>('design')
const activeTab = ref<'design' | 'code' | 'ai'>('design')
const providerDef = computed(
() => AI_PROVIDERS.find((p) => p.id === providerID.value) ?? AI_PROVIDERS[0]

View file

@ -34,6 +34,14 @@ export interface CollabState {
localColor: Color
}
export const DEFAULT_COLLAB_STATE: CollabState = {
connected: false,
roomId: null,
peers: [],
localName: '',
localColor: { r: 0.5, g: 0.5, b: 0.5, a: 1 }
}
export function useCollab(store: EditorStore) {
const storedName = useLocalStorage('op-collab-name', '')
const state = ref<CollabState>({

8
src/global.d.ts vendored
View file

@ -1,3 +1,11 @@
interface Uint8ArrayConstructor {
fromBase64(base64: string, options?: { alphabet?: 'base64' | 'base64url' }): Uint8Array
}
interface Uint8Array {
toBase64(options?: { alphabet?: 'base64' | 'base64url' }): string
}
interface GestureEvent extends UIEvent {
scale: number
rotation: number