From d1ad374c2c9ba83467a485a7f070c38f70c52b59 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 15 Oct 2026 12:05:43 +0200 Subject: [PATCH 1/2] Support presentation preferences for Markdown embedded editors Forward validated resizable, full-width and full-view preferences from static contributions and exported providers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CONTRIBUTING.md | 14 +++++++++++++ .../schemas/package.schema.json | 11 ++++++++++ .../codeBlockEditorPresentation.test.mts | 20 +++++++++++++++++++ .../src/markdownExtensions.ts | 11 ++++++++++ .../src/preview/markdownEditorProtocol.ts | 7 +++++++ .../src/preview/markdownEditorProvider.ts | 17 +++++++++++----- .../src/test/markdownEditorProvider.test.ts | 12 ++++++++++- 7 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts diff --git a/extensions/markdown-language-features/CONTRIBUTING.md b/extensions/markdown-language-features/CONTRIBUTING.md index 1b62674b50af1f..bf4bb3471423cb 100644 --- a/extensions/markdown-language-features/CONTRIBUTING.md +++ b/extensions/markdown-language-features/CONTRIBUTING.md @@ -80,6 +80,20 @@ Build outputs are written to `out/` (desktop), `dist/` (web), and `notebook-out/ Markdown editor commands and their default keybindings are defined in `vscode-packages/vscode-team-tools/packages/markdown-editor/src/editorCommands.ts`. Do not manually edit entries marked with `"$generated": true` in this extension's `package.json` or their titles in `package.nls.json`: `npm run build-markdown-editor` and `npm run watch-markdown-editor` regenerate them while preserving manual entries. Run `npm run check-markdown-editor-package-json` to verify that the checked-in manifests are current without modifying them. +### Embedded code-block editor presentation + +`markdown.codeBlockEditorProviders` contributions and export-API resolve results +accept optional `presentation: { resizable, fullWidth, fullView, minHeight }`. +The Markdown editor owns these controls, including for static/uncooperative +pages. Resolve-result properties override contribution defaults; explicit +provider properties override guest-advertised preferences. Keep the contribution +schema, descriptor validation, RPC schema, equality checks, and static/export-API +paths in sync when extending this contract. + +The implementation and interaction fixtures live in the local Markdown editor +package. See its README's **Embedded editor presentation** section for the +optional mode-notification messages and sizing precedence. + ### Active block highlighting `markdown.editor.highlightActiveBlock` controls the rich editor's active-block diff --git a/extensions/markdown-language-features/schemas/package.schema.json b/extensions/markdown-language-features/schemas/package.schema.json index dc8f89cb2bb577..a3f10c689d40f5 100644 --- a/extensions/markdown-language-features/schemas/package.schema.json +++ b/extensions/markdown-language-features/schemas/package.schema.json @@ -178,6 +178,17 @@ "exclusiveMinimum": 0, "description": "Initial placeholder height before the editor reports its measured height" }, + "presentation": { + "type": "object", + "additionalProperties": false, + "description": "Host-owned Markdown editor presentation controls. Supported even when the embedded page does not implement the editor protocol.", + "properties": { + "resizable": { "type": "boolean", "description": "Allow users to resize the editor height." }, + "fullWidth": { "type": "boolean", "description": "Offer a toggle to use the available document width." }, + "fullView": { "type": "boolean", "description": "Offer a toggle to fill the Markdown editor viewport." }, + "minHeight": { "type": "number", "exclusiveMinimum": 0, "maximum": 10000, "description": "Minimum inline content height in CSS pixels." } + } + }, "sandbox": { "type": "object", "additionalProperties": false, diff --git a/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts new file mode 100644 index 00000000000000..6c19feea2881d4 --- /dev/null +++ b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { codeBlockEditorPresentation } from '../src/preview/markdownEditorProtocol.ts'; + +test('presentation RPC schema preserves explicit false and optional preferences', () => { + const options = { resizable: true, fullWidth: false, fullView: true, minHeight: 120 }; + assert.deepEqual(codeBlockEditorPresentation.parse(options), options); + assert.deepEqual(codeBlockEditorPresentation.parse({}), {}); +}); + +test('presentation RPC schema rejects invalid preferences and heights', () => { + for (const value of [null, { fullView: 'true' }, { minHeight: 0 }, { minHeight: -1 }, { minHeight: Infinity }, { minHeight: NaN }, { minHeight: 10_001 }]) { + assert.equal(codeBlockEditorPresentation.safeParse(value).success, false); + } +}); diff --git a/extensions/markdown-language-features/src/markdownExtensions.ts b/extensions/markdown-language-features/src/markdownExtensions.ts index d24b9108a042fe..a4f74719b22301 100644 --- a/extensions/markdown-language-features/src/markdownExtensions.ts +++ b/extensions/markdown-language-features/src/markdownExtensions.ts @@ -23,6 +23,13 @@ function* resolveExtensionResources(extension: vscode.Extension, resourcePa } } +import { codeBlockEditorPresentation, type ResolvedCodeBlockEditor } from './preview/markdownEditorProtocol'; + +export function codeBlockEditorPresentationEqual(a: ResolvedCodeBlockEditor['presentation'], b: ResolvedCodeBlockEditor['presentation']): boolean { + return a?.resizable === b?.resizable && a?.fullWidth === b?.fullWidth + && a?.fullView === b?.fullView && a?.minHeight === b?.minHeight; +} + export interface MarkdownPreviewScript { readonly resource: vscode.Uri; readonly type?: 'module'; @@ -53,6 +60,7 @@ export interface MarkdownCodeBlockEditorProvider { readonly source: MarkdownCodeBlockEditorSource; readonly contentType: 'text' | 'json'; readonly initialHeight?: number; + readonly presentation?: ResolvedCodeBlockEditor['presentation']; readonly sandbox?: MarkdownCodeBlockEditorSandbox; } @@ -106,6 +114,7 @@ export namespace MarkdownContributions { && sourceEqual(x.source, y.source) && x.contentType === y.contentType && x.initialHeight === y.initialHeight + && codeBlockEditorPresentationEqual(x.presentation, y.presentation) && sandboxEqual(x.sandbox, y.sandbox)); } @@ -184,6 +193,7 @@ export namespace MarkdownContributions { || (provider.runtimeKey !== undefined && (typeof provider.runtimeKey !== 'string' || provider.runtimeKey.length === 0 || provider.runtimeKey.length > 256)) || (provider.contentType !== undefined && provider.contentType !== 'text' && provider.contentType !== 'json') || (provider.initialHeight !== undefined && !isPositiveNumber(provider.initialHeight)) + || (provider.presentation !== undefined && !codeBlockEditorPresentation.safeParse(provider.presentation).success) ) { continue; } @@ -197,6 +207,7 @@ export namespace MarkdownContributions { source, contentType: provider.contentType ?? 'text', initialHeight: provider.initialHeight as number | undefined, + presentation: provider.presentation === undefined ? undefined : codeBlockEditorPresentation.parse(provider.presentation), sandbox: readSandbox(provider.sandbox), }; } diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts index 1700c2b0ff24b1..4a8d9b423e197b 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -37,6 +37,12 @@ const sandbox = z.object({ pointerLock: z.optional(z.boolean()), clipboardWrite: z.optional(z.boolean()), }); +export const codeBlockEditorPresentation = z.object({ + resizable: z.optional(z.boolean()), + fullWidth: z.optional(z.boolean()), + fullView: z.optional(z.boolean()), + minHeight: z.optional(z.number().check(z.positive(), z.maximum(10_000))), +}); const resolvedCodeBlockEditor = z.object({ cacheKey: z.optional(z.string()), html: z.string(), @@ -45,6 +51,7 @@ const resolvedCodeBlockEditor = z.object({ hostTransport: z.optional(z.boolean()), contentType: z.enum(['text', 'json']), initialHeight: z.optional(z.number().check(z.positive())), + presentation: z.optional(codeBlockEditorPresentation), sandbox: z.optional(sandbox), }); const codeBlockEditorProvider = z.object({ diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index d63134b49dbd3b..b5687e9cd06149 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -14,14 +14,15 @@ import { RecoveringTaskQueue } from './recoveringTaskQueue'; import { encodeWebviewInitialState } from './webviewInitialState'; import type { ILogger } from '../logging'; import type { ResolvedDocumentLinkTarget } from '../client/protocol'; -import type { - MarkdownCodeBlockEditorProvider, - MarkdownCodeBlockEditorSandbox, - MarkdownContributionProvider, +import { + codeBlockEditorPresentationEqual, + type MarkdownCodeBlockEditorProvider, + type MarkdownCodeBlockEditorSandbox, + type MarkdownContributionProvider, } from '../markdownExtensions'; import { generateUuid } from '../util/uuid'; import { MarkdownEditorRichLinkController } from './markdownEditorRichLinks'; -import { markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; +import { codeBlockEditorPresentation, markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from './markdownEditorRpc'; import { MarkdownEditorRename } from './markdownEditorRename'; import { MarkdownEditorLanguageFeatures } from './markdownEditorLanguageFeatures'; @@ -59,6 +60,7 @@ interface ProviderResolvedCodeBlockEditor { readonly cacheKey?: string; readonly runtimeKey?: string; readonly initialHeight?: number; + readonly presentation?: ResolvedCodeBlockEditor['presentation']; readonly sandbox?: MarkdownCodeBlockEditorSandbox; } @@ -801,6 +803,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT resourceBaseUrl: getCodeBlockEditorResourceBaseUrl(webview, provider.source.resource), contentType: provider.contentType, initialHeight: provider.initialHeight, + presentation: provider.presentation, sandbox: provider.sandbox, }, }, @@ -963,6 +966,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT hostTransport: hasHostTransport && contribution.source.kind === 'exportApi' && contribution.source.apiVersion >= 2, contentType: value.contentType ?? contribution.contentType, initialHeight: value.initialHeight ?? contribution.initialHeight, + presentation: contribution.presentation || value.presentation + ? { ...contribution.presentation, ...value.presentation } : undefined, sandbox: intersectSandbox(contribution.sandbox, value.sandbox), }; } @@ -1199,6 +1204,7 @@ function resolvedCodeBlockEditorsEqual(a: ResolvedCodeBlockEditor, b: ResolvedCo && a.hostTransport === b.hostTransport && a.contentType === b.contentType && a.initialHeight === b.initialHeight + && codeBlockEditorPresentationEqual(a.presentation, b.presentation) && a.sandbox?.forms === b.sandbox?.forms && a.sandbox?.downloads === b.sandbox?.downloads && a.sandbox?.pointerLock === b.sandbox?.pointerLock @@ -1254,6 +1260,7 @@ function isProviderResolvedCodeBlockEditor(value: unknown): value is ProviderRes || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') || (descriptor.runtimeKey !== undefined && (typeof descriptor.runtimeKey !== 'string' || descriptor.runtimeKey.length === 0 || descriptor.runtimeKey.length > 256)) || (descriptor.initialHeight !== undefined && (!Number.isFinite(descriptor.initialHeight) || (descriptor.initialHeight as number) <= 0)) + || (descriptor.presentation !== undefined && !codeBlockEditorPresentation.safeParse(descriptor.presentation).success) || !isSandbox(descriptor.sandbox) || !descriptor.content || typeof descriptor.content !== 'object' diff --git a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts index fd4a2ea248aa45..c8a7cc8135b5ce 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts @@ -314,9 +314,18 @@ suite('Markdown code block editor API versioning', () => { 'shared-runtime', )[0]?.runtimeKey, 'shared-runtime'); }); + + test('reads and validates presentation preferences', () => { + const source = { kind: 'exportApi', apiVersion: 2 }; + const presentation = { resizable: true, fullWidth: false, fullView: true, minHeight: 160 }; + assert.deepStrictEqual(readCodeBlockEditorProviders(source, undefined, presentation)[0]?.presentation, presentation); + for (const invalid of [{ resizable: 'yes' }, { minHeight: 0 }, { minHeight: Infinity }, { minHeight: 10_001 }]) { + assert.strictEqual(readCodeBlockEditorProviders(source, undefined, invalid).length, 0); + } + }); }); -function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) { +function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string, presentation?: unknown) { const extension = { id: 'test.markdown-code-block-editor', extensionUri: vscode.Uri.file('/test/markdown-code-block-editor'), @@ -328,6 +337,7 @@ function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) { selector: { language: 'test' }, source, runtimeKey, + presentation, }], }, }, From b96bd00718ef5304e81a3d07b355e35d4fceeca6 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 15 Oct 2026 17:01:50 +0200 Subject: [PATCH 2/2] Consume published Markdown editor presentation controls Preserve contributed presentation defaults when providers return undefined optional fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CONTRIBUTING.md | 2 ++ .../package-lock.json | 8 ++--- .../markdown-language-features/package.json | 2 +- .../codeBlockEditorPresentation.test.mts | 33 ++++++++++++++++++- .../src/preview/markdownEditorProtocol.ts | 19 +++++++++++ .../src/preview/markdownEditorProvider.ts | 5 ++- 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/extensions/markdown-language-features/CONTRIBUTING.md b/extensions/markdown-language-features/CONTRIBUTING.md index bf4bb3471423cb..b144ff6cf80a3f 100644 --- a/extensions/markdown-language-features/CONTRIBUTING.md +++ b/extensions/markdown-language-features/CONTRIBUTING.md @@ -90,6 +90,8 @@ provider properties override guest-advertised preferences. Keep the contribution schema, descriptor validation, RPC schema, equality checks, and static/export-API paths in sync when extending this contract. +Undefined resolve-result properties inherit the contribution defaults; explicit `false` still overrides them. The extension consumes the published `@vscode/markdown-editor` package, which implements the controls and mode notifications. + The implementation and interaction fixtures live in the local Markdown editor package. See its README's **Embedded editor presentation** section for the optional mode-notification messages and sizing precedence. diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index f4afd9cec5e07f..4928ea624983e4 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-121", + "@vscode/markdown-editor": "0.0.2-130", "@vscode/observables": "^0.1.1-0", "@vscode/web-editors": "^0.0.2-41", "dompurify": "^3.4.10", @@ -670,9 +670,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-121", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-121.tgz", - "integrity": "sha512-PkBZf2cGqutKE+t1UThpoRSomyvqHwsiXiCtoG+0/YDTbH6n4WulkIq8fE8jNmyJQtSHJihwJaIwVaeMBOmMXQ==", + "version": "0.0.2-130", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-130.tgz", + "integrity": "sha512-pqDLMtjxLSfT38Olu0UDnMPMTTF8ZSotjIMXjj8ESMJ0qmc5I/QGvbcjwMEwA4afJP1TG0gfBqo8DNUuKNgBlw==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 78b09f67720b97..8d8920c5f8e79c 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1991,7 +1991,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-121", + "@vscode/markdown-editor": "0.0.2-130", "@vscode/observables": "^0.1.1-0", "@vscode/web-editors": "^0.0.2-41", "dompurify": "^3.4.10", diff --git a/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts index 6c19feea2881d4..a86e5b363765b3 100644 --- a/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts +++ b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { codeBlockEditorPresentation } from '../src/preview/markdownEditorProtocol.ts'; +import { codeBlockEditorPresentation, mergeCodeBlockEditorPresentation } from '../src/preview/markdownEditorProtocol.ts'; test('presentation RPC schema preserves explicit false and optional preferences', () => { const options = { resizable: true, fullWidth: false, fullView: true, minHeight: 120 }; @@ -18,3 +18,34 @@ test('presentation RPC schema rejects invalid preferences and heights', () => { assert.equal(codeBlockEditorPresentation.safeParse(value).success, false); } }); + +test('undefined resolved preferences inherit defaults while explicit false overrides them', () => { + const contributed = { resizable: true, fullWidth: false, fullView: true, minHeight: 160 }; + assert.deepEqual(mergeCodeBlockEditorPresentation(contributed, { + resizable: false, + fullWidth: undefined, + fullView: false, + minHeight: undefined, + }), { resizable: false, fullWidth: false, fullView: false, minHeight: 160 }); + assert.deepEqual(mergeCodeBlockEditorPresentation(contributed, { + resizable: undefined, + fullWidth: true, + fullView: undefined, + minHeight: 240, + }), { resizable: true, fullWidth: true, fullView: true, minHeight: 240 }); +}); + +test('missing presentation preference objects preserve the available preferences', () => { + const preferences = { fullView: false, minHeight: 160 }; + assert.deepEqual({ + neither: mergeCodeBlockEditorPresentation(undefined, undefined), + contributed: mergeCodeBlockEditorPresentation(preferences, undefined), + resolved: mergeCodeBlockEditorPresentation(undefined, preferences), + empty: mergeCodeBlockEditorPresentation({}, {}), + }, { + neither: undefined, + contributed: preferences, + resolved: preferences, + empty: { resizable: undefined, fullWidth: undefined, fullView: undefined, minHeight: undefined }, + }); +}); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts index 4a8d9b423e197b..3b5aeda54cca78 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -43,6 +43,25 @@ export const codeBlockEditorPresentation = z.object({ fullView: z.optional(z.boolean()), minHeight: z.optional(z.number().check(z.positive(), z.maximum(10_000))), }); + +export function mergeCodeBlockEditorPresentation( + contributed: ResolvedCodeBlockEditor['presentation'], + resolved: ResolvedCodeBlockEditor['presentation'], +): ResolvedCodeBlockEditor['presentation'] { + if (!contributed) { + return resolved; + } + if (!resolved) { + return contributed; + } + return { + resizable: resolved.resizable ?? contributed.resizable, + fullWidth: resolved.fullWidth ?? contributed.fullWidth, + fullView: resolved.fullView ?? contributed.fullView, + minHeight: resolved.minHeight ?? contributed.minHeight, + }; +} + const resolvedCodeBlockEditor = z.object({ cacheKey: z.optional(z.string()), html: z.string(), diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index b5687e9cd06149..6513b291c5b2c8 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -22,7 +22,7 @@ import { } from '../markdownExtensions'; import { generateUuid } from '../util/uuid'; import { MarkdownEditorRichLinkController } from './markdownEditorRichLinks'; -import { codeBlockEditorPresentation, markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; +import { codeBlockEditorPresentation, markdownEditorHost, markdownEditorRenderer, mergeCodeBlockEditorPresentation, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from './markdownEditorRpc'; import { MarkdownEditorRename } from './markdownEditorRename'; import { MarkdownEditorLanguageFeatures } from './markdownEditorLanguageFeatures'; @@ -966,8 +966,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT hostTransport: hasHostTransport && contribution.source.kind === 'exportApi' && contribution.source.apiVersion >= 2, contentType: value.contentType ?? contribution.contentType, initialHeight: value.initialHeight ?? contribution.initialHeight, - presentation: contribution.presentation || value.presentation - ? { ...contribution.presentation, ...value.presentation } : undefined, + presentation: mergeCodeBlockEditorPresentation(contribution.presentation, value.presentation), sandbox: intersectSandbox(contribution.sandbox, value.sandbox), }; }