/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { AsyncClipboardStrategy, CommentModeController, CommentsModel, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, VsCodeV2CommentsView, commands, findNodeOffsetById, vscodeHostKeyboardProfile, vscodeLocalKeyboardProfile, type CodeBlockAstNode, type LinkPresentationKind } from '@vscode/markdown-editor'; import { VirtualizedIframeEmbeddedEditorFactory, type IframeEmbeddedEditorProvider, type IframeEmbeddedEditorProviderSelector, type ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; import { Disposable, autorun, observableValue } from '@vscode/observables'; import 'katex/dist/katex.min.css'; import '@vscode/markdown-editor/editor.css'; import '@vscode/markdown-editor/themes/vscode-default.css'; import '@vscode/markdown-editor/commentInput.css'; import '@vscode/markdown-editor/vscodeCommentWidgetV2.css'; import './markdownEditor.css'; import { WebviewSyntaxHighlighter } from './syntaxHighlighter'; import { WebviewLinkPresentationProvider } from './linkPresentationProvider'; interface VsCodeApi { postMessage(message: unknown): void; getState(): unknown; setState(state: unknown): void; } declare function acquireVsCodeApi(): VsCodeApi; /** * The editor's view state, persisted as webview state (`getState`/`setState`) so * the scroll and cursor position are restored when the webview is reloaded or the * custom editor is re-created (e.g. after switching sessions and back). */ interface PersistedViewState { scrollTop?: number; selection?: { anchor: number; active: number }; } interface CodeBlockEditorProviderDefinition { readonly id: string; readonly selector: IframeEmbeddedEditorProviderSelector; readonly source: { readonly kind: 'static'; readonly descriptor: ResolvedIframeEmbeddedEditor } | { readonly kind: 'exportApi' }; } interface InitialState { readonly content: string; readonly documentVersion: number; readonly readonly: boolean; readonly richLinksEnabled: boolean; readonly linkPresentationRules: readonly { id: string; source: string; flags: string; initialKind: LinkPresentationKind }[]; } class Editor extends Disposable { readonly model = new EditorModel(); isUpdatingFromExtension = false; #isUpdatingComments = false; #mermaidCounter = 0; #codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] = []; #nextCodeBlockEditorRequestId = 1; readonly #codeBlockEditorRequests = new Map void>(); #controller: EditorController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: VirtualizedIframeEmbeddedEditorFactory | undefined; readonly #comments = new CommentsModel(); #commentsView: VsCodeV2CommentsView | undefined; /** Whether the workbench feedback store currently accepts new comments for this resource. */ readonly #acceptsComments = observableValue('acceptsComments', false); // the message secret allows to distinguish vscode sending us a message vs a nested iframe readonly #messageSecret: string; readonly #vscode = acquireVsCodeApi(); readonly #syntaxHighlighter = new WebviewSyntaxHighlighter((message) => this.#vscode.postMessage(message)); readonly #linkPresentationProvider: WebviewLinkPresentationProvider | undefined; constructor(host: HTMLElement, initialState: InitialState) { super(); const messageSecret = document.querySelector('meta[name="vscode-markdown-editor-message-secret"]')?.content; if (!messageSecret) { throw new Error('Missing Markdown editor message secret'); } this.#messageSecret = messageSecret; this.#linkPresentationProvider = initialState.richLinksEnabled ? this._register(new WebviewLinkPresentationProvider( initialState.linkPresentationRules, message => this.#vscode.postMessage(message), )) : undefined; this.model.sourceText.set(new StringValue(initialState.content), undefined); this.model.readonlyMode.set(initialState.readonly, undefined); window.addEventListener('message', (event) => { const message = event.data; if (!message || typeof message !== 'object' || message.messageSecret !== this.#messageSecret) { return; } if (this.#syntaxHighlighter.handleMessage(message)) { return; } if (this.#linkPresentationProvider?.handleMessage(message)) { return; } switch (message.type) { case 'update': { // `replaceSourceText` (not `sourceText.set`) applies authoritative host // text: it maps the selection through the change and clears stale // pending-paragraph state, so the caret stays valid after an undo shrinks // the document. The guard stops this echoing back as a user edit. this.isUpdatingFromExtension = true; this.model.replaceSourceText(new StringValue(message.content)); this.isUpdatingFromExtension = false; break; } case 'codeBlockEditorProviders': { const providers = readCodeBlockEditorProviderDefinitions(message.codeBlockEditorProviders); this.#codeBlockEditorProviders = providers; this.#embeddedCodeEditorFactory?.updateProviders(this.#createIframeProviders(providers)); break; } case 'resolvedCodeBlockEditor': { if (typeof message.requestId !== 'number') { break; } const resolve = this.#codeBlockEditorRequests.get(message.requestId); if (resolve) { this.#codeBlockEditorRequests.delete(message.requestId); resolve(readResolvedCodeBlockEditor(message.descriptor)); } break; } case 'gutterMarkers': { const markers: GutterMarker[] = message.markers.map((marker: { start: number; endExclusive: number; type: GutterMarker['type'] }) => ({ range: OffsetRange.fromTo(marker.start, marker.endExclusive), type: marker.type, })); this.model.gutterMarkers.set(markers, undefined); break; } case 'comments': { this.#isUpdatingComments = true; this.#comments.set(message.comments.map((comment: { id: string; start: number; endExclusive: number; body: string; author?: string }) => ({ id: comment.id, range: OffsetRange.fromTo(comment.start, comment.endExclusive), body: comment.body, author: comment.author, }))); this.#isUpdatingComments = false; this.#acceptsComments.set(!!message.acceptsComments, undefined); break; } case 'revealComment': { this.#commentsView?.revealComment(message.id); break; } case 'command': { const command = commands.find(command => command.id === message.command); if (command) { this.#controller?.executeCommand(command); } break; } } }); this.#createView(host, initialState.content); this.#vscode.postMessage({ type: 'ready', documentVersion: initialState.documentVersion }); this._register({ dispose: () => { for (const resolve of this.#codeBlockEditorRequests.values()) { resolve(undefined); } this.#codeBlockEditorRequests.clear(); }, }); } #createView(host: HTMLElement, content: string): void { const model = this.model; const scriptNonce = document.querySelector('meta[name="vscode-markdown-editor-script-nonce"]')?.content; const embeddedCodeEditorFactory = this._register(new VirtualizedIframeEmbeddedEditorFactory({ providers: this.#createIframeProviders(this.#codeBlockEditorProviders), scriptNonce, themeCss: () => `:root { ${document.documentElement.getAttribute('style') ?? ''} }`, onAmbiguous: (language, providers) => this.#vscode.postMessage({ type: 'codeBlockEditorDiagnostic', message: `Ambiguous providers for ${language}: ${providers.map(provider => provider.id).join(', ')}`, }), onDidChange: () => this.#view?.refreshEmbeddedCodeEditors(), })); this.#embeddedCodeEditorFactory = embeddedCodeEditorFactory; // The scroll + cursor position last persisted for this document, captured // before any listener below can overwrite it, so it survives the editor being // re-created (e.g. after a session switch). const savedViewState = this.#getViewState(); const view = this._register(new EditorView(model, { classNames: ['md-theme-vscode-default'], syntaxHighlighter: this.#syntaxHighlighter, linkPresentationProvider: this.#linkPresentationProvider, embeddedCodeEditorFactory, onEmbeddedCodeEditorEdit: (block: CodeBlockAstNode, contentEdit: StringEdit) => { const doc = model.document.get(); const blockOffset = findNodeOffsetById(doc, block); if (blockOffset === undefined) { return; } const contentStart = blockOffset + block.codeOffset; model.applyEdit(new StringEdit( contentEdit.replacements.map(replacement => StringReplacement.replace( replacement.replaceRange.delta(contentStart), replacement.newText, )), )); }, onOpenLink: url => { this.#vscode.postMessage({ type: 'openLink', href: url }); }, onToggleCheckbox: (item, newChecked) => { model.setTaskCheckboxChecked(item, newChecked); }, renderCustomCodeBlock: (language, content) => { if (language !== 'mermaid') { return undefined; } const div = document.createElement('div'); div.className = 'md-mermaid'; div.textContent = content; div.setAttribute('aria-busy', 'true'); const id = `mermaid-${this.#mermaidCounter++}`; loadMermaid() .then(mermaid => mermaid.render(id, content)) .then(({ svg }) => { div.innerHTML = svg; div.setAttribute('aria-busy', 'false'); }) .catch(error => { div.textContent = content; div.setAttribute('aria-busy', 'false'); this.#vscode.postMessage({ type: 'codeBlockEditorDiagnostic', message: `Failed to render Mermaid diagram: ${error instanceof Error ? error.message : String(error)}`, }); }); return div; }, })); this.#view = view; // Wire history chords (undo/redo) to the extension so they run against the // backing TextDocument's own undo stack. `record` is deliberately omitted: // the TextDocument owns the history, and a second local stack would drift // from the Edit menu, dirty state and hot exit. this.#controller = this._register(new EditorController(model, view, { clipboardStrategy: new AsyncClipboardStrategy(), keyboardProfile: vscodeLocalKeyboardProfile, forwardedKeyboardProfile: vscodeHostKeyboardProfile, historyStrategy: { undo: () => this.#vscode.postMessage({ type: 'history', command: 'undo' }), redo: () => this.#vscode.postMessage({ type: 'history', command: 'redo' }), }, })); let lastEditorFocus: boolean | undefined; const postEditorFocus = (): void => { const focused = document.hasFocus() && document.activeElement === view.element; if (focused === lastEditorFocus) { return; } lastEditorFocus = focused; this.#vscode.postMessage({ type: 'editorFocusChanged', focused }); }; const onFocusOut = (): void => queueMicrotask(postEditorFocus); document.addEventListener('focusin', postEditorFocus); document.addEventListener('focusout', onFocusOut); window.addEventListener('focus', postEditorFocus); window.addEventListener('blur', postEditorFocus); this._register({ dispose: () => { document.removeEventListener('focusin', postEditorFocus); document.removeEventListener('focusout', onFocusOut); window.removeEventListener('focus', postEditorFocus); window.removeEventListener('blur', postEditorFocus); }, }); host.appendChild(view.element); postEditorFocus(); // Render comments as the VS Code V2 markdown cards. The card colours come // from the webview's own `--vscode-*` theme variables; `theme` only picks // the light/dark token wrapper. `resolveLine` maps a comment's start offset // to a 1-based line for the card header. const isLight = document.body.classList.contains('vscode-light'); this.#commentsView = this._register(new VsCodeV2CommentsView(this.#comments, view, { theme: isLight ? 'light' : 'dark', resolveLine: (offset) => model.sourceText.get().value.slice(0, offset).split('\n').length, })); // The comment input (the gdocs-style "add a comment" affordance) is only // useful when the workbench feedback store will actually accept the comment; // otherwise submitting is a no-op. Mount the controller only while the // resource is in scope for a session, and tear it down when it leaves scope. let commentController: CommentModeController | undefined; this._register(autorun((reader) => { const accepts = reader.readObservable(this.#acceptsComments); if (accepts && !commentController) { commentController = new CommentModeController(model, view, { onSubmit: ({ text, range }) => { this.#vscode.postMessage({ type: 'addComment', start: range.start, endExclusive: range.endExclusive, text }); }, }); } else if (!accepts && commentController) { commentController.dispose(); commentController = undefined; } })); this._register({ dispose: () => commentController?.dispose() }); // The comment card's delete button mutates the local CommentsModel // directly. Mirror those removals back to the extension so the shared // store (and the code editor) stay in sync. Removals coming from an // extension-driven update set `#isUpdatingComments`, so they are not // echoed back. let knownCommentIds = new Set(this.#comments.comments.get().map(comment => comment.id)); this._register(autorun((reader) => { const currentIds = new Set(reader.readObservable(this.#comments.comments).map(comment => comment.id)); if (!this.#isUpdatingComments) { for (const id of knownCommentIds) { if (!currentIds.has(id)) { this.#vscode.postMessage({ type: 'deleteComment', id }); } } } knownCommentIds = currentIds; })); if (savedViewState.selection) { const max = content.length; const anchor = Math.min(savedViewState.selection.anchor, max); const active = Math.min(savedViewState.selection.active, max); model.selection.set(new Selection(anchor, active), undefined); } // Persist scroll as webview state (throttled to a frame). Registered after the // restore above so it never clobbers the values we are about to restore. let scrollSaveScheduled = false; const saveScroll = (): void => { scrollSaveScheduled = false; this.#patchViewState({ scrollTop: host.scrollTop }); }; const onScroll = (): void => { if (scrollSaveScheduled) { return; } scrollSaveScheduled = true; requestAnimationFrame(saveScroll); }; host.addEventListener('scroll', onScroll, { passive: true }); this._register({ dispose: () => host.removeEventListener('scroll', onScroll) }); // Flush the latest scroll synchronously before the webview is hidden or torn // down, since the frame-throttled save above may not have run yet. const onHide = (): void => { if (document.visibilityState === 'hidden') { this.#patchViewState({ scrollTop: host.scrollTop }); } }; document.addEventListener('visibilitychange', onHide); window.addEventListener('pagehide', saveScroll); this._register({ dispose: () => { document.removeEventListener('visibilitychange', onHide); window.removeEventListener('pagehide', saveScroll); } }); // Persist the cursor whenever it moves. this._register(autorun((reader) => { const sel = reader.readObservable(this.model.selection); this.#patchViewState({ selection: sel ? { anchor: sel.anchor, active: sel.active } : undefined }); })); // Persist the edit/read-only mode as the global default whenever the lock // toggle flips it, so the next Markdown editor opens in the same mode. The // initial (restored) value is skipped so opening an editor doesn't re-write it. let firstReadonly = true; this._register(autorun((reader) => { const isReadonly = reader.readObservable(this.model.readonlyMode); if (!firstReadonly) { this.#vscode.postMessage({ type: 'setReadonly', readonly: isReadonly }); } firstReadonly = false; })); // Forward user edits to the extension. Edits are ignored by the model while // read-only, so this is a no-op in that mode; keeping it always registered // means unlocking a read-only editor immediately resumes edit forwarding. let previousText = this.model.sourceText.get().value; this._register(autorun((reader) => { const text = reader.readObservable(this.model.sourceText).value; if (!this.isUpdatingFromExtension && text !== previousText) { this.#vscode.postMessage({ type: 'edit', ...computeTextEdit(previousText, text) }); } previousText = text; })); // Restore scroll last: content height settles over a few frames (async parse, // syntax highlighting, mermaid), so re-apply until it sticks. // TODO@copilot: Consider using a more robust method for restoring scroll position, e.g. by waiting for the editor to stabilize this.#restoreScroll(host, savedViewState.scrollTop); } #createIframeProviders(definitions: readonly CodeBlockEditorProviderDefinition[]): readonly IframeEmbeddedEditorProvider[] { return definitions.map(definition => ({ id: definition.id, selector: definition.selector, resolve: definition.source.kind === 'static' ? async () => definition.source.kind === 'static' ? definition.source.descriptor : undefined : language => this.#resolveCodeBlockEditor(definition.id, language), })); } #resolveCodeBlockEditor(providerId: string, language: string): Promise { const requestId = this.#nextCodeBlockEditorRequestId++; return new Promise(resolve => { this.#codeBlockEditorRequests.set(requestId, resolve); this.#vscode.postMessage({ type: 'resolveCodeBlockEditor', requestId, providerId, language, }); }); } #getViewState(): PersistedViewState { return (this.#vscode.getState() as PersistedViewState | undefined) ?? {}; } #patchViewState(patch: PersistedViewState): void { this.#vscode.setState({ ...this.#getViewState(), ...patch }); } #restoreScroll(host: HTMLElement, scrollTop: number | undefined): void { if (typeof scrollTop !== 'number' || scrollTop <= 0) { return; } let tries = 0; const apply = (): void => { host.scrollTop = scrollTop; if (++tries < 6 && Math.abs(host.scrollTop - scrollTop) > 1) { requestAnimationFrame(apply); } }; requestAnimationFrame(apply); } } let mermaidPromise: Promise<(typeof import('mermaid'))['default']> | undefined; function loadMermaid(): Promise<(typeof import('mermaid'))['default']> { if (!mermaidPromise) { mermaidPromise = import('mermaid').then(module => { module.default.initialize({ startOnLoad: false, theme: 'default' }); return module.default; }); } return mermaidPromise; } function readInitialState(): InitialState { const element = document.getElementById('vscode-markdown-editor-initial-state'); if (!(element instanceof HTMLMetaElement)) { throw new Error('Markdown editor initial state was not found.'); } element.remove(); const value: unknown = JSON.parse(decodeURIComponent(element.content)); if (!isInitialState(value)) { throw new Error('Markdown editor initial state is invalid.'); } return value; } function isInitialState(value: unknown): value is InitialState { if (!value || typeof value !== 'object') { return false; } const candidate = value as Record; return typeof candidate.content === 'string' && typeof candidate.documentVersion === 'number' && typeof candidate.readonly === 'boolean' && typeof candidate.richLinksEnabled === 'boolean' && Array.isArray(candidate.linkPresentationRules); } function readCodeBlockEditorProviderDefinitions(value: unknown): readonly CodeBlockEditorProviderDefinition[] { if (!Array.isArray(value)) { return []; } const values: unknown[] = value; return values.filter(isCodeBlockEditorProviderDefinition); } function isCodeBlockEditorProviderDefinition(value: unknown): value is CodeBlockEditorProviderDefinition { if (typeof value !== 'object' || value === null) { return false; } const candidate = value as Record; return typeof candidate.id === 'string' && isCodeBlockEditorSelector(candidate.selector) && isCodeBlockEditorSource(candidate.source); } function isCodeBlockEditorSelector(value: unknown): value is IframeEmbeddedEditorProviderSelector { if (!value || typeof value !== 'object') { return false; } const selector = value as Record; return (typeof selector.language === 'string' && selector.languagePrefix === undefined) || (selector.language === undefined && typeof selector.languagePrefix === 'string'); } function isCodeBlockEditorSource(value: unknown): value is CodeBlockEditorProviderDefinition['source'] { if (!value || typeof value !== 'object') { return false; } const source = value as Record; return source.kind === 'exportApi' || (source.kind === 'static' && readResolvedCodeBlockEditor(source.descriptor) !== undefined); } function readResolvedCodeBlockEditor(value: unknown): ResolvedIframeEmbeddedEditor | undefined { if (!value || typeof value !== 'object') { return undefined; } const descriptor = value as Record; if ( typeof descriptor.html !== 'string' || (descriptor.contentType !== 'text' && descriptor.contentType !== 'json') || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') || (descriptor.initialHeight !== undefined && (typeof descriptor.initialHeight !== 'number' || !Number.isFinite(descriptor.initialHeight) || descriptor.initialHeight <= 0)) || !isResolvedSandbox(descriptor.sandbox) ) { return undefined; } return descriptor as unknown as ResolvedIframeEmbeddedEditor; } function isResolvedSandbox(value: unknown): boolean { if (value === undefined) { return true; } if (!value || typeof value !== 'object') { return false; } const sandbox = value as Record; return ['forms', 'downloads', 'pointerLock', 'clipboardWrite'] .every(key => sandbox[key] === undefined || typeof sandbox[key] === 'boolean'); } function computeTextEdit(previousText: string, text: string): { start: number; endExclusive: number; text: string } { let start = 0; while (start < previousText.length && start < text.length && previousText.charCodeAt(start) === text.charCodeAt(start)) { start++; } let previousEnd = previousText.length; let end = text.length; while (previousEnd > start && end > start && previousText.charCodeAt(previousEnd - 1) === text.charCodeAt(end - 1)) { previousEnd--; end--; } return { start, endExclusive: previousEnd, text: text.slice(start, end), }; } new Editor(document.getElementById('editor')!, readInitialState());