A labelled Sync button sits top right on every Archify page, also in Source, and opens the Sync menu. On a shared model it turns green and reads Shared. The title-bar icon and the pre-share status bar entry are gone, with their settings.
221 lines
9.7 KiB
TypeScript
221 lines
9.7 KiB
TypeScript
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<typeof setTimeout> | 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 <kbd>Ctrl</kbd>+<kbd>Space</kbd> · 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 · <kbd>${MAC ? '⌘' : 'Ctrl+'}S</kbd> 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);
|