diff --git a/extensions/markdown-language-features/CONTRIBUTING.md b/extensions/markdown-language-features/CONTRIBUTING.md index 1b62674b50af1..b144ff6cf80a3 100644 --- a/extensions/markdown-language-features/CONTRIBUTING.md +++ b/extensions/markdown-language-features/CONTRIBUTING.md @@ -80,6 +80,22 @@ 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. + +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. + ### Active block highlighting `markdown.editor.highlightActiveBlock` controls the rich editor's active-block diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 0aa4b535737b5..4928ea624983e 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-127", + "@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-127", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-127.tgz", - "integrity": "sha512-YXjQ8j5tq31J50xEEhOG00Le6OP+ovTiH97IzNYdDxPTUFib3OTecFr7iNEPs5jbP+Wl1f8zBiN6GSkFVhkrLg==", + "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 93a0ec2355a78..19312715806cd 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1992,7 +1992,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-127", + "@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/schemas/package.schema.json b/extensions/markdown-language-features/schemas/package.schema.json index dc8f89cb2bb57..a3f10c689d40f 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 0000000000000..a86e5b363765b --- /dev/null +++ b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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 }; + 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); + } +}); + +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/markdownExtensions.ts b/extensions/markdown-language-features/src/markdownExtensions.ts index d24b9108a042f..a4f74719b2230 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 05b6a29e5603a..38e1767b1c839 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -37,6 +37,31 @@ 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))), +}); + +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(), @@ -45,6 +70,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 2c1bb7e1a27f0..b91552f27868e 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, mergeCodeBlockEditorPresentation, 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; } @@ -802,6 +804,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, }, }, @@ -964,6 +967,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: mergeCodeBlockEditorPresentation(contribution.presentation, value.presentation), sandbox: intersectSandbox(contribution.sandbox, value.sandbox), }; } @@ -1200,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 @@ -1255,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 fd4a2ea248aa4..c8a7cc8135b5c 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, }], }, },