Skip to content
Open
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
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert"
"typescript.format.semicolons": "insert",
"snyk.advanced.autoSelectOrganization": true
}
9 changes: 7 additions & 2 deletions apps/vscode-editor/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
VSC_VE_GetSlideIndex,
VSC_VE_GetActiveBlockContext,
VSC_VE_SetBlockSelection,
VSC_VE_GetSelectedText,
VSC_VE_Init,
VSC_VE_Focus,
VSC_VEH_FlushEditorUpdates,
Expand Down Expand Up @@ -181,7 +182,7 @@ export async function syncEditorToHost(

// visual editor => text editor (just send the state, host will call back for markdown)
editor.subscribe(UpdateEvent, () => host.onEditorUpdated(editor.getStateJson()));
editor.subscribe(StateChangeEvent, () => host.onEditorStateChanged(editor.getEditorSourcePos()));
editor.subscribe(StateChangeEvent, () => host.onEditorStateChanged(editor.getEditorSourcePos(), editor.getSelectedText()));


// return canonical markdown
Expand Down Expand Up @@ -252,6 +253,9 @@ export async function syncEditorToHost(
},
async setBlockSelection(context: CodeViewActiveBlockContext, action: CodeViewSelectionAction) {
editor.setBlockSelection(context, action);
},
async getSelectedText(): Promise<string> {
return editor.getSelectedText();
}
})

Expand Down Expand Up @@ -334,6 +338,7 @@ function visualEditorHostServer(vscode: WebviewApi<unknown>, editor: VSCodeVisua
[VSC_VE_ApplyExternalEdit]: args => editor.applyExternalEdit(args[0]),
[VSC_VE_GetActiveBlockContext]: () => editor.getActiveBlockContext(),
[VSC_VE_SetBlockSelection]: args => editor.setBlockSelection(args[0], args[1]),
[VSC_VE_GetSelectedText]: () => editor.getSelectedText(),
[VSC_VE_PrefsChanged]: args => editor.prefsChanged(args[0]),
[VSC_VE_ImageChanged]: args => editor.imageChanged(args[0])
})
Expand All @@ -346,7 +351,7 @@ function editorJsonRpcContainer(request: JsonRpcRequestTransport) : VSCodeVisual
reopenSourceMode: () => request(VSC_VEH_ReopenSourceMode, []),
onEditorReady: () => request(VSC_VEH_OnEditorReady, []),
onEditorUpdated: (state: unknown) => request(VSC_VEH_OnEditorUpdated, [state]),
onEditorStateChanged: (sourcePos: SourcePos) => request(VSC_VEH_OnEditorStateChanged, [sourcePos]),
onEditorStateChanged: (sourcePos: SourcePos, selectedText: string) => request(VSC_VEH_OnEditorStateChanged, [sourcePos, selectedText]),
flushEditorUpdates: () => request(VSC_VEH_FlushEditorUpdates, []),
saveDocument: () => request(VSC_VEH_SaveDocument, []),
renderDocument: () => request(VSC_VEH_RenderDocument, []),
Expand Down
2 changes: 2 additions & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 1.134.0 (Unreleased)

- Exposed the visual editor's text selection: added a `quarto.editor.getSelectedText` command and an `onDidChangeVisualEditorSelection` event on the public extension API (<https://github.com/quarto-dev/quarto/pull/997>).

## 1.133.0 (Release on 2026-06-03)

- Added diagnostics (i.e. squiggly underlines) to code cells in qmds (<https://github.com/quarto-dev/quarto/pull/980>).
Expand Down
19 changes: 19 additions & 0 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@
"title": "New Quarto Presentation (qmd)",
"category": "Quarto"
},
{
"command": "quarto.toggleWordCount",
"title": "Toggle Word Count",
"category": "Quarto"
},
{
"command": "quarto.newNotebook",
"title": "New Quarto Notebook (ipynb)",
Expand Down Expand Up @@ -1074,6 +1079,20 @@
"default": true,
"markdownDescription": "Use reticulate to execute Python cells within Knitr engine documents."
},
"quarto.wordCount.enabled": {
"order": 28,
"scope": "window",
"type": "boolean",
"default": true,
"markdownDescription": "Show word counts (per-section code lens / visual editor badge, plus document total and selection count in the status bar)."
},
"quarto.wordCount.includeCodeCells": {
"order": 29,
"scope": "window",
"type": "boolean",
"default": false,
"markdownDescription": "Include executable code cells, fenced code, and raw blocks in word counts. YAML front matter is always excluded."
},
"quarto.mathjax.scale": {
"order": 15,
"scope": "window",
Expand Down
38 changes: 36 additions & 2 deletions apps/vscode/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,20 @@
*
*/

import * as vscode from "vscode";

import { QuartoContext } from "quarto-core";

/**
* Payload for the {@link QuartoExtensionApi.onDidChangeVisualEditorSelection} event.
*/
export interface VisualEditorSelection {
/** The document whose visual editor selection changed. */
uri: vscode.Uri;
/** The currently selected text (empty string when nothing is selected). */
selectedText: string;
}

/**
* Public API for the Quarto extension.
*
Expand All @@ -29,11 +41,16 @@ import { QuartoContext } from "quarto-core";
* copy this interface definition into your own codebase:
*
* ```typescript
* // Copy this interface into your extension
* // Copy these definitions into your extension
* interface VisualEditorSelection {
* uri: { toString(): string };
* selectedText: string;
* }
* interface QuartoExtensionApi {
* getQuartoPath(): string | undefined;
* getQuartoVersion(): string | undefined;
* isQuartoAvailable(): boolean;
* onDidChangeVisualEditorSelection: (listener: (e: VisualEditorSelection) => unknown) => { dispose(): unknown };
* }
Comment thread
stevehaigh marked this conversation as resolved.
*
* // Then use it like this:
Expand Down Expand Up @@ -67,12 +84,27 @@ export interface QuartoExtensionApi {
* Check if Quarto is available.
*/
isQuartoAvailable(): boolean;

/**
* Fires when the text selection changes in a Quarto visual editor.
*
* The event carries the document URI and the currently selected text. An
* empty `selectedText` is a meaningful event (e.g. the selection was cleared),
* so consumers can use it to reset their UI. Events are de-duplicated against
* the previous selection for a given document, but not time-debounced — the
* underlying editor reports state changes on every cursor move, so consumers
* that need throttling should debounce themselves.
*/
onDidChangeVisualEditorSelection: vscode.Event<VisualEditorSelection>;
}

/**
* Create the public API for the Quarto extension.
*/
export function createQuartoExtensionApi(quartoContext: QuartoContext): QuartoExtensionApi {
export function createQuartoExtensionApi(
quartoContext: QuartoContext,
onDidChangeVisualEditorSelection: vscode.Event<VisualEditorSelection>
): QuartoExtensionApi {
return {
getQuartoPath(): string | undefined {
if (!quartoContext.available) {
Expand All @@ -91,5 +123,7 @@ export function createQuartoExtensionApi(quartoContext: QuartoContext): QuartoEx
isQuartoAvailable(): boolean {
return quartoContext.available;
},

onDidChangeVisualEditorSelection,
};
}
14 changes: 11 additions & 3 deletions apps/vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ import { activateYamlLinks } from "./providers/yaml-links";
import { activateYamlFilepathCompletions } from "./providers/yaml-filepath-completions";
import { activateContextKeySetter } from "./providers/context-keys";
import { activateDivBracketDecorations } from "./providers/div-brackets";
import { activateWordCount } from "./providers/wordcount/wordcount";
import { CommandManager } from "./core/command";
import { createQuartoExtensionApi, QuartoExtensionApi } from "./api";
import { createQuartoExtensionApi, QuartoExtensionApi, VisualEditorSelection } from "./api";

let embeddedDiagnostics: EmbeddedDiagnosticsService | undefined;

Expand All @@ -67,6 +68,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// create extension host
const host = extensionHost();

// emits visual editor selection changes to consumers of the public API
const visualEditorSelectionEmitter = new vscode.EventEmitter<VisualEditorSelection>();
context.subscriptions.push(visualEditorSelectionEmitter);

// create markdown engine
const engine = new MarkdownEngine();

Expand Down Expand Up @@ -133,7 +138,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
registerOutlineConfigListener(context);

// provide visual editor
const editorCommands = activateEditor(context, host, quartoContext, lspClient, engine);
const editorCommands = activateEditor(context, host, quartoContext, lspClient, engine, visualEditorSelectionEmitter);
commands.push(...editorCommands);

// zotero
Expand Down Expand Up @@ -238,6 +243,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// div bracket decorations
activateDivBracketDecorations(context);

// word counts (per-section code lens + status bar total/selection)
activateWordCount(context, engine, visualEditorSelectionEmitter.event);

// commands
const commandManager = new CommandManager();
for (const cmd of commands) {
Expand All @@ -251,7 +259,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
outputChannel.info("Activated Quarto extension.");

// Return the public API for other extensions to use
return createQuartoExtensionApi(quartoContext);
return createQuartoExtensionApi(quartoContext, visualEditorSelectionEmitter.event);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion apps/vscode/src/providers/editor/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
VSC_VE_ApplyExternalEdit,
VSC_VE_GetActiveBlockContext,
VSC_VE_SetBlockSelection,
VSC_VE_GetSelectedText,
VSC_VEH_EditorResourceUri,
VSC_VEH_GetHostContext,
VSC_VEH_ReopenSourceMode,
Expand Down Expand Up @@ -88,6 +89,7 @@ export function visualEditorClient(webviewPanel: WebviewPanel)
context: CodeViewActiveBlockContext,
action: CodeViewSelectionAction
) => request(VSC_VE_SetBlockSelection, [context, action]),
getSelectedText: () => request(VSC_VE_GetSelectedText, []),
applyExternalEdit: (markdown: string) => request(VSC_VE_ApplyExternalEdit, [markdown]),
prefsChanged: (prefs: Prefs) => request(VSC_VE_PrefsChanged, [prefs]),
imageChanged: (file: string) => request(VSC_VE_ImageChanged, [file])
Expand Down Expand Up @@ -144,7 +146,7 @@ function editorHostMethods(host: VSCodeVisualEditorHost): Record<string, JsonRpc
[VSC_VEH_ReopenSourceMode]: () => host.reopenSourceMode(),
[VSC_VEH_OnEditorReady]: () => host.onEditorReady(),
[VSC_VEH_OnEditorUpdated]: args => host.onEditorUpdated(args[0]),
[VSC_VEH_OnEditorStateChanged]: args => host.onEditorStateChanged(args[0]),
[VSC_VEH_OnEditorStateChanged]: args => host.onEditorStateChanged(args[0], args[1]),
[VSC_VEH_FlushEditorUpdates]: () => host.flushEditorUpdates(),
[VSC_VEH_SaveDocument]: () => host.saveDocument(),
[VSC_VEH_RenderDocument]: () => host.renderDocument(),
Expand Down
48 changes: 41 additions & 7 deletions apps/vscode/src/providers/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
Selection,
TextEditorRevealType,
GlobPattern,
TabInputText
TabInputText,
EventEmitter
} from "vscode";

import { LanguageClient } from "vscode-languageclient/node";
Expand Down Expand Up @@ -67,24 +68,27 @@ import {
} from "./toggle";
import { ExtensionHost } from "../../host";
import { TabInputCustom } from "vscode";
import { VisualEditorSelection } from "../../api";

const kVisualModeConfirmed = "visualModeConfirmed";

export interface QuartoVisualEditor extends QuartoEditor {
hasFocus(): Promise<boolean>;
getActiveBlockContext(): Promise<CodeViewActiveBlockContext | null>;
setBlockSelection(context: CodeViewActiveBlockContext, action: CodeViewSelectionAction): Promise<void>;
getSelectedText(): Promise<string>;
}

export function activateEditor(
context: ExtensionContext,
host: ExtensionHost,
quartoContext: QuartoContext,
lspClient: LanguageClient,
engine: MarkdownEngine
engine: MarkdownEngine,
selectionEmitter: EventEmitter<VisualEditorSelection>
): Command[] {
// register the provider
context.subscriptions.push(VisualEditorProvider.register(context, host, quartoContext, lspClient, engine));
context.subscriptions.push(VisualEditorProvider.register(context, host, quartoContext, lspClient, engine, selectionEmitter));

// return commands
return [
Expand All @@ -100,6 +104,16 @@ export function activateEditor(
return VisualEditorProvider.activeEditor() !== undefined;
}
},
{
id: 'quarto.editor.getSelectedText',
async execute() {
const editor = VisualEditorProvider.activeEditor();
if (editor) {
return await editor.getSelectedText();
}
return '';
}
},
editInVisualModeCommand(),
editInSourceModeCommand(),
toggleRenderOnSaveCommand()
Expand Down Expand Up @@ -136,7 +150,8 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
host: ExtensionHost,
quartoContext: QuartoContext,
lspClient: LanguageClient,
engine: MarkdownEngine
engine: MarkdownEngine,
selectionEmitter: EventEmitter<VisualEditorSelection>
): Disposable {

// setup request transport
Expand Down Expand Up @@ -260,7 +275,7 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
}
}, 100)));

const provider = new VisualEditorProvider(context, host, quartoContext, lspRequest, engine);
const provider = new VisualEditorProvider(context, host, quartoContext, lspRequest, engine, selectionEmitter);
const providerRegistration = window.registerCustomEditorProvider(
VisualEditorProvider.viewType,
provider,
Expand Down Expand Up @@ -303,6 +318,9 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
setBlockSelection: async (context, action) => {
await editor.editor.setBlockSelection(context, action);
},
getSelectedText: async () => {
return await editor.editor.getSelectedText();
},
viewColumn: editor.webviewPanel.viewColumn
};
} else {
Expand All @@ -322,7 +340,21 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
private readonly extensionHost: ExtensionHost,
private readonly quartoContext: QuartoContext,
private readonly lspRequest: JsonRpcRequestTransport,
private readonly engine: MarkdownEngine) { }
private readonly engine: MarkdownEngine,
private readonly selectionEmitter: EventEmitter<VisualEditorSelection>) { }

// last selection emitted per document (used to de-duplicate selection-change events)
private readonly lastSelectedText = new Map<string, string>();
Comment thread
stevehaigh marked this conversation as resolved.

// fire the public selection-change event, de-duplicating against the previous selection
private fireSelectionChanged(uri: Uri, selectedText: string) {
const key = uri.toString();
if (this.lastSelectedText.get(key) === selectedText) {
return;
}
this.lastSelectedText.set(key, selectedText);
this.selectionEmitter.fire({ uri, selectedText });
}
Comment thread
stevehaigh marked this conversation as resolved.

public async resolveCustomTextEditor(
document: TextDocument,
Expand Down Expand Up @@ -478,8 +510,9 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
// notify sync manager when visual editor is updated
onEditorUpdated: syncManager.onVisualEditorChanged,

onEditorStateChanged: async (sourcePos: SourcePos) => {
onEditorStateChanged: async (sourcePos: SourcePos, selectedText: string) => {
VisualEditorProvider.visualEditorLastSourcePos.set(document.uri.toString(), sourcePos);
this.fireSelectionChanged(document.uri, selectedText);
},

// flush any pending updates
Expand Down Expand Up @@ -571,6 +604,7 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
for (const disposable of disposables) {
disposable.dispose();
}
this.lastSelectedText.delete(document.uri.toString());
});

}
Expand Down
Loading