clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / src / state.js
state.js 3.3K
1import { deflate, inflate } from 'pako'2import { APPEARANCES } from './client-svg.js'34// Leave room below Cloudflare's 16 KB request URL ceiling.5export const MAX_IMAGE_URL_LENGTH = 15_0006export const DEFAULT_DIAGRAM_TYPE = 'plantuml'7export const DEFAULT_SOURCE = `@startuml8skinparam monochrome true9skinparam shadowing false1011actor Alice12participant "Alice Agent" as AliceAgent13participant "Tandem App" as Tandem14participant "Bob Agent" as BobAgent15actor Bob1617Alice -> AliceAgent: Describe a shared to-do app18AliceAgent -> Tandem: Draft the task flow19Bob -> BobAgent: Build and test the API20BobAgent -> Tandem: Ship task sync21Alice -> Tandem: Add "Buy paint"22Tandem -> Bob: Share the new task23Bob -> Tandem: Mark it done24Tandem --> Alice: Everyone is in sync25@enduml`2627const encoder = new TextEncoder()28const decoder = new TextDecoder()2930function toBase64Url(bytes) {31 let binary = ''32 const chunkSize = 0x800033 for (let index = 0; index < bytes.length; index += chunkSize) {34 binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))35 }36 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')37}3839function fromBase64Url(value) {40 const normalized = value.replaceAll('-', '+').replaceAll('_', '/')41 const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)42 const binary = atob(padded)43 return Uint8Array.from(binary, character => character.charCodeAt(0))44}4546export function encodeText(value) {47 return toBase64Url(deflate(encoder.encode(value), { level: 9 }))48}4950export function decodeText(value) {51 return decoder.decode(inflate(fromBase64Url(value)))52}5354export function normalizeMetadata(meta = {}) {55 if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {56 throw new Error('Invalid diagram metadata.')57 }58 const title = meta.title ?? ''59 const description = meta.description ?? ''60 if (typeof title !== 'string' || typeof description !== 'string') {61 throw new Error('Invalid diagram metadata.')62 }63 return { title, description }64}6566export function documentTitle(title) {67 return `${title.trim() || 'untitled'}.zip`68}6970export function normalizePresentation(presentation = {}) {71 if (typeof presentation !== 'object' || presentation === null || Array.isArray(presentation)) {72 throw new Error('Invalid diagram presentation.')73 }74 const background = presentation.background ?? ''75 const padding = presentation.padding ?? 076 const frame = presentation.frame ?? false77 const appearance = presentation.appearance ?? 'raw'78 if (typeof background !== 'string' || (background && !/^#[0-9a-f]{6}$/i.test(background))) {79 throw new Error('Invalid diagram presentation.')80 }81 if (!Number.isInteger(padding) || padding < 0 || padding > 256 || typeof frame !== 'boolean' || !APPEARANCES.includes(appearance)) {82 throw new Error('Invalid diagram presentation.')83 }84 return { background, padding, frame, appearance }85}8687export function packedSvgUrl(origin, source) {88 if (typeof source !== 'string' || !source.startsWith('<svg')) throw new Error('Packed SVG must be a complete SVG document.')89 const url = new URL(`/svg/${encodeText(source)}`, origin).toString()90 if (url.length > MAX_IMAGE_URL_LENGTH) {91 throw new Error('This diagram is too large for a packed SVG URL. Save it as a file or publish it instead.')92 }93 return url94}