Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions extensions/markdown-language-features/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions extensions/markdown-language-features/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extensions/markdown-language-features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions extensions/markdown-language-features/schemas/package.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 },
});
});
11 changes: 11 additions & 0 deletions extensions/markdown-language-features/src/markdownExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ function* resolveExtensionResources(extension: vscode.Extension<any>, 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';
Expand Down Expand Up @@ -53,6 +60,7 @@ export interface MarkdownCodeBlockEditorProvider {
readonly source: MarkdownCodeBlockEditorSource;
readonly contentType: 'text' | 'json';
readonly initialHeight?: number;
readonly presentation?: ResolvedCodeBlockEditor['presentation'];
readonly sandbox?: MarkdownCodeBlockEditorSandbox;
}

Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +60,7 @@ interface ProviderResolvedCodeBlockEditor {
readonly cacheKey?: string;
readonly runtimeKey?: string;
readonly initialHeight?: number;
readonly presentation?: ResolvedCodeBlockEditor['presentation'];
readonly sandbox?: MarkdownCodeBlockEditorSandbox;
}

Expand Down Expand Up @@ -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,
},
},
Expand Down Expand Up @@ -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),
};
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -328,6 +337,7 @@ function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) {
selector: { language: 'test' },
source,
runtimeKey,
presentation,
}],
},
},
Expand Down
Loading