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:
parent
4a25b88438
commit
4b49d69601
14
bun.lock
14
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
packages/core/src/global.d.ts
vendored
8
packages/core/src/global.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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' }, () => [
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
8
src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue