Fix pasteID int overflow and make error toasts copyable
- Use Int32Array instead of Uint32Array for Kiwi pasteID (signed int field) - Error toasts: don't auto-dismiss, show copy button, text is selectable - Add clipboard roundtrip tests (encode → decode → verify)
This commit is contained in:
parent
af9b9fd0fa
commit
ce55af6171
|
|
@ -420,7 +420,7 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph):
|
|||
type: 'NODE_CHANGES',
|
||||
sessionID: 0,
|
||||
ackID: 0,
|
||||
pasteID: crypto.getRandomValues(new Uint32Array(1))[0],
|
||||
pasteID: crypto.getRandomValues(new Int32Array(1))[0],
|
||||
pasteFileKey: 'openpencil',
|
||||
nodeChanges
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport } from 'reka-ui'
|
||||
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose } from 'reka-ui'
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { toast } from '@/composables/use-toast'
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider :duration="toast.TOAST_DURATION" swipe-direction="up">
|
||||
<ToastProvider swipe-direction="up">
|
||||
<ToastRoot
|
||||
v-for="t in toast.toasts.value"
|
||||
:key="t.id"
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
|
||||
:duration="t.variant === 'error' ? 0 : toast.TOAST_DURATION"
|
||||
class="flex max-w-sm items-start gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
|
||||
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
|
||||
@update:open="
|
||||
(open) => {
|
||||
|
|
@ -17,9 +22,24 @@ import { toast } from '@/composables/use-toast'
|
|||
}
|
||||
"
|
||||
>
|
||||
<icon-lucide-check v-if="t.variant === 'default'" class="size-3 shrink-0" />
|
||||
<icon-lucide-alert-triangle v-else class="size-3 shrink-0" />
|
||||
<ToastDescription>{{ t.message }}</ToastDescription>
|
||||
<icon-lucide-check v-if="t.variant === 'default'" class="mt-0.5 size-3 shrink-0" />
|
||||
<icon-lucide-alert-triangle v-else class="mt-0.5 size-3 shrink-0" />
|
||||
<ToastDescription class="min-w-0 flex-1 select-text">{{ t.message }}</ToastDescription>
|
||||
<button
|
||||
v-if="t.variant === 'error'"
|
||||
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
|
||||
:title="copied ? 'Copied!' : 'Copy error'"
|
||||
@click="copy(t.message)"
|
||||
>
|
||||
<icon-lucide-check v-if="copied" class="size-3" />
|
||||
<icon-lucide-copy v-else class="size-3" />
|
||||
</button>
|
||||
<ToastClose
|
||||
v-if="t.variant === 'error'"
|
||||
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<icon-lucide-x class="size-3" />
|
||||
</ToastClose>
|
||||
</ToastRoot>
|
||||
|
||||
<ToastViewport
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
import { beforeAll, describe, expect, it } from 'bun:test'
|
||||
|
||||
import {
|
||||
parseFigmaClipboard,
|
||||
importClipboardNodes,
|
||||
figmaNodesBounds,
|
||||
buildFigmaClipboardHTML,
|
||||
} from '../../packages/core/src/clipboard'
|
||||
import { initCodec } from '../../packages/core/src/kiwi/codec'
|
||||
import { SceneGraph, type SceneNode } from '../../packages/core/src/scene-graph'
|
||||
|
||||
function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) {
|
||||
|
|
@ -334,3 +336,115 @@ describe('figmaNodesBounds', () => {
|
|||
expect(figmaNodesBounds(nodes)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFigmaClipboardHTML', () => {
|
||||
beforeAll(async () => {
|
||||
await initCodec()
|
||||
})
|
||||
|
||||
it('encodes a simple frame without throwing', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
name: 'Card',
|
||||
x: 0, y: 0, width: 300, height: 200,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([frame], graph)
|
||||
expect(html).toContain('figmeta')
|
||||
expect(html).toContain('figma')
|
||||
})
|
||||
|
||||
it('encodes text nodes with style runs', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const text = graph.createNode('TEXT', page.id, {
|
||||
name: 'Styled',
|
||||
x: 0, y: 0, width: 200, height: 24,
|
||||
text: 'Hello World',
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 400,
|
||||
fontSize: 16,
|
||||
styleRuns: [
|
||||
{ start: 0, length: 5, style: { fontWeight: 700 } },
|
||||
{ start: 6, length: 5, style: { fontWeight: 400, italic: true } },
|
||||
],
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([text], graph)
|
||||
expect(html).toContain('figmeta')
|
||||
})
|
||||
|
||||
it('encodes auto-layout frames', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
name: 'Row',
|
||||
x: 0, y: 0, width: 400, height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
itemSpacing: 16,
|
||||
paddingTop: 12, paddingRight: 12, paddingBottom: 12, paddingLeft: 12,
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'FIXED',
|
||||
})
|
||||
graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Child',
|
||||
x: 0, y: 0, width: 50, height: 50,
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([frame], graph)
|
||||
expect(html).toContain('figmeta')
|
||||
})
|
||||
|
||||
it('roundtrips: encode then decode back', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
name: 'Analytics Overview',
|
||||
x: 0, y: 0, width: 300, height: 200,
|
||||
layoutMode: 'VERTICAL',
|
||||
itemSpacing: 8,
|
||||
paddingTop: 20, paddingRight: 20, paddingBottom: 20, paddingLeft: 20,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
cornerRadius: 12,
|
||||
})
|
||||
graph.createNode('TEXT', frame.id, {
|
||||
name: 'Title',
|
||||
x: 0, y: 0, width: 260, height: 24,
|
||||
text: 'Analytics Overview',
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 600,
|
||||
fontSize: 18,
|
||||
})
|
||||
graph.createNode('TEXT', frame.id, {
|
||||
name: 'Subtitle',
|
||||
x: 0, y: 0, width: 260, height: 40,
|
||||
text: 'Track your key metrics and performance indicators in real time.',
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([frame], graph)
|
||||
expect(html).not.toBeNull()
|
||||
|
||||
const parsed = await parseFigmaClipboard(html!)
|
||||
expect(parsed).not.toBeNull()
|
||||
expect(parsed!.nodes.length).toBeGreaterThan(0)
|
||||
|
||||
const graph2 = new SceneGraph()
|
||||
const page2 = graph2.getPages()[0]
|
||||
const created = importClipboardNodes(parsed!.nodes, graph2, page2.id)
|
||||
expect(created).toHaveLength(1)
|
||||
|
||||
const imported = graph2.getNode(created[0])!
|
||||
expect(imported.name).toBe('Analytics Overview')
|
||||
expect(imported.cornerRadius).toBe(12)
|
||||
|
||||
const children = graph2.getChildren(imported.id)
|
||||
expect(children).toHaveLength(2)
|
||||
expect(children[0].text).toBe('Analytics Overview')
|
||||
expect(children[1].text).toContain('Track your key metrics')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue