Fix MCP auth, collab state, and export bounds
This commit is contained in:
parent
fd15ab6636
commit
943995fb70
|
|
@ -25,8 +25,7 @@ export async function getAppToken(): Promise<string> {
|
|||
return cachedToken
|
||||
}
|
||||
|
||||
export async function rpc<T = unknown>(command: string, args: unknown = {}): Promise<T> {
|
||||
const token = await getAppToken()
|
||||
async function doRpc<T>(token: string, command: string, args: unknown): Promise<T> {
|
||||
const res = await fetch(RPC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -49,6 +48,19 @@ export async function rpc<T = unknown>(command: string, args: unknown = {}): Pro
|
|||
return body.result as T
|
||||
}
|
||||
|
||||
export async function rpc<T = unknown>(command: string, args: unknown = {}): Promise<T> {
|
||||
let token = await getAppToken()
|
||||
try {
|
||||
return await doRpc<T>(token, command, args)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!message.includes('Unauthorized')) throw error
|
||||
cachedToken = null
|
||||
token = await getAppToken()
|
||||
return doRpc<T>(token, command, args)
|
||||
}
|
||||
}
|
||||
|
||||
export function isAppMode(file?: string): boolean {
|
||||
return !file
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Effect, Stroke } from './scene-graph'
|
||||
import type { Rect, Vector } from './types'
|
||||
|
||||
export function degToRad(degrees: number): number {
|
||||
|
|
@ -81,6 +82,39 @@ export function computeBounds(items: Iterable<Rect>): Rect {
|
|||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
|
||||
function strokeOverflow(strokes?: Stroke[]): number {
|
||||
let overflow = 0
|
||||
for (const stroke of strokes ?? []) {
|
||||
if (!stroke.visible) continue
|
||||
let extra = 0
|
||||
if (stroke.align === 'OUTSIDE') extra = stroke.weight
|
||||
else if (stroke.align === 'CENTER') extra = stroke.weight / 2
|
||||
overflow = Math.max(overflow, extra)
|
||||
}
|
||||
return overflow
|
||||
}
|
||||
|
||||
function effectOverflow(effects?: Effect[]) {
|
||||
let left = 0
|
||||
let right = 0
|
||||
let top = 0
|
||||
let bottom = 0
|
||||
|
||||
for (const effect of effects ?? []) {
|
||||
if (!effect.visible) continue
|
||||
if (effect.type !== 'DROP_SHADOW' && effect.type !== 'LAYER_BLUR' && effect.type !== 'FOREGROUND_BLUR') {
|
||||
continue
|
||||
}
|
||||
const blurSpread = effect.radius + effect.spread
|
||||
left = Math.max(left, blurSpread + Math.max(0, -effect.offset.x))
|
||||
right = Math.max(right, blurSpread + Math.max(0, effect.offset.x))
|
||||
top = Math.max(top, blurSpread + Math.max(0, -effect.offset.y))
|
||||
bottom = Math.max(bottom, blurSpread + Math.max(0, effect.offset.y))
|
||||
}
|
||||
|
||||
return { left, right, top, bottom }
|
||||
}
|
||||
|
||||
export function computeAbsoluteBounds(
|
||||
nodes: Iterable<{ id: string; width: number; height: number }>,
|
||||
getAbsolutePosition: (id: string) => Vector
|
||||
|
|
@ -99,3 +133,34 @@ export function computeAbsoluteBounds(
|
|||
if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 }
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
|
||||
export function computeVisualBounds(
|
||||
nodes: Iterable<{
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
rotation?: number
|
||||
strokes?: Stroke[]
|
||||
effects?: Effect[]
|
||||
}>,
|
||||
getAbsolutePosition: (id: string) => Vector
|
||||
): Rect {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity
|
||||
|
||||
for (const n of nodes) {
|
||||
const abs = getAbsolutePosition(n.id)
|
||||
const bbox = rotatedBBox(abs.x, abs.y, n.width, n.height, n.rotation ?? 0)
|
||||
const stroke = strokeOverflow(n.strokes)
|
||||
const effects = effectOverflow(n.effects)
|
||||
minX = Math.min(minX, bbox.left - stroke - effects.left)
|
||||
minY = Math.min(minY, bbox.top - stroke - effects.top)
|
||||
maxX = Math.max(maxX, bbox.right + stroke + effects.right)
|
||||
maxY = Math.max(maxY, bbox.bottom + stroke + effects.bottom)
|
||||
}
|
||||
|
||||
if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 }
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ function ensureSingleNode(target: ExportRequest['target']): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
function findPageId(graph: ExportRequest['graph'], nodeId: string): string | null {
|
||||
let current = graph.getNode(nodeId)
|
||||
while (current?.parentId) {
|
||||
const parent = graph.getNode(current.parentId)
|
||||
if (!parent) return null
|
||||
if (parent.type === 'CANVAS') return parent.id
|
||||
current = parent
|
||||
}
|
||||
return current?.type === 'CANVAS' ? current.id : null
|
||||
}
|
||||
|
||||
function resolveExportNodes(request: ExportRequest): { pageId: string; nodeIds: string[] } | null {
|
||||
switch (request.target.scope) {
|
||||
case 'document': {
|
||||
|
|
@ -42,14 +53,12 @@ function resolveExportNodes(request: ExportRequest): { pageId: string; nodeIds:
|
|||
case 'selection': {
|
||||
const first = request.target.nodeIds[0]
|
||||
if (!first) return null
|
||||
const node = request.graph.getNode(first)
|
||||
if (!node) return null
|
||||
let current = node.parentId ? request.graph.getNode(node.parentId) : undefined
|
||||
while (current && current.type !== 'CANVAS') {
|
||||
current = current.parentId ? request.graph.getNode(current.parentId) : undefined
|
||||
const pageId = findPageId(request.graph, first)
|
||||
if (!pageId) return null
|
||||
if (!request.target.nodeIds.every((nodeId) => findPageId(request.graph, nodeId) === pageId)) {
|
||||
throw new Error('Export selection must stay on a single page')
|
||||
}
|
||||
if (!current) return null
|
||||
return { pageId: current.id, nodeIds: request.target.nodeIds }
|
||||
return { pageId, nodeIds: request.target.nodeIds }
|
||||
}
|
||||
case 'node':
|
||||
return resolveExportNodes({
|
||||
|
|
@ -114,7 +123,7 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter {
|
|||
exportOptions: {
|
||||
scale: true,
|
||||
quality: format !== 'PNG',
|
||||
colorSpace: true
|
||||
colorSpace: false
|
||||
},
|
||||
async exportContent(request, options?: RasterExportOptions, context?: IOContext) {
|
||||
const data = await renderRaster(
|
||||
|
|
@ -122,8 +131,7 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter {
|
|||
{
|
||||
format,
|
||||
scale: options?.scale,
|
||||
quality: options?.quality,
|
||||
colorSpace: options?.colorSpace
|
||||
quality: options?.quality
|
||||
},
|
||||
context
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,12 +38,17 @@ export async function headlessRenderNodes(
|
|||
options: { scale?: number; format?: ExportFormat; quality?: number } = {}
|
||||
): Promise<Uint8Array | null> {
|
||||
const { ck, renderer } = await getRenderer()
|
||||
await renderer.prepareForExport(graph, pageId, nodeIds)
|
||||
return renderNodesToImage(ck, renderer, graph, pageId, nodeIds, {
|
||||
scale: options.scale ?? 1,
|
||||
format: options.format ?? 'PNG',
|
||||
quality: options.quality
|
||||
})
|
||||
renderer.invalidateAllPictures()
|
||||
const restoreTextMeasurer = await renderer.prepareForExport(graph, pageId, nodeIds)
|
||||
try {
|
||||
return renderNodesToImage(ck, renderer, graph, pageId, nodeIds, {
|
||||
scale: options.scale ?? 1,
|
||||
format: options.format ?? 'PNG',
|
||||
quality: options.quality
|
||||
})
|
||||
} finally {
|
||||
restoreTextMeasurer()
|
||||
}
|
||||
}
|
||||
|
||||
export async function headlessRenderThumbnail(
|
||||
|
|
@ -53,7 +58,14 @@ export async function headlessRenderThumbnail(
|
|||
height: number
|
||||
): Promise<Uint8Array | null> {
|
||||
const { ck, renderer } = await getRenderer()
|
||||
renderer.invalidateAllPictures()
|
||||
const page = graph.getNode(pageId)
|
||||
if (page) await renderer.prepareForExport(graph, pageId, page.childIds)
|
||||
return renderThumbnail(ck, renderer, graph, pageId, width, height)
|
||||
const restoreTextMeasurer = page
|
||||
? await renderer.prepareForExport(graph, pageId, page.childIds)
|
||||
: () => undefined
|
||||
try {
|
||||
return renderThumbnail(ck, renderer, graph, pageId, width, height)
|
||||
} finally {
|
||||
restoreTextMeasurer()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { computeVisualBounds } from '../../../geometry'
|
||||
import { extractExportGraph } from '../../subgraph'
|
||||
|
||||
import type { RenderColorSpace } from '@open-pencil/core/color-management'
|
||||
import type { SkiaRenderer } from '@open-pencil/core/renderer'
|
||||
import type { SceneGraph } from '@open-pencil/core/scene-graph'
|
||||
|
|
@ -13,27 +16,50 @@ interface RenderOptions {
|
|||
colorSpace?: RenderColorSpace
|
||||
}
|
||||
|
||||
function findPageId(graph: SceneGraph, nodeId: string): string | null {
|
||||
let current = graph.getNode(nodeId)
|
||||
while (current?.parentId) {
|
||||
const parent = graph.getNode(current.parentId)
|
||||
if (!parent) return null
|
||||
if (parent.type === 'CANVAS') return parent.id
|
||||
current = parent
|
||||
}
|
||||
return current?.type === 'CANVAS' ? current.id : null
|
||||
}
|
||||
|
||||
function ensureSinglePageSelection(graph: SceneGraph, pageId: string, nodeIds: string[]): boolean {
|
||||
return nodeIds.every((nodeId) => findPageId(graph, nodeId) === pageId)
|
||||
}
|
||||
|
||||
function nodeNeedsSceneBackdrop(graph: SceneGraph, nodeId: string): boolean {
|
||||
const node = graph.getNode(nodeId)
|
||||
if (!node) return false
|
||||
if (node.blendMode !== 'NORMAL' && node.blendMode !== 'PASS_THROUGH') return true
|
||||
if (node.effects.some((effect) => effect.visible && effect.type === 'BACKGROUND_BLUR')) {
|
||||
return true
|
||||
}
|
||||
return node.childIds.some((childId) => nodeNeedsSceneBackdrop(graph, childId))
|
||||
}
|
||||
|
||||
export function computeContentBounds(
|
||||
graph: SceneGraph,
|
||||
nodeIds: string[]
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity
|
||||
const nodes = nodeIds
|
||||
.map((id) => graph.getNode(id))
|
||||
.filter(
|
||||
(node): node is NonNullable<ReturnType<SceneGraph['getNode']>> => !!node && node.visible
|
||||
)
|
||||
|
||||
for (const id of nodeIds) {
|
||||
const node = graph.getNode(id)
|
||||
if (!node || !node.visible) continue
|
||||
const abs = graph.getAbsolutePosition(id)
|
||||
minX = Math.min(minX, abs.x)
|
||||
minY = Math.min(minY, abs.y)
|
||||
maxX = Math.max(maxX, abs.x + node.width)
|
||||
maxY = Math.max(maxY, abs.y + node.height)
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const bounds = computeVisualBounds(nodes, (id) => graph.getAbsolutePosition(id))
|
||||
return {
|
||||
minX: bounds.x,
|
||||
minY: bounds.y,
|
||||
maxX: bounds.x + bounds.width,
|
||||
maxY: bounds.y + bounds.height
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) return null
|
||||
return { minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
function ckImageFormat(ck: CanvasKit, format: ExportFormat) {
|
||||
|
|
@ -50,7 +76,7 @@ function ckImageFormat(ck: CanvasKit, format: ExportFormat) {
|
|||
function renderToSurface(
|
||||
ck: CanvasKit,
|
||||
renderer: SkiaRenderer,
|
||||
graph: SceneGraph,
|
||||
renderGraph: SceneGraph,
|
||||
pageId: string,
|
||||
width: number,
|
||||
height: number,
|
||||
|
|
@ -64,7 +90,7 @@ function renderToSurface(
|
|||
try {
|
||||
const canvas = surface.getCanvas()
|
||||
setup(canvas)
|
||||
renderer.renderSceneToCanvas(canvas, graph, pageId)
|
||||
renderer.renderSceneToCanvas(canvas, renderGraph, pageId)
|
||||
surface.flush()
|
||||
const image = surface.makeImageSnapshot()
|
||||
const encoded = image.encodeToBytes(ckImageFormat(ck, format), quality)
|
||||
|
|
@ -83,6 +109,10 @@ export function renderNodesToImage(
|
|||
nodeIds: string[],
|
||||
options: RenderOptions
|
||||
): Uint8Array | null {
|
||||
if (!ensureSinglePageSelection(graph, pageId, nodeIds)) {
|
||||
throw new Error('Raster export selection must stay on a single page')
|
||||
}
|
||||
|
||||
const bounds = computeContentBounds(graph, nodeIds)
|
||||
if (!bounds) return null
|
||||
|
||||
|
|
@ -94,12 +124,20 @@ export function renderNodesToImage(
|
|||
const pixelH = Math.ceil(contentH * options.scale)
|
||||
if (pixelW <= 0 || pixelH <= 0) return null
|
||||
|
||||
const extracted = extractExportGraph(graph, { scope: 'selection', nodeIds })
|
||||
if (!extracted.pageId) return null
|
||||
|
||||
const renderGraph = nodeIds.some((nodeId) => nodeNeedsSceneBackdrop(graph, nodeId))
|
||||
? graph
|
||||
: extracted.graph
|
||||
const renderPageId = renderGraph === graph ? pageId : extracted.pageId
|
||||
|
||||
const quality = options.quality ?? (options.format === 'PNG' ? 100 : 90)
|
||||
return renderToSurface(
|
||||
ck,
|
||||
renderer,
|
||||
graph,
|
||||
pageId,
|
||||
renderGraph,
|
||||
renderPageId,
|
||||
pixelW,
|
||||
pixelH,
|
||||
options.format,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getDefaultRenderColorSpace } from '@open-pencil/core/color-management'
|
||||
import { computeContentBounds } from '@open-pencil/core/io/formats/raster'
|
||||
|
||||
import { resolveNodeTextDirection } from '../../../direction'
|
||||
import {
|
||||
nextDefId,
|
||||
formatColor,
|
||||
|
|
@ -19,7 +19,6 @@ import {
|
|||
roundedRectPath,
|
||||
arcPath
|
||||
} from './paths'
|
||||
import { resolveNodeTextDirection } from '../../../direction'
|
||||
|
||||
export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './paths'
|
||||
|
||||
|
|
@ -148,7 +147,8 @@ function nodeShapeElements(
|
|||
}
|
||||
|
||||
function styleOverrideToTspanAttrs(
|
||||
style: CharacterStyleOverride
|
||||
style: CharacterStyleOverride,
|
||||
colorSpace: 'srgb' | 'display-p3'
|
||||
): Record<string, string | number | undefined> {
|
||||
const attrs: Record<string, string | number | undefined> = {}
|
||||
if (style.fontFamily) attrs['font-family'] = style.fontFamily
|
||||
|
|
@ -161,7 +161,7 @@ function styleOverrideToTspanAttrs(
|
|||
if (style.fills) {
|
||||
const visibleFill = style.fills.find((f) => f.visible && f.type === 'SOLID')
|
||||
if (visibleFill) {
|
||||
attrs.fill = formatColor(visibleFill.color, visibleFill.opacity)
|
||||
attrs.fill = formatColor(visibleFill.color, visibleFill.opacity, colorSpace)
|
||||
}
|
||||
}
|
||||
return attrs
|
||||
|
|
@ -174,7 +174,10 @@ function isLogicalTextEnd(node: SceneNode, direction: 'LTR' | 'RTL'): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
function textAnchorForNode(node: SceneNode, direction: 'LTR' | 'RTL'): 'middle' | 'end' | undefined {
|
||||
function textAnchorForNode(
|
||||
node: SceneNode,
|
||||
direction: 'LTR' | 'RTL'
|
||||
): 'middle' | 'end' | undefined {
|
||||
if (node.textAlignHorizontal === 'CENTER') return 'middle'
|
||||
if (isLogicalTextEnd(node, direction)) return 'end'
|
||||
return undefined
|
||||
|
|
@ -186,7 +189,11 @@ function textXForNode(node: SceneNode, direction: 'LTR' | 'RTL'): number {
|
|||
return 0
|
||||
}
|
||||
|
||||
function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode {
|
||||
function renderTextNode(
|
||||
node: SceneNode,
|
||||
fillAttr: string | null,
|
||||
colorSpace: 'srgb' | 'display-p3'
|
||||
): SVGNode {
|
||||
const direction = resolveNodeTextDirection(node)
|
||||
const textAnchor = textAnchorForNode(node, direction)
|
||||
|
||||
|
|
@ -215,7 +222,7 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode {
|
|||
for (const run of node.styleRuns) {
|
||||
const text = node.text.slice(pos, pos + run.length)
|
||||
pos += run.length
|
||||
spans.push(svg('tspan', styleOverrideToTspanAttrs(run.style), text))
|
||||
spans.push(svg('tspan', styleOverrideToTspanAttrs(run.style, colorSpace), text))
|
||||
}
|
||||
|
||||
return svg('text', { x, y, ...attrs }, ...spans)
|
||||
|
|
@ -282,12 +289,13 @@ function buildGroupAttrs(
|
|||
}
|
||||
|
||||
function buildSVGStrokeAttrs(
|
||||
visibleStrokes: Stroke[]
|
||||
visibleStrokes: Stroke[],
|
||||
colorSpace: 'srgb' | 'display-p3'
|
||||
): Record<string, string | number | undefined> {
|
||||
if (visibleStrokes.length === 0) return {}
|
||||
const stroke = visibleStrokes[0]
|
||||
const attrs: Record<string, string | number | undefined> = {
|
||||
stroke: formatColor(stroke.color, 1),
|
||||
stroke: formatColor(stroke.color, 1, colorSpace),
|
||||
'stroke-width': round(stroke.weight)
|
||||
}
|
||||
if (stroke.opacity < 1) attrs['stroke-opacity'] = round(stroke.opacity)
|
||||
|
|
@ -344,14 +352,14 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
|
|||
if (node.type === 'TEXT') {
|
||||
const firstFill = node.fills.find((f) => f.visible)
|
||||
const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null
|
||||
const textEl = renderTextNode(node, fillAttr)
|
||||
const textEl = renderTextNode(node, fillAttr, ctx.colorSpace)
|
||||
return svg('g', groupAttrs, textEl)
|
||||
}
|
||||
|
||||
const visibleFills = node.fills.filter((f) => f.visible)
|
||||
const visibleStrokes = node.strokes.filter((s) => s.visible)
|
||||
const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null
|
||||
const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes)
|
||||
const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes, ctx.colorSpace)
|
||||
|
||||
const children: (SVGNode | null)[] = buildShapeChildren(
|
||||
node,
|
||||
|
|
@ -397,7 +405,7 @@ function isGroupLike(node: SceneNode): boolean {
|
|||
export interface SVGExportOptions {
|
||||
/** Include XML declaration (default: true) */
|
||||
xmlDeclaration?: boolean
|
||||
/** Target export color space (default: display-p3) */
|
||||
/** Target export color space (default: srgb) */
|
||||
colorSpace?: 'srgb' | 'display-p3'
|
||||
}
|
||||
|
||||
|
|
@ -418,7 +426,7 @@ export function renderNodesToSVG(
|
|||
defs: [],
|
||||
defIdCounter: 0,
|
||||
graph,
|
||||
colorSpace: options.colorSpace ?? getDefaultRenderColorSpace()
|
||||
colorSpace: options.colorSpace ?? 'srgb'
|
||||
}
|
||||
|
||||
const contentNodes: SVGNode[] = []
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ function estimateTextSize(node: SceneNode, maxWidth?: number): { width: number;
|
|||
return { width: singleLineWidth, height: lineH }
|
||||
}
|
||||
|
||||
export function getTextMeasurer(): TextMeasurer | null {
|
||||
return globalTextMeasurer
|
||||
}
|
||||
|
||||
export function setTextMeasurer(measurer: TextMeasurer | null): void {
|
||||
globalTextMeasurer = measurer
|
||||
}
|
||||
|
|
@ -183,7 +187,11 @@ function buildGridTree(
|
|||
root.insertChild(yogaChild, root.getChildCount())
|
||||
} else {
|
||||
const yogaChild = createGridChildNode(child)
|
||||
if (child.layoutMode === 'GRID' || child.layoutMode === 'HORIZONTAL' || child.layoutMode === 'VERTICAL') {
|
||||
if (
|
||||
child.layoutMode === 'GRID' ||
|
||||
child.layoutMode === 'HORIZONTAL' ||
|
||||
child.layoutMode === 'VERTICAL'
|
||||
) {
|
||||
const childDirection = resolveNodeLayoutDirection(child, direction)
|
||||
yogaChild.setDirection(childDirection === 'RTL' ? Direction.RTL : Direction.LTR)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
DEFAULT_FONT_FAMILY,
|
||||
IS_BROWSER
|
||||
} from '../constants'
|
||||
import { computeAbsoluteBounds } from '../geometry'
|
||||
import { computeVisualBounds } from '../geometry'
|
||||
import { RenderProfiler } from '../profiler'
|
||||
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
|
||||
import {
|
||||
|
|
@ -473,16 +473,23 @@ export class SkiaRenderer {
|
|||
* Collects all font family+weight pairs used by `nodeIds`, loads them,
|
||||
* wires up the text measurer for Yoga layout, and recomputes layout.
|
||||
*/
|
||||
async prepareForExport(graph: SceneGraph, pageId: string, nodeIds: string[]): Promise<void> {
|
||||
async prepareForExport(
|
||||
graph: SceneGraph,
|
||||
pageId: string,
|
||||
nodeIds: string[]
|
||||
): Promise<() => void> {
|
||||
const { collectFontKeys, loadFont } = await import('../fonts')
|
||||
const { setTextMeasurer, computeAllLayouts } = await import('../layout')
|
||||
const { getTextMeasurer, setTextMeasurer, computeAllLayouts } = await import('../layout')
|
||||
|
||||
const previousTextMeasurer = getTextMeasurer()
|
||||
setTextMeasurer((node, maxWidth) => this.measureTextNode(node, maxWidth))
|
||||
|
||||
const fontKeys = collectFontKeys(graph, nodeIds)
|
||||
await Promise.all(fontKeys.map(([family, style]) => loadFont(family, style)))
|
||||
|
||||
computeAllLayouts(graph, pageId)
|
||||
|
||||
return () => setTextMeasurer(previousTextMeasurer)
|
||||
}
|
||||
|
||||
replaceSurface(surface: Surface): void {
|
||||
|
|
@ -902,7 +909,7 @@ export class SkiaRenderer {
|
|||
: []
|
||||
const sceneBounds =
|
||||
sceneNodes.length > 0
|
||||
? computeAbsoluteBounds(sceneNodes, (id) => graph.getAbsolutePosition(id))
|
||||
? computeVisualBounds(sceneNodes, (id) => graph.getAbsolutePosition(id))
|
||||
: { x: 0, y: 0, width: 1, height: 1 }
|
||||
const padding = 1024
|
||||
const bounds = this.ck.LTRBRect(
|
||||
|
|
|
|||
|
|
@ -80,12 +80,17 @@ export function startServer(options: ServerOptions = {}) {
|
|||
const pending = new Map<string, PendingRequest>()
|
||||
let browserWs: WebSocket | null = null
|
||||
let browserToken: string | null = null
|
||||
let browserRegistered = false
|
||||
|
||||
function currentRpcToken(): string | null {
|
||||
return authToken ?? browserToken
|
||||
}
|
||||
|
||||
// --- WebSocket: browser connects here ---
|
||||
|
||||
function sendToBrowser(body: Record<string, unknown>): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!browserWs || browserWs.readyState !== browserWs.OPEN) {
|
||||
if (!browserWs || browserWs.readyState !== browserWs.OPEN || !browserRegistered) {
|
||||
reject(new Error('OpenPencil app is not connected'))
|
||||
return
|
||||
}
|
||||
|
|
@ -99,7 +104,7 @@ export function startServer(options: ServerOptions = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
function handleBrowserMessage(data: string) {
|
||||
function handleBrowserMessage(data: string, ws: WebSocket) {
|
||||
try {
|
||||
const msg = JSON.parse(data) as {
|
||||
type: string
|
||||
|
|
@ -110,9 +115,20 @@ export function startServer(options: ServerOptions = {}) {
|
|||
ok?: boolean
|
||||
}
|
||||
if (msg.type === 'register' && msg.token) {
|
||||
if (authToken && msg.token !== authToken) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
if (browserWs && browserWs !== ws && browserWs.readyState === WebSocket.OPEN) {
|
||||
browserWs.close()
|
||||
rejectAllPending('Browser reconnected')
|
||||
}
|
||||
browserWs = ws
|
||||
browserToken = msg.token
|
||||
browserRegistered = true
|
||||
return
|
||||
}
|
||||
if (!browserRegistered || browserWs !== ws) return
|
||||
if (msg.type === 'response' && msg.id) {
|
||||
const req = pending.get(msg.id)
|
||||
if (!req) return
|
||||
|
|
@ -140,14 +156,10 @@ export function startServer(options: ServerOptions = {}) {
|
|||
const wss = new WebSocketServer({ port: wsPort, host: '127.0.0.1' })
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
if (browserWs && browserWs.readyState === WebSocket.OPEN) browserWs.close()
|
||||
rejectAllPending('Browser reconnected')
|
||||
browserWs = ws
|
||||
browserToken = null
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
handleBrowserMessage(
|
||||
typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8')
|
||||
typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8'),
|
||||
ws
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -155,6 +167,7 @@ export function startServer(options: ServerOptions = {}) {
|
|||
if (browserWs === ws) {
|
||||
browserWs = null
|
||||
browserToken = null
|
||||
browserRegistered = false
|
||||
rejectAllPending('Browser disconnected')
|
||||
}
|
||||
})
|
||||
|
|
@ -201,24 +214,24 @@ export function startServer(options: ServerOptions = {}) {
|
|||
exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
|
||||
})
|
||||
)
|
||||
} else {
|
||||
app.use('*', cors())
|
||||
}
|
||||
|
||||
app.get('/health', (c) =>
|
||||
c.json({
|
||||
status: browserWs ? 'ok' : 'no_app',
|
||||
...(browserWs && browserToken ? { token: browserToken } : {})
|
||||
status: browserWs && browserRegistered ? 'ok' : 'no_app',
|
||||
authRequired: authToken !== null,
|
||||
...(currentRpcToken() ? { token: currentRpcToken() } : {})
|
||||
})
|
||||
)
|
||||
|
||||
app.use('/rpc', async (c, next) => {
|
||||
if (!browserWs || !browserToken) {
|
||||
const rpcToken = currentRpcToken()
|
||||
if (!browserWs || !browserRegistered || !rpcToken) {
|
||||
return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503)
|
||||
}
|
||||
const auth = c.req.header('authorization')
|
||||
const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null
|
||||
if (provided !== browserToken) {
|
||||
if (provided !== rpcToken) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
return next()
|
||||
|
|
@ -242,8 +255,22 @@ export function startServer(options: ServerOptions = {}) {
|
|||
// --- MCP Streamable HTTP ---
|
||||
|
||||
type MCPTransport = { handleRequest: (r: Request) => Promise<Response> }
|
||||
const mcpSessions = new Map<string, MCPTransport>()
|
||||
interface MCPSession {
|
||||
transport: MCPTransport
|
||||
lastSeen: number
|
||||
}
|
||||
const mcpSessions = new Map<string, MCPSession>()
|
||||
const MAX_MCP_SESSIONS = 10
|
||||
const MCP_SESSION_TTL_MS = 15 * 60_000
|
||||
|
||||
function cleanupExpiredMCPSessions() {
|
||||
const now = Date.now()
|
||||
for (const [id, session] of mcpSessions) {
|
||||
if (now - session.lastSeen > MCP_SESSION_TTL_MS) {
|
||||
mcpSessions.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMCPSession(id: string): MCPTransport {
|
||||
const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION })
|
||||
|
|
@ -297,7 +324,7 @@ export function startServer(options: ServerOptions = {}) {
|
|||
sessionIdGenerator: () => id
|
||||
})
|
||||
void mcpServer.connect(transport)
|
||||
mcpSessions.set(id, transport)
|
||||
mcpSessions.set(id, { transport, lastSeen: Date.now() })
|
||||
return transport
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +338,7 @@ export function startServer(options: ServerOptions = {}) {
|
|||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
}
|
||||
cleanupExpiredMCPSessions()
|
||||
const sessionId = c.req.header('mcp-session-id') ?? undefined
|
||||
const existing = sessionId ? mcpSessions.get(sessionId) : undefined
|
||||
if (!existing && mcpSessions.size >= MAX_MCP_SESSIONS) {
|
||||
|
|
@ -319,7 +347,14 @@ export function startServer(options: ServerOptions = {}) {
|
|||
{ status: 503, headers: { 'Retry-After': '5' } }
|
||||
)
|
||||
}
|
||||
const transport = existing ?? createMCPSession(sessionId ?? randomUUID())
|
||||
const transport = existing?.transport ?? createMCPSession(sessionId ?? randomUUID())
|
||||
const resolvedSessionId =
|
||||
sessionId ??
|
||||
[...mcpSessions.entries()].find(([, entry]) => entry.transport === transport)?.[0]
|
||||
if (resolvedSessionId) {
|
||||
const session = mcpSessions.get(resolvedSessionId)
|
||||
if (session) session.lastSeen = Date.now()
|
||||
}
|
||||
const response = await transport.handleRequest(c.req.raw)
|
||||
if (c.req.method === 'DELETE' && sessionId) {
|
||||
mcpSessions.delete(sessionId)
|
||||
|
|
|
|||
|
|
@ -206,7 +206,9 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
|
|||
})
|
||||
|
||||
const stdoutChunks: Uint8Array[] = []
|
||||
let stdoutResolver: ((chunk: Uint8Array) => void) | null = null
|
||||
let stdoutResolver: ((chunk: Uint8Array | null) => void) | null = null
|
||||
let stdoutClosed = false
|
||||
let stdoutClosedError: Error | null = null
|
||||
|
||||
command.stdout.on('data', (raw: Uint8Array | number[]) => {
|
||||
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
|
|
@ -224,6 +226,13 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
|
|||
})
|
||||
|
||||
command.on('close', () => {
|
||||
stdoutClosed = true
|
||||
stdoutClosedError = this.destroying ? null : new Error('Agent process exited unexpectedly.')
|
||||
if (stdoutResolver) {
|
||||
const resolve = stdoutResolver
|
||||
stdoutResolver = null
|
||||
resolve(null)
|
||||
}
|
||||
if (this.destroying || !this.session) return
|
||||
this.session.dead = true
|
||||
this.session = null
|
||||
|
|
@ -238,12 +247,20 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
|
|||
controller.enqueue(buffered)
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
stdoutResolver = (chunk) => {
|
||||
controller.enqueue(chunk)
|
||||
resolve()
|
||||
}
|
||||
if (stdoutClosed) {
|
||||
if (stdoutClosedError) controller.error(stdoutClosedError)
|
||||
else controller.close()
|
||||
return
|
||||
}
|
||||
const chunk = await new Promise<Uint8Array | null>((resolve) => {
|
||||
stdoutResolver = resolve
|
||||
})
|
||||
if (chunk) {
|
||||
controller.enqueue(chunk)
|
||||
return
|
||||
}
|
||||
if (stdoutClosedError) controller.error(stdoutClosedError)
|
||||
else controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -270,6 +287,8 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
|
|||
}
|
||||
|
||||
const connection = new ClientSideConnection((_agent: Agent) => clientImpl, stream)
|
||||
const { getAutomationAuthToken } = await import('@/automation/spawn-mcp')
|
||||
const automationAuthToken = await getAutomationAuthToken()
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
|
|
@ -285,7 +304,9 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
|
|||
type: 'http' as const,
|
||||
name: 'open-pencil',
|
||||
url: 'http://127.0.0.1:7600/mcp',
|
||||
headers: []
|
||||
headers: automationAuthToken
|
||||
? [{ name: 'Authorization', value: `Bearer ${automationAuthToken}` }]
|
||||
: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { tool } from 'ai'
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { makeFigmaFromStore } from '@/automation/figma-factory'
|
||||
import { getActiveEditorStore } from '@/stores/editor'
|
||||
import {
|
||||
CORE_TOOLS,
|
||||
collectFontKeys,
|
||||
|
|
@ -46,37 +47,48 @@ class RunState {
|
|||
clear(): void {
|
||||
this.toolLog = []
|
||||
this.stepUsages = []
|
||||
this.currentSteps = 0
|
||||
}
|
||||
}
|
||||
|
||||
export const runState = new RunState()
|
||||
const runStates = new WeakMap<EditorStore, RunState>()
|
||||
|
||||
export function getToolLogEntries(): ToolLogEntry[] {
|
||||
return runState.toolLog
|
||||
function getRunState(store?: EditorStore): RunState {
|
||||
const target = store ?? getActiveEditorStore()
|
||||
const existing = runStates.get(target)
|
||||
if (existing) return existing
|
||||
const created = new RunState()
|
||||
runStates.set(target, created)
|
||||
return created
|
||||
}
|
||||
|
||||
export function getStepUsages(): StepUsage[] {
|
||||
return runState.stepUsages
|
||||
export function getToolLogEntries(store?: EditorStore): ToolLogEntry[] {
|
||||
return getRunState(store).toolLog
|
||||
}
|
||||
|
||||
export function recordStepUsage(usage: StepUsage): void {
|
||||
runState.recordStep(usage)
|
||||
export function getStepUsages(store?: EditorStore): StepUsage[] {
|
||||
return getRunState(store).stepUsages
|
||||
}
|
||||
|
||||
export function resetRunSteps(): void {
|
||||
runState.resetSteps()
|
||||
export function recordStepUsage(usage: StepUsage, store?: EditorStore): void {
|
||||
getRunState(store).recordStep(usage)
|
||||
}
|
||||
|
||||
export function didHitStepLimit(): boolean {
|
||||
return runState.hitLimit()
|
||||
export function resetRunSteps(store?: EditorStore): void {
|
||||
getRunState(store).resetSteps()
|
||||
}
|
||||
|
||||
export function clearToolLogEntries(): void {
|
||||
runState.clear()
|
||||
export function didHitStepLimit(store?: EditorStore): boolean {
|
||||
return getRunState(store).hitLimit()
|
||||
}
|
||||
|
||||
export function clearToolLogEntries(store?: EditorStore): void {
|
||||
getRunState(store).clear()
|
||||
}
|
||||
|
||||
export function createAITools(store: EditorStore) {
|
||||
let beforeSnapshot: Map<string, SceneNode> | null = null
|
||||
const runState = getRunState(store)
|
||||
|
||||
return toolsToAI(
|
||||
CORE_TOOLS,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
import type { EditorStore } from '@/stores/editor'
|
||||
import type { RasterExportFormat } from '@open-pencil/core'
|
||||
|
||||
export function connectAutomation(getStore: () => EditorStore) {
|
||||
const token = randomHex(32)
|
||||
export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) {
|
||||
const token = authToken ?? randomHex(32)
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,76 @@
|
|||
import { decodeTauriStderr } from '@/utils/tauri'
|
||||
import { AUTOMATION_HTTP_PORT, IS_TAURI } from '@open-pencil/core'
|
||||
import { AUTOMATION_HTTP_PORT, IS_TAURI, randomHex } from '@open-pencil/core'
|
||||
|
||||
async function checkHealth(): Promise<boolean> {
|
||||
interface AutomationHealth {
|
||||
status: 'ok' | 'no_app'
|
||||
authRequired?: boolean
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface AutomationServerHandle {
|
||||
disconnect: () => void
|
||||
authToken: string | null
|
||||
}
|
||||
|
||||
const DEV_AUTOMATION_AUTH_TOKEN = import.meta.env.DEV ? __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__ : null
|
||||
const noop = () => undefined
|
||||
|
||||
let runtimeAutomationAuthToken: string | null = DEV_AUTOMATION_AUTH_TOKEN
|
||||
|
||||
async function readHealth(): Promise<AutomationHealth | null> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${AUTOMATION_HTTP_PORT}/health`, {
|
||||
signal: AbortSignal.timeout(1000)
|
||||
})
|
||||
return res.ok
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as AutomationHealth
|
||||
} catch {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealth(retries: number, delayMs: number): Promise<boolean> {
|
||||
async function pollHealth(retries: number, delayMs: number): Promise<AutomationHealth | null> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
await new Promise((r) => setTimeout(r, delayMs))
|
||||
if (await checkHealth()) return true
|
||||
const health = await readHealth()
|
||||
if (health) return health
|
||||
}
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
export async function spawnMCPIfNeeded(): Promise<(() => void) | null> {
|
||||
if (import.meta.env.DEV || !IS_TAURI) return null
|
||||
export async function getAutomationAuthToken(): Promise<string | null> {
|
||||
if (runtimeAutomationAuthToken) return runtimeAutomationAuthToken
|
||||
const health = await readHealth()
|
||||
runtimeAutomationAuthToken = health?.token ?? null
|
||||
return runtimeAutomationAuthToken
|
||||
}
|
||||
|
||||
if (await checkHealth()) return null
|
||||
export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null> {
|
||||
if (import.meta.env.DEV || !IS_TAURI) {
|
||||
return DEV_AUTOMATION_AUTH_TOKEN
|
||||
? { disconnect: noop, authToken: DEV_AUTOMATION_AUTH_TOKEN }
|
||||
: null
|
||||
}
|
||||
|
||||
const existing = await readHealth()
|
||||
if (existing) {
|
||||
runtimeAutomationAuthToken = existing.token ?? null
|
||||
return {
|
||||
disconnect: noop,
|
||||
authToken: runtimeAutomationAuthToken
|
||||
}
|
||||
}
|
||||
|
||||
const authToken = randomHex(32)
|
||||
runtimeAutomationAuthToken = authToken
|
||||
|
||||
const { Command } = await import('@tauri-apps/plugin-shell')
|
||||
const command = Command.create('openpencil-mcp', [])
|
||||
const command = Command.create('openpencil-mcp', [], {
|
||||
env: {
|
||||
OPENPENCIL_MCP_AUTH_TOKEN: authToken,
|
||||
OPENPENCIL_MCP_CORS_ORIGIN: window.location.origin
|
||||
}
|
||||
})
|
||||
|
||||
command.stderr.on('data', (raw: Uint8Array | number[] | string) => {
|
||||
console.error('[MCP]', decodeTauriStderr(raw))
|
||||
|
|
@ -37,10 +81,15 @@ export async function spawnMCPIfNeeded(): Promise<(() => void) | null> {
|
|||
})
|
||||
|
||||
const child = await command.spawn()
|
||||
const health = await pollHealth(5, 1000)
|
||||
|
||||
if (await pollHealth(5, 1000)) {
|
||||
return () => {
|
||||
void child.kill()
|
||||
if (health) {
|
||||
runtimeAutomationAuthToken = health.token ?? authToken
|
||||
return {
|
||||
disconnect: () => {
|
||||
void child.kill()
|
||||
},
|
||||
authToken: runtimeAutomationAuthToken
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { spawn } from 'node:child_process'
|
|||
import type { Plugin } from 'vite'
|
||||
|
||||
// TODO: production — bundle MCP server as Tauri sidecar or spawn via shell plugin
|
||||
export function automationPlugin(): Plugin {
|
||||
export function automationPlugin(authToken: string | null, corsOrigin: string): Plugin {
|
||||
let child: ReturnType<typeof spawn> | null = null
|
||||
|
||||
return {
|
||||
|
|
@ -13,7 +13,13 @@ export function automationPlugin(): Plugin {
|
|||
|
||||
child = spawn('bun', ['run', 'packages/mcp/src/index.ts'], {
|
||||
stdio: ['ignore', 'inherit', 'pipe'],
|
||||
env: { ...process.env, PORT: '7600', WS_PORT: '7601' }
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: '7600',
|
||||
WS_PORT: '7601',
|
||||
...(authToken ? { OPENPENCIL_MCP_AUTH_TOKEN: authToken } : {}),
|
||||
OPENPENCIL_MCP_CORS_ORIGIN: corsOrigin
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,14 @@ function ids() {
|
|||
return [...selectedIds.value]
|
||||
}
|
||||
|
||||
function execCommand(cmd: string) {
|
||||
window.document.execCommand(cmd)
|
||||
function execCommand(cmd: 'copy' | 'cut' | 'paste') {
|
||||
try {
|
||||
if (window.document.execCommand(cmd)) return
|
||||
} catch (error) {
|
||||
console.warn(`Clipboard command ${cmd} failed`, error)
|
||||
}
|
||||
|
||||
toast.show('Clipboard access is blocked in this browser context', 'error')
|
||||
}
|
||||
|
||||
async function clipboardWrite(text: string | null, label: string) {
|
||||
|
|
@ -37,6 +43,10 @@ async function clipboardWrite(text: string | null, label: string) {
|
|||
}
|
||||
|
||||
async function copyAsPNG() {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
||||
toast.show('PNG clipboard export is not available in this browser', 'error')
|
||||
return
|
||||
}
|
||||
const data = await store.renderExportImage([...selectedIds.value], 2, 'PNG')
|
||||
if (!data) return
|
||||
const blob = new Blob([data], { type: 'image/png' })
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { computed, markRaw, nextTick, ref, watch } from 'vue'
|
|||
import { getAcpDebugText, clearAcpDebugLog, hasAcpDebugEntries } from '@/ai/acp-transport'
|
||||
import { copyChatLog } from '@/ai/chat-debug'
|
||||
import { clearToolLogEntries, didHitStepLimit } from '@/ai/tools'
|
||||
import { activeTab } from '@/stores/tabs'
|
||||
import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue'
|
||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||
|
|
@ -61,6 +62,13 @@ function scrollToBottom() {
|
|||
}
|
||||
|
||||
watch(messages, scrollToBottom, { deep: true })
|
||||
watch(
|
||||
() => activeTab.value?.id,
|
||||
async () => {
|
||||
const nextChat = await ensureChat()
|
||||
chat.value = nextChat ? markRaw(nextChat) : null
|
||||
}
|
||||
)
|
||||
|
||||
async function handleSubmit(text: string) {
|
||||
if (status.value === 'streaming' || status.value === 'submitted') return
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
|
||||
|
||||
|
|
@ -21,8 +21,10 @@ const { dialogs } = useI18n()
|
|||
|
||||
const joinInput = ref('')
|
||||
const nameDraft = ref(collab?.state.value.localName ?? '')
|
||||
const pendingRoomId = (route.params.roomId as string) || null
|
||||
const popoverOpen = ref(!!pendingRoomId)
|
||||
const pendingRoomId = computed(() =>
|
||||
typeof route.params.roomId === 'string' ? route.params.roomId : null
|
||||
)
|
||||
const popoverOpen = ref(!!pendingRoomId.value)
|
||||
|
||||
const state = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
|
||||
const peers = computed(() => collab?.remotePeers.value ?? [])
|
||||
|
|
@ -33,7 +35,17 @@ const shareUrl = computed(() => {
|
|||
return `${window.location.origin}/share/${state.value.roomId}`
|
||||
})
|
||||
|
||||
const isJoining = computed(() => !!pendingRoomId && !state.value.connected)
|
||||
const isJoining = computed(() => !!pendingRoomId.value && !state.value.connected)
|
||||
|
||||
watch(
|
||||
pendingRoomId,
|
||||
(roomId) => {
|
||||
if (!state.value.connected) {
|
||||
popoverOpen.value = !!roomId
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function copyLink() {
|
||||
if (!shareUrl.value) return
|
||||
|
|
@ -53,7 +65,7 @@ function onShare() {
|
|||
|
||||
function onJoin() {
|
||||
if (!collab) return
|
||||
const roomId = pendingRoomId || joinInput.value.trim().replace(/.*\/share\//, '')
|
||||
const roomId = pendingRoomId.value || joinInput.value.trim().replace(/.*\/share\//, '')
|
||||
if (!roomId || !nameDraft.value.trim()) return
|
||||
collab.setLocalName(nameDraft.value.trim())
|
||||
collab.connect(roomId)
|
||||
|
|
@ -64,6 +76,7 @@ function onJoin() {
|
|||
function onDisconnect() {
|
||||
if (!collab) return
|
||||
collab.disconnect()
|
||||
popoverOpen.value = false
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { computed, ref, watch } from 'vue'
|
|||
|
||||
import SYSTEM_PROMPT from '@/ai/system-prompt.md?raw'
|
||||
import { MAX_AGENT_STEPS, createAITools, recordStepUsage, resetRunSteps } from '@/ai/tools'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { getActiveEditorStore } from '@/stores/editor'
|
||||
import {
|
||||
ACP_AGENTS,
|
||||
AI_PROVIDERS,
|
||||
|
|
@ -78,9 +78,13 @@ const isConfigured = computed(() => {
|
|||
})
|
||||
|
||||
let transportDirty = false
|
||||
let currentChatStore: ReturnType<typeof getActiveEditorStore> | null = null
|
||||
let currentChatMessages = new WeakMap<ReturnType<typeof getActiveEditorStore>, UIMessage[]>()
|
||||
|
||||
function markTransportDirty() {
|
||||
transportDirty = true
|
||||
currentChatStore = null
|
||||
currentChatMessages = new WeakMap()
|
||||
}
|
||||
|
||||
watch(
|
||||
|
|
@ -217,13 +221,13 @@ async function createACPTransport() {
|
|||
return transport
|
||||
}
|
||||
|
||||
function createTransport() {
|
||||
function createTransport(store: ReturnType<typeof getActiveEditorStore>) {
|
||||
if (overrideTransport) return overrideTransport()
|
||||
|
||||
void acpTransportInstance?.destroy()
|
||||
acpTransportInstance = null
|
||||
|
||||
const tools = createAITools(useEditorStore())
|
||||
const tools = createAITools(store)
|
||||
const cacheProviderOptions = supportsAnthropicCaching() ? ANTHROPIC_CACHE_CONTROL : undefined
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
|
|
@ -234,7 +238,7 @@ function createTransport() {
|
|||
maxOutputTokens: maxOutputTokens.value,
|
||||
providerOptions: cacheProviderOptions,
|
||||
prepareCall: (options) => {
|
||||
resetRunSteps()
|
||||
resetRunSteps(store)
|
||||
return {
|
||||
...options,
|
||||
maxOutputTokens: maxOutputTokens.value,
|
||||
|
|
@ -242,13 +246,16 @@ function createTransport() {
|
|||
}
|
||||
},
|
||||
onStepFinish: ({ usage }) => {
|
||||
recordStepUsage({
|
||||
inputTokens: usage.inputTokens ?? 0,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
recordStepUsage(
|
||||
{
|
||||
inputTokens: usage.inputTokens ?? 0,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
store
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -257,17 +264,28 @@ function createTransport() {
|
|||
|
||||
async function ensureChat(): Promise<Chat<UIMessage> | null> {
|
||||
if (!isConfigured.value) return null
|
||||
if (!chat || transportDirty) {
|
||||
const messages = chat?.messages
|
||||
const transport = isACPProvider.value ? await createACPTransport() : createTransport()
|
||||
|
||||
const store = getActiveEditorStore()
|
||||
if (currentChatStore && chat) {
|
||||
currentChatMessages.set(currentChatStore, chat.messages)
|
||||
}
|
||||
|
||||
if (!chat || transportDirty || currentChatStore !== store) {
|
||||
const messages = currentChatMessages.get(store)
|
||||
const transport = isACPProvider.value ? await createACPTransport() : createTransport(store)
|
||||
chat = new Chat<UIMessage>({ transport, messages })
|
||||
currentChatStore = store
|
||||
transportDirty = false
|
||||
}
|
||||
return chat
|
||||
}
|
||||
|
||||
function resetChat() {
|
||||
if (currentChatStore) {
|
||||
currentChatMessages.delete(currentChatStore)
|
||||
}
|
||||
chat = null
|
||||
currentChatStore = null
|
||||
transportDirty = false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ export const DEFAULT_COLLAB_STATE: CollabState = {
|
|||
localColor: { r: 0.5, g: 0.5, b: 0.5, a: 1 }
|
||||
}
|
||||
|
||||
export function useCollab(store: EditorStore) {
|
||||
export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) {
|
||||
const getStore = () =>
|
||||
typeof storeOrGetter === 'function'
|
||||
? (storeOrGetter as () => EditorStore)()
|
||||
: storeOrGetter
|
||||
const storedName = useLocalStorage('op-collab-name', '')
|
||||
const state = ref<CollabState>({
|
||||
connected: false,
|
||||
|
|
@ -58,9 +62,11 @@ export function useCollab(store: EditorStore) {
|
|||
let yimages: Y.Map<Uint8Array> | null = null
|
||||
let room: Room | null = null
|
||||
let persistence: IndexeddbPersistence | null = null
|
||||
let connectedStore: EditorStore | null = null
|
||||
let suppressGraphSync = false
|
||||
let suppressYjsEvents = false
|
||||
let unbindGraphEvents: (() => void) | null = null
|
||||
let stopZoomWatch: (() => void) | null = null
|
||||
let sendYjsUpdate: ((data: Uint8Array, peerId?: string) => void) | null = null
|
||||
let sendAwareness: ((data: Uint8Array, peerId?: string) => void) | null = null
|
||||
let sendSyncStep1: ((data: Uint8Array, peerId?: string) => void) | null = null
|
||||
|
|
@ -70,6 +76,8 @@ export function useCollab(store: EditorStore) {
|
|||
function connect(roomId: string) {
|
||||
if (room) disconnect()
|
||||
|
||||
const store = getStore()
|
||||
connectedStore = store
|
||||
state.value.roomId = roomId
|
||||
ydoc = new Y.Doc()
|
||||
awareness = new awarenessProtocol.Awareness(ydoc)
|
||||
|
|
@ -203,7 +211,7 @@ export function useCollab(store: EditorStore) {
|
|||
state.value.connected = true
|
||||
broadcastAwareness()
|
||||
|
||||
watch(
|
||||
stopZoomWatch = watch(
|
||||
() => store.state.zoom,
|
||||
(zoom) => {
|
||||
if (!awareness) return
|
||||
|
|
@ -251,8 +259,11 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function disconnect() {
|
||||
const store = connectedStore ?? getStore()
|
||||
unbindGraphEvents?.()
|
||||
unbindGraphEvents = null
|
||||
stopZoomWatch?.()
|
||||
stopZoomWatch = null
|
||||
void room?.leave()
|
||||
room = null
|
||||
sendYjsUpdate = null
|
||||
|
|
@ -276,11 +287,14 @@ export function useCollab(store: EditorStore) {
|
|||
state.value.connected = false
|
||||
state.value.roomId = null
|
||||
state.value.peers = []
|
||||
followingPeer.value = null
|
||||
store.state.remoteCursors = []
|
||||
store.requestRender()
|
||||
connectedStore = null
|
||||
}
|
||||
|
||||
function syncNodeToYjs(nodeId: string) {
|
||||
const store = connectedStore ?? getStore()
|
||||
if (!ydoc || !ynodes) return
|
||||
const node = store.graph.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
|
@ -319,6 +333,7 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function syncAllNodesToYjs() {
|
||||
const store = connectedStore ?? getStore()
|
||||
if (!ydoc || !ynodes) return
|
||||
const localYnodes = ynodes
|
||||
const localYimages = yimages
|
||||
|
|
@ -346,6 +361,7 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function applyYjsToGraph(events: Y.YEvent<Y.Map<unknown>>[]) {
|
||||
const store = connectedStore ?? getStore()
|
||||
if (!ynodes) return
|
||||
const localYnodes = ynodes
|
||||
for (const event of events) {
|
||||
|
|
@ -377,6 +393,7 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function applyYnodeToGraph(nodeId: string, ynode: Y.Map<unknown>) {
|
||||
const store = connectedStore ?? getStore()
|
||||
const existing = store.graph.getNode(nodeId)
|
||||
const props: Record<string, unknown> = {}
|
||||
|
||||
|
|
@ -415,6 +432,7 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function updateCursor(x: number, y: number, pageId: string) {
|
||||
const store = connectedStore ?? getStore()
|
||||
if (!awareness) return
|
||||
awareness.setLocalStateField('cursor', { x, y, pageId, zoom: store.state.zoom })
|
||||
}
|
||||
|
|
@ -425,6 +443,7 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function updatePeersList() {
|
||||
const store = connectedStore ?? getStore()
|
||||
if (!awareness) return
|
||||
const states = awareness.getStates()
|
||||
const peers: RemotePeer[] = []
|
||||
|
|
@ -492,6 +511,7 @@ export function useCollab(store: EditorStore) {
|
|||
}
|
||||
|
||||
function tickFollow() {
|
||||
const store = connectedStore ?? getStore()
|
||||
if (!followingPeer.value || !awareness) return
|
||||
const peerState = awareness.getStates().get(followingPeer.value)
|
||||
if (!peerState?.cursor) {
|
||||
|
|
|
|||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
|
|
@ -2,6 +2,8 @@
|
|||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
||||
|
||||
declare const __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: string | null
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
|
|
|
|||
|
|
@ -851,6 +851,27 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
return new Promise((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
|
||||
function setDocumentSource(
|
||||
fileName: string,
|
||||
sourceFormat: string,
|
||||
handle?: FileSystemFileHandle,
|
||||
path?: string
|
||||
) {
|
||||
stopWatchingFile()
|
||||
fileHandle = sourceFormat === 'fig' ? (handle ?? null) : null
|
||||
filePath = sourceFormat === 'fig' ? (path ?? null) : null
|
||||
downloadName = sourceFormat === 'fig' ? fileName : fileName.replace(/\.[^.]+$/i, '.fig')
|
||||
savedVersion = state.sceneVersion
|
||||
if (sourceFormat === 'fig' && (fileHandle || filePath)) {
|
||||
void startWatchingFile()
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stopWatchingFile()
|
||||
;(debouncedAutosave as typeof debouncedAutosave & { cancel?: () => void }).cancel?.()
|
||||
}
|
||||
|
||||
async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) {
|
||||
try {
|
||||
state.loading = true
|
||||
|
|
@ -859,16 +880,13 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
await yieldToUI()
|
||||
editor.replaceGraph(imported)
|
||||
editor.undo.clear()
|
||||
fileHandle = handle ?? null
|
||||
filePath = path ?? null
|
||||
state.documentName = file.name.replace(/\.fig$/i, '')
|
||||
downloadName = file.name
|
||||
setDocumentSource(file.name, 'fig', handle, path)
|
||||
state.selectedIds = new Set()
|
||||
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
|
||||
const pageId = firstPage?.id ?? editor.graph.rootId
|
||||
await editor.switchPage(pageId)
|
||||
editor.requestRender()
|
||||
void startWatchingFile()
|
||||
} catch (e) {
|
||||
console.error('Failed to open .fig file:', e)
|
||||
toast.show(`Failed to open file: ${e instanceof Error ? e.message : String(e)}`, 'error')
|
||||
|
|
@ -1235,6 +1253,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
openFigFile,
|
||||
saveFigFile,
|
||||
saveFigFileAs,
|
||||
setDocumentSource,
|
||||
dispose,
|
||||
renderExportImage,
|
||||
listSelectionExportFormats,
|
||||
exportTarget,
|
||||
|
|
|
|||
|
|
@ -64,11 +64,13 @@ export function closeTab(tabId: string) {
|
|||
const idx = tabsRef.value.findIndex((t) => t.id === tabId)
|
||||
if (idx === -1) return
|
||||
|
||||
const closingTab = tabsRef.value[idx]
|
||||
const wasActive = activeTabId.value === tabId
|
||||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
||||
|
||||
if (tabsRef.value.length === 0) {
|
||||
createTab()
|
||||
closingTab.store.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -76,18 +78,20 @@ export function closeTab(tabId: string) {
|
|||
const newIdx = Math.min(idx, tabsRef.value.length - 1)
|
||||
activateTab(tabsRef.value[newIdx])
|
||||
}
|
||||
|
||||
closingTab.store.dispose()
|
||||
}
|
||||
|
||||
export async function openFileInNewTab(
|
||||
file: File,
|
||||
_handle?: FileSystemFileHandle,
|
||||
_path?: string
|
||||
handle?: FileSystemFileHandle,
|
||||
path?: string
|
||||
): Promise<void> {
|
||||
const current = activeTab.value
|
||||
const isUntouched =
|
||||
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
const { graph: imported } = await io.readDocument({
|
||||
const { graph: imported, sourceFormat } = await io.readDocument({
|
||||
name: file.name,
|
||||
mimeType: file.type || undefined,
|
||||
data: bytes
|
||||
|
|
@ -98,6 +102,7 @@ export async function openFileInNewTab(
|
|||
current.store.replaceGraph(imported)
|
||||
current.store.undo.clear()
|
||||
current.store.state.documentName = documentName
|
||||
current.store.setDocumentSource(file.name, sourceFormat, handle, path)
|
||||
current.store.state.selectedIds = new Set()
|
||||
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
|
||||
await current.store.switchPage(pageId)
|
||||
|
|
@ -106,6 +111,7 @@ export async function openFileInNewTab(
|
|||
createTab(store)
|
||||
store.undo.clear()
|
||||
store.state.documentName = documentName
|
||||
store.setDocumentSource(file.name, sourceFormat, handle, path)
|
||||
store.state.selectedIds = new Set()
|
||||
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||
await store.switchPage(pageId)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ useHead({ title: route.meta.demo ? 'Demo' : undefined })
|
|||
useKeyboard()
|
||||
useMenu()
|
||||
|
||||
const collab = useCollab(firstTab.store)
|
||||
const collab = useCollab(getActiveStore)
|
||||
provide(COLLAB_KEY, collab)
|
||||
|
||||
useEventListener(
|
||||
|
|
@ -58,11 +58,12 @@ const automationCleanup = ref<(() => void) | null>(null)
|
|||
const mcpCleanup = ref<(() => void) | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (import.meta.env.DEV || IS_TAURI) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore).disconnect
|
||||
}
|
||||
try {
|
||||
mcpCleanup.value = await spawnMCPIfNeeded()
|
||||
const mcp = await spawnMCPIfNeeded()
|
||||
mcpCleanup.value = mcp?.disconnect ?? null
|
||||
if (import.meta.env.DEV || IS_TAURI) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomUUID } from 'crypto'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
|
|
@ -11,10 +12,13 @@ import { copyFileSync, existsSync, mkdirSync } from 'fs'
|
|||
|
||||
import { automationPlugin } from './src/automation/vite-plugin'
|
||||
|
||||
const devAutomationAuthToken = randomUUID()
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
const devAutomationCorsOrigin = host ? `http://${host}:1420` : 'http://localhost:1420'
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
export default defineConfig(async ({ command }) => ({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
|
|
@ -26,6 +30,11 @@ export default defineConfig(async () => ({
|
|||
'beautiful-mermaid': resolve(__dirname, 'src/shims/mermaid.ts')
|
||||
}
|
||||
},
|
||||
define: {
|
||||
__OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify(
|
||||
command === 'serve' ? devAutomationAuthToken : null
|
||||
)
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'copy-canvaskit-wasm',
|
||||
|
|
@ -55,7 +64,7 @@ export default defineConfig(async () => ({
|
|||
tailwindcss(),
|
||||
Icons({ compiler: 'vue3' }),
|
||||
Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }),
|
||||
automationPlugin(),
|
||||
automationPlugin(command === 'serve' ? devAutomationAuthToken : null, devAutomationCorsOrigin),
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
|
|
|
|||
Loading…
Reference in a new issue