import type { ArchifyHost } from '../host'; import { TextChange, minimalChange } from '../../util/textDiff'; import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor'; /** * The Source view of an Archify page: the file's YAML in an editor, in the * page under its toolbar and heading, in place of the picture. The bridge * script (page.ts) loads this file the first time Source is chosen and calls * window.__archifySource.open(); the document itself stays in the host, which * applies every edit to it and sends back problems and outside changes. */ /** Host → page. */ type HostMessage = | { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string } | { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number } | { type: 'sourceSchema'; schema: { key: string; schema: object } | null } | { type: 'sourceDirty'; dirty: boolean } | { type: 'sourceCursors'; cursors: RemoteCursor[] }; const STYLE = ` html.archify-source-on .container > :not(.header):not(#archify-source), html.archify-source-on body > :not(.toolbar):not(.container):not(.archify-model-head):not(.archify-model-switch-row):not(#archify-source):not(#archify-sync-button):not(script):not(style):not(.archify-modal-backdrop), html.archify-source-on #btn-3d, html.archify-source-on .archify-vscode-notice { display: none !important; } html:not(.archify-source-on) #archify-source { display: none !important; } .archify-source { display: flex; flex-direction: column; box-sizing: border-box; min-height: 260px; margin: 0.75rem 0 1rem; } body > .archify-source { max-width: 1400px; margin: 0.75rem auto 1rem; padding: 0 2rem; } .archify-source-editor { flex: 1; min-height: 0; overflow: hidden; border: 1px solid var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); border-radius: 0.75rem; background: color-mix(in srgb, var(--panel, var(--vscode-editor-background)) 70%, transparent); } .archify-source-editor .cm-editor { height: 100%; } .archify-source-strip { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; align-items: center; padding: 0.45rem 0.25rem 0; font-size: 0.75rem; color: var(--text-muted, var(--vscode-descriptionForeground)); font-family: var(--vscode-font-family, system-ui, sans-serif); } .archify-source-strip .is-ok { color: var(--backend-stroke, var(--vscode-testing-iconPassed, #34d399)); } .archify-source-strip .is-bad { color: var(--security-stroke, var(--vscode-editorError-foreground, #fb7185)); } .archify-source-strip button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } .archify-source-strip button:hover { text-decoration: underline; } .archify-source-strip .archify-source-hint { margin-left: auto; } .archify-source-strip kbd { font: inherit; font-size: 0.6875rem; padding: 0 0.3rem; border: 1px solid currentColor; border-radius: 0.25rem; opacity: 0.8; } `; const MAC = /Mac|iPhone|iPad/.test(navigator.platform); function isUndoRedo(event: KeyboardEvent): boolean { const key = event.key.toLowerCase(); return (event.metaKey || event.ctrlKey) && !event.altKey && (key === 'z' || key === 'y'); } class SourcePane { private root: HTMLElement | undefined; private editorHost!: HTMLElement; private stateEl!: HTMLElement; private countEl!: HTMLButtonElement; private checksEl!: HTMLElement; private editor: SourceEditor | undefined; /** The text the host last knew the page to have; edits are sent against it. */ private text = ''; private diagnostics: SourceDiagnostic[] = []; private cursors: RemoteCursor[] = []; private shown = false; private selectionTimer: ReturnType | undefined; private selection: { anchor: number; head: number } | undefined; constructor(private readonly host: ArchifyHost) { window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage)); window.addEventListener('resize', () => this.fit()); } open(): void { this.build(); this.shown = true; document.documentElement.classList.add('archify-source-on'); window.dispatchEvent(new CustomEvent('archify-source', { detail: { on: true } })); this.fit(); this.host.post({ type: 'sourceOpen' }); this.editor?.focus(); } close(): void { this.shown = false; document.documentElement.classList.remove('archify-source-on'); window.dispatchEvent(new CustomEvent('archify-source', { detail: { on: false } })); this.host.post({ type: 'sourceClose' }); } private build(): void { if (this.root) return; const style = document.createElement('style'); style.textContent = STYLE; document.head.appendChild(style); const root = document.createElement('section'); root.id = 'archify-source'; root.className = 'archify-source'; root.setAttribute('aria-label', 'Source'); this.editorHost = document.createElement('div'); this.editorHost.className = 'archify-source-editor'; const strip = document.createElement('div'); strip.className = 'archify-source-strip'; this.stateEl = document.createElement('span'); this.countEl = document.createElement('button'); this.countEl.type = 'button'; this.countEl.title = 'Go to the next problem'; this.countEl.addEventListener('click', () => this.editor?.nextProblem()); this.checksEl = document.createElement('span'); const hint = document.createElement('span'); hint.className = 'archify-source-hint'; hint.innerHTML = 'Completion as you type, or Ctrl+Space · hover a key for its description'; strip.append(this.stateEl, this.countEl, this.checksEl, hint); root.append(this.editorHost, strip); this.renderState(false); this.renderCount(); // Archify's page has single-key shortcuts; typing here must not reach // them. Keys with ⌘ or Ctrl go on to VS Code (save, find, quick open), // except undo and redo, which the editor has handled already. for (const type of ['keydown', 'keypress', 'keyup'] as const) { root.addEventListener(type, (event) => { if (!event.metaKey && !event.ctrlKey) event.stopPropagation(); else if (isUndoRedo(event)) event.stopPropagation(); }); } const header = document.querySelector('.container > .header'); const modelHead = document.querySelector('.archify-model-head'); if (header?.parentElement) header.parentElement.insertBefore(root, header.nextSibling); else if (modelHead?.parentElement) modelHead.parentElement.insertBefore(root, modelHead.nextSibling); else document.body.appendChild(root); this.root = root; } /** The editor fills the window below where it starts. */ private fit(): void { if (!this.root || !this.shown) return; const top = this.root.getBoundingClientRect().top; this.root.style.height = `${Math.max(260, window.innerHeight - Math.max(top, 0) - 12)}px`; this.editor?.requestMeasure(); } private onMessage(message: HostMessage): void { if (!message || typeof message !== 'object') return; switch (message.type) { case 'sourceText': this.build(); if (!this.editor) { this.text = message.text; this.editor = new SourceEditor( this.editorHost, message.text, (changes, text) => this.onEdit(changes, text), (anchor, head) => this.onSelection(anchor, head), ); this.editor.setDiagnostics(this.diagnostics); this.editor.setRemoteCursors(this.cursors); this.fit(); if (this.shown) this.editor.focus(); } else { const change = minimalChange(this.editor.text, message.text); if (change) this.editor.applyFromHost(change); this.text = message.text; } if (message.schema !== undefined) this.editor.setSchema(message.schema?.key, message.schema?.schema); this.checksEl.textContent = message.checks; return; case 'sourceDiagnostics': this.diagnostics = message.items; this.editor?.setDiagnostics(message.items); this.renderCount(); return; case 'sourceSchema': this.editor?.setSchema(message.schema?.key, message.schema?.schema); return; case 'sourceDirty': this.renderState(message.dirty); return; case 'sourceCursors': this.cursors = message.cursors; this.editor?.setRemoteCursors(message.cursors); return; } } /** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */ private onSelection(anchor: number, head: number): void { this.selection = { anchor, head }; if (this.selectionTimer) return; this.selectionTimer = setTimeout(() => { this.selectionTimer = undefined; if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection }); }, 80); } private onEdit(changes: TextChange[], text: string): void { const base = this.text; this.text = text; this.host.post({ type: 'sourceEdit', base, text, changes }); } private renderState(dirty: boolean): void { if (!this.stateEl) return; this.stateEl.innerHTML = dirty ? `● Unsaved · ${MAC ? '⌘' : 'Ctrl+'}S saves` : 'Saved'; } private renderCount(): void { if (!this.countEl) return; const problems = this.diagnostics.filter((d) => d.severity !== 'info').length; this.countEl.className = problems ? 'is-bad' : 'is-ok'; this.countEl.textContent = problems ? `✕ ${problems} problem${problems === 1 ? '' : 's'}` : '✓ No problems'; this.countEl.disabled = !problems; } } declare global { interface Window { __archifySource?: { open(): void; close(): void }; } } const host = window.__archifyHost; if (host) window.__archifySource = new SourcePane(host);