diff --git a/playground/package-lock.json b/playground/package-lock.json index 3de5b851bf..81260beb3a 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "workspaces": [ "ty", + "ty-embed", "ruff", "shared" ], @@ -6081,6 +6082,10 @@ "resolved": "ty/ty_wasm", "link": true }, + "node_modules/ty-embed": { + "resolved": "ty-embed", + "link": true + }, "node_modules/ty-playground": { "resolved": "ty", "link": true @@ -6608,6 +6613,22 @@ "vite-plugin-static-copy": "^3.0.0" } }, + "ty-embed": { + "version": "0.0.0", + "dependencies": { + "monaco-editor": "^0.54.0", + "ty_wasm": "file:ty_wasm" + }, + "devDependencies": { + "typescript": "^5.8.2", + "vite": "^7.0.0" + } + }, + "ty-embed/ty_wasm": { + "version": "0.0.0", + "extraneous": true, + "license": "MIT" + }, "ty/ty_wasm": { "version": "0.0.0", "license": "MIT" diff --git a/playground/package.json b/playground/package.json index 771b356ae0..e431720d7f 100644 --- a/playground/package.json +++ b/playground/package.json @@ -9,11 +9,12 @@ "dev:build": "npm run dev:build --workspace ty-playground && npm run dev:build --workspace ruff-playground", "fmt": "prettier --cache -w .", "fmt:check": "prettier --cache --check .", - "lint": "eslint --cache --ext .ts,.tsx ruff/src ty/src", + "lint": "eslint --cache --ext .ts,.tsx ruff/src ty/src ty-embed/src", "tsc": "tsc" }, "workspaces": [ "ty", + "ty-embed", "ruff", "shared" ], diff --git a/playground/ty-embed/.gitignore b/playground/ty-embed/.gitignore new file mode 100644 index 0000000000..1892b4af1f --- /dev/null +++ b/playground/ty-embed/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +ty_wasm +.DS_Store +*.log diff --git a/playground/ty-embed/README.md b/playground/ty-embed/README.md new file mode 100644 index 0000000000..689af81ef4 --- /dev/null +++ b/playground/ty-embed/README.md @@ -0,0 +1,3 @@ +# ty Embeddable Editor + +A simplified, embeddable version of the ty playground that can be used in documentation pages. This allows you to create multiple interactive Python type-checking editors on a single webpage. diff --git a/playground/ty-embed/example-dev.html b/playground/ty-embed/example-dev.html new file mode 100644 index 0000000000..a0598b25e2 --- /dev/null +++ b/playground/ty-embed/example-dev.html @@ -0,0 +1,91 @@ + + + + + + + ty Type Checker - Interactive Example + + + + +

+ ty Type Checker +

+

+ This interactive editor provides real-time type checking. + Hover over symbols to see type information, Ctrl+click to jump to definitions, + and click on errors to see quick fix suggestions. +

+ +

TypedDict Keys

+

+ The code below has a typo in the dictionary key. Click on the error + and use the quick fix (lightbulb icon or Ctrl+.) to automatically fix it: +

+
+ + + + + diff --git a/playground/ty-embed/example.html b/playground/ty-embed/example.html new file mode 100644 index 0000000000..a082ee6946 --- /dev/null +++ b/playground/ty-embed/example.html @@ -0,0 +1,89 @@ + + + + + + + ty Type Checker - Interactive Example + + + + + +

+ ty Type Checker +

+

+ This interactive editor provides real-time type checking. + Hover over symbols to see type information, Ctrl+click to jump to definitions, + and click on errors to see quick fix suggestions. +

+ +

TypedDict Keys

+

+ The code below has a typo in the dictionary key. Click on the error + and use the quick fix (lightbulb icon or Ctrl+.) to automatically fix it: +

+
+ + + + + + diff --git a/playground/ty-embed/package.json b/playground/ty-embed/package.json new file mode 100644 index 0000000000..e7d38bfed0 --- /dev/null +++ b/playground/ty-embed/package.json @@ -0,0 +1,25 @@ +{ + "name": "ty-embed", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "prebuild": "npm run build:wasm", + "build": "vite build", + "build:wasm": "wasm-pack build ../../crates/ty_wasm --target web --out-dir ../../playground/ty-embed/ty_wasm", + "dev:wasm": "wasm-pack build ../../crates/ty_wasm --dev --target web --out-dir ../../playground/ty-embed/ty_wasm", + "predev:build": "npm run dev:wasm", + "dev:build": "vite build", + "prestart": "npm run dev:wasm", + "start": "vite" + }, + "dependencies": { + "monaco-editor": "^0.54.0", + "ty_wasm": "file:ty_wasm" + }, + "devDependencies": { + "typescript": "^5.8.2", + "vite": "^7.0.0", + "vite-plugin-static-copy": "^2.2.0" + } +} diff --git a/playground/ty-embed/src/EmbeddableEditor.ts b/playground/ty-embed/src/EmbeddableEditor.ts new file mode 100644 index 0000000000..a8053360cd --- /dev/null +++ b/playground/ty-embed/src/EmbeddableEditor.ts @@ -0,0 +1,843 @@ +import * as monaco from "monaco-editor"; +import { + FileHandle, + PositionEncoding, + Workspace, + Range as TyRange, + Severity, + Position as TyPosition, + CompletionKind, + LocationLink, + TextEdit, + InlayHintKind, + Diagnostic as TyDiagnostic, +} from "ty_wasm"; + +// Ayu theme colors from the ty playground +const RADIATE = "#d7ff64"; +const ROCK = "#78876e"; +const COSMIC = "#de5fe9"; +const SUN = "#ffac2f"; +const ELECTRON = "#46ebe1"; +const CONSTELLATION = "#5f6de9"; +const STARLIGHT = "#f4f4f1"; +const PROTON = "#f6afbc"; +const SUPERNOVA = "#f1aff6"; +const ASTEROID = "#e3cee3"; + +let themesInitialized = false; + +function defineAyuThemes() { + if (themesInitialized) return; + themesInitialized = true; + + // Ayu Light theme + monaco.editor.defineTheme("Ayu-Light", { + inherit: false, + base: "vs", + colors: { + "editor.background": "#f8f9fa", + "editor.foreground": "#5c6166", + "editorLineNumber.foreground": "#8a919966", + "editorLineNumber.activeForeground": "#8a9199cc", + "editorCursor.foreground": "#ffaa33", + "editor.selectionBackground": "#035bd626", + "editor.lineHighlightBackground": "#8a91991a", + "editorIndentGuide.background": "#8a91992e", + "editorIndentGuide.activeBackground": "#8a919959", + "editorError.foreground": "#e65050", + "editorWarning.foreground": "#ffaa33", + "editorWidget.background": "#f3f4f5", + "editorWidget.border": "#6b7d8f1f", + "editorHoverWidget.background": "#f3f4f5", + "editorHoverWidget.border": "#6b7d8f1f", + "editorSuggestWidget.background": "#f3f4f5", + "editorSuggestWidget.border": "#6b7d8f1f", + "editorSuggestWidget.highlightForeground": "#ffaa33", + "editorSuggestWidget.selectedBackground": "#56728f1f", + }, + rules: [ + { fontStyle: "italic", foreground: "#787b8099", token: "comment" }, + { foreground: COSMIC, token: "keyword" }, + { foreground: COSMIC, token: "builtinConstant" }, + { foreground: CONSTELLATION, token: "number" }, + { foreground: ROCK, token: "tag" }, + { foreground: ROCK, token: "string" }, + { foreground: SUN, token: "method" }, + { foreground: SUN, token: "function" }, + { foreground: SUN, token: "decorator" }, + ], + encodedTokensColors: [], + }); + + // Ayu Dark theme + monaco.editor.defineTheme("Ayu-Dark", { + inherit: false, + base: "vs-dark", + colors: { + "editor.background": "#0b0e14", + "editor.foreground": "#bfbdb6", + "editorLineNumber.foreground": "#6c738099", + "editorLineNumber.activeForeground": "#6c7380e6", + "editorCursor.foreground": "#e6b450", + "editor.selectionBackground": "#409fff4d", + "editor.lineHighlightBackground": "#131721", + "editorIndentGuide.background": "#6c738033", + "editorIndentGuide.activeBackground": "#6c738080", + "editorError.foreground": "#d95757", + "editorWarning.foreground": "#e6b450", + "editorWidget.background": "#0f131a", + "editorWidget.border": "#11151c", + "editorHoverWidget.background": "#0f131a", + "editorHoverWidget.border": "#11151c", + "editorSuggestWidget.background": "#0f131a", + "editorSuggestWidget.border": "#11151c", + "editorSuggestWidget.highlightForeground": "#e6b450", + "editorSuggestWidget.selectedBackground": "#47526640", + }, + rules: [ + { fontStyle: "italic", foreground: "#acb6bf8c", token: "comment" }, + { foreground: ELECTRON, token: "string" }, + { foreground: CONSTELLATION, token: "number" }, + { foreground: STARLIGHT, token: "identifier" }, + { foreground: RADIATE, token: "keyword" }, + { foreground: RADIATE, token: "builtinConstant" }, + { foreground: PROTON, token: "tag" }, + { foreground: ASTEROID, token: "delimiter" }, + { foreground: SUPERNOVA, token: "class" }, + { foreground: STARLIGHT, token: "variable" }, + { foreground: STARLIGHT, token: "parameter" }, + { foreground: SUN, token: "method" }, + { foreground: SUN, token: "function" }, + { foreground: SUN, token: "decorator" }, + ], + encodedTokensColors: [], + }); +} + +export interface EditorOptions { + initialCode?: string; + theme?: "light" | "dark"; + fileName?: string; + settings?: Record; + height?: string; + showDiagnostics?: boolean; + id?: string; +} + +interface Diagnostic { + id: string; + message: string; + severity: Severity; + range: TyRange | null; + raw: TyDiagnostic; +} + +const DEFAULT_SETTINGS = { + environment: { + "python-version": "3.14", + }, + rules: { + "undefined-reveal": "ignore", + }, +}; + +export class EmbeddableEditor { + private container: HTMLElement; + private options: Required; + private editor: monaco.editor.IStandaloneCodeEditor | null = null; + private workspace: Workspace | null = null; + private fileHandle: FileHandle | null = null; + private languageServer: LanguageServer | null = null; + private diagnosticsContainer: HTMLElement | null = null; + private editorContainer: HTMLElement | null = null; + private errorContainer: HTMLElement | null = null; + private checkTimeoutId: number | null = null; + + constructor(container: HTMLElement | string, options: EditorOptions) { + const element = + typeof container === "string" + ? document.querySelector(container) + : container; + + if (!element) { + throw new Error(`Container not found: ${container}`); + } + + this.container = element as HTMLElement; + this.options = { + initialCode: options.initialCode ?? "", + theme: options.theme ?? "light", + fileName: options.fileName ?? "main.py", + settings: options.settings ?? DEFAULT_SETTINGS, + height: options.height ?? "400px", + showDiagnostics: options.showDiagnostics ?? true, + id: options.id ?? `editor-${Date.now()}`, + }; + + this.init(); + } + + private async init() { + try { + // Create container structure + this.createContainerStructure(); + + // Initialize ty workspace + const ty = await import("ty_wasm"); + await ty.default(); + + this.workspace = new Workspace("/", PositionEncoding.Utf16, {}); + this.workspace.updateOptions(this.options.settings); + this.fileHandle = this.workspace.openFile( + this.options.fileName, + this.options.initialCode, + ); + + // Initialize Monaco editor + if (this.editorContainer) { + // Define Ayu themes before creating the editor + defineAyuThemes(); + + // Create model with URI matching the workspace file path + const modelUri = monaco.Uri.parse(this.options.fileName); + const model = monaco.editor.createModel( + this.options.initialCode, + "python", + modelUri, + ); + + this.editor = monaco.editor.create(this.editorContainer, { + model: model, + theme: this.options.theme === "light" ? "Ayu-Light" : "Ayu-Dark", + minimap: { enabled: false }, + fontSize: 14, + scrollBeyondLastLine: false, + roundedSelection: false, + contextmenu: true, + automaticLayout: true, + fixedOverflowWidgets: true, + }); + + // Setup language server features + this.languageServer = new LanguageServer( + this.workspace, + this.fileHandle, + ); + + // Listen to content changes + this.editor.onDidChangeModelContent(() => { + this.onContentChange(); + }); + + // Initial check + this.checkFile(); + } + } catch (err) { + this.showError(this.formatError(err)); + } + } + + private createContainerStructure() { + const isLight = this.options.theme === "light"; + + this.container.style.height = this.options.height; + this.container.style.display = "flex"; + this.container.style.flexDirection = "column"; + this.container.style.border = isLight + ? "1px solid #6b7d8f1f" + : "1px solid #11151c"; + this.container.style.borderRadius = "4px"; + this.container.style.overflow = "hidden"; + this.container.style.fontFamily = + 'Roboto Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; + + // Editor container + this.editorContainer = document.createElement("div"); + this.editorContainer.style.height = this.options.showDiagnostics + ? `calc(${this.options.height} - 120px)` + : this.options.height; + this.container.appendChild(this.editorContainer); + + // Diagnostics container + if (this.options.showDiagnostics) { + this.diagnosticsContainer = document.createElement("div"); + this.diagnosticsContainer.style.height = "120px"; + this.diagnosticsContainer.style.overflow = "auto"; + this.diagnosticsContainer.style.borderTop = isLight + ? "1px solid #6b7d8f1f" + : "1px solid #11151c"; + this.diagnosticsContainer.style.backgroundColor = isLight + ? "#f8f9fa" + : "#0b0e14"; + this.diagnosticsContainer.style.color = isLight ? "#5c6166" : "#bfbdb6"; + this.diagnosticsContainer.style.padding = "8px"; + this.diagnosticsContainer.style.fontSize = "13px"; + this.container.appendChild(this.diagnosticsContainer); + } + } + + private onContentChange() { + // Debounce both workspace update and type checking + if (this.checkTimeoutId !== null) { + window.clearTimeout(this.checkTimeoutId); + } + this.checkTimeoutId = window.setTimeout(() => { + const content = this.editor?.getValue() ?? ""; + + if (this.workspace && this.fileHandle) { + try { + this.workspace.updateFile(this.fileHandle, content); + } catch (err) { + console.error("Error updating file:", err); + } + } + + this.checkFile(); + }, 150); + } + + private checkFile() { + if (!this.workspace || !this.fileHandle || !this.editor) { + return; + } + + try { + const diagnostics = this.workspace.checkFile(this.fileHandle); + const mapped: Diagnostic[] = diagnostics.map((diagnostic) => ({ + id: diagnostic.id(), + message: diagnostic.message(), + severity: diagnostic.severity(), + range: diagnostic.toRange(this.workspace!) ?? null, + raw: diagnostic, + })); + + this.updateDiagnostics(mapped); + this.hideError(); + } catch (err) { + console.error("Error checking file:", err); + this.showError(this.formatError(err)); + } + } + + private updateDiagnostics(diagnostics: Diagnostic[]) { + // Update language server diagnostics for code actions + if (this.languageServer) { + this.languageServer.updateDiagnostics(diagnostics); + } + + // Update Monaco markers + if (this.editor) { + const model = this.editor.getModel(); + if (model) { + monaco.editor.setModelMarkers( + model, + "owner", + diagnostics.map((diagnostic) => { + const range = diagnostic.range; + return { + code: diagnostic.id, + startLineNumber: range?.start?.line ?? 1, + startColumn: range?.start?.column ?? 1, + endLineNumber: range?.end?.line ?? 1, + endColumn: range?.end?.column ?? 1, + message: diagnostic.message, + severity: this.mapSeverity(diagnostic.severity), + tags: [], + }; + }), + ); + } + } + + // Update diagnostics panel + if (this.diagnosticsContainer) { + const isLight = this.options.theme === "light"; + const mutedColor = isLight ? "#8a9199" : "#565b66"; + + this.diagnosticsContainer.innerHTML = ""; + + if (diagnostics.length > 0) { + const list = document.createElement("ul"); + list.style.margin = "0"; + list.style.padding = "0"; + list.style.listStyle = "none"; + + diagnostics.forEach((diagnostic) => { + const item = this.createDiagnosticItem(diagnostic, mutedColor); + list.appendChild(item); + }); + this.diagnosticsContainer.appendChild(list); + } + } + } + + private createDiagnosticItem( + diagnostic: Diagnostic, + mutedColor: string, + ): HTMLElement { + const startLine = diagnostic.range?.start?.line ?? 1; + const startColumn = diagnostic.range?.start?.column ?? 1; + const isLight = this.options.theme === "light"; + + // Error: red, Warning: yellow/orange + const severityColor = + diagnostic.severity === Severity.Error + ? isLight + ? "#e65050" + : "#d95757" + : diagnostic.severity === Severity.Warning + ? isLight + ? "#f2ae49" + : "#e6b450" + : mutedColor; + + const item = document.createElement("li"); + item.style.marginBottom = "8px"; + item.style.paddingLeft = "8px"; + item.style.borderLeft = `3px solid ${severityColor}`; + + const button = document.createElement("button"); + button.style.all = "unset"; + button.style.width = "100%"; + button.style.textAlign = "left"; + button.style.cursor = "pointer"; + button.style.userSelect = "text"; + + button.innerHTML = ` + ${diagnostic.id} + ${startLine}:${startColumn} +
${diagnostic.message}
+ `; + + button.addEventListener("click", () => { + if (diagnostic.range && this.editor) { + const range = diagnostic.range; + this.editor.revealRange({ + startLineNumber: range.start.line, + startColumn: range.start.column, + endLineNumber: range.end.line, + endColumn: range.end.column, + }); + this.editor.setSelection({ + startLineNumber: range.start.line, + startColumn: range.start.column, + endLineNumber: range.end.line, + endColumn: range.end.column, + }); + this.editor.focus(); + } + }); + + item.appendChild(button); + return item; + } + + private showError(message: string) { + if (!this.errorContainer) { + this.errorContainer = document.createElement("div"); + this.errorContainer.style.position = "absolute"; + this.errorContainer.style.bottom = "10px"; + this.errorContainer.style.left = "10px"; + this.errorContainer.style.right = "10px"; + this.errorContainer.style.padding = "12px"; + this.errorContainer.style.backgroundColor = "#ff4444"; + this.errorContainer.style.color = "#fff"; + this.errorContainer.style.borderRadius = "4px"; + this.container.style.position = "relative"; + this.container.appendChild(this.errorContainer); + } + this.errorContainer.textContent = message; + this.errorContainer.style.display = "block"; + } + + private hideError() { + if (this.errorContainer) { + this.errorContainer.style.display = "none"; + } + } + + private formatError(error: unknown): string { + const message = error instanceof Error ? error.message : `${error}`; + return message.startsWith("Error: ") + ? message.slice("Error: ".length) + : message; + } + + private mapSeverity(severity: Severity): monaco.MarkerSeverity { + switch (severity) { + case Severity.Info: + return monaco.MarkerSeverity.Info; + case Severity.Warning: + return monaco.MarkerSeverity.Warning; + case Severity.Error: + return monaco.MarkerSeverity.Error; + case Severity.Fatal: + return monaco.MarkerSeverity.Error; + } + } + + dispose() { + this.languageServer?.dispose(); + this.editor?.dispose(); + if (this.workspace && this.fileHandle) { + try { + this.workspace.closeFile(this.fileHandle); + } catch (err) { + console.warn("Error closing file:", err); + } + } + if (this.checkTimeoutId !== null) { + window.clearTimeout(this.checkTimeoutId); + } + } +} + +class LanguageServer + implements + monaco.languages.DefinitionProvider, + monaco.languages.HoverProvider, + monaco.languages.CompletionItemProvider, + monaco.languages.InlayHintsProvider, + monaco.languages.CodeActionProvider +{ + private definitionDisposable: monaco.IDisposable; + private hoverDisposable: monaco.IDisposable; + private completionDisposable: monaco.IDisposable; + private inlayHintsDisposable: monaco.IDisposable; + private codeActionDisposable: monaco.IDisposable; + private diagnostics: Diagnostic[] = []; + + constructor( + private workspace: Workspace, + private fileHandle: FileHandle, + ) { + this.definitionDisposable = monaco.languages.registerDefinitionProvider( + "python", + this, + ); + this.hoverDisposable = monaco.languages.registerHoverProvider( + "python", + this, + ); + this.completionDisposable = + monaco.languages.registerCompletionItemProvider("python", this); + this.inlayHintsDisposable = monaco.languages.registerInlayHintsProvider( + "python", + this, + ); + this.codeActionDisposable = monaco.languages.registerCodeActionProvider( + "python", + this, + ); + } + + updateDiagnostics(diagnostics: Diagnostic[]) { + this.diagnostics = diagnostics; + } + + triggerCharacters = ["."]; + + provideCompletionItems( + _model: monaco.editor.ITextModel, + position: monaco.Position, + ): monaco.languages.ProviderResult { + try { + const completions = this.workspace.completions( + this.fileHandle, + new TyPosition(position.lineNumber, position.column), + ); + + const digitsLength = String(completions.length - 1).length; + + return { + suggestions: completions.map((completion, i) => ({ + label: { + label: completion.name, + detail: + completion.module_name == null + ? undefined + : ` (import ${completion.module_name})`, + description: completion.detail ?? undefined, + }, + sortText: String(i).padStart(digitsLength, "0"), + kind: + completion.kind == null + ? monaco.languages.CompletionItemKind.Variable + : mapCompletionKind(completion.kind), + insertText: completion.insert_text ?? completion.name, + additionalTextEdits: completion.additional_text_edits?.map( + (edit: TextEdit) => ({ + range: tyRangeToMonacoRange(edit.range), + text: edit.new_text, + }), + ), + documentation: completion.documentation, + detail: completion.detail, + range: undefined as any, + })), + }; + } catch (err) { + console.warn("Error providing completions:", err); + return undefined; + } + } + + provideHover( + _model: monaco.editor.ITextModel, + position: monaco.Position, + ): monaco.languages.ProviderResult { + try { + const hover = this.workspace.hover( + this.fileHandle, + new TyPosition(position.lineNumber, position.column), + ); + + if (hover == null) { + return undefined; + } + + return { + range: tyRangeToMonacoRange(hover.range), + contents: [{ value: hover.markdown, isTrusted: true }], + }; + } catch (err) { + console.warn("Error providing hover:", err); + return undefined; + } + } + + provideDefinition( + model: monaco.editor.ITextModel, + position: monaco.Position, + ): monaco.languages.ProviderResult< + monaco.languages.Definition | monaco.languages.LocationLink[] + > { + try { + const links = this.workspace.gotoDefinition( + this.fileHandle, + new TyPosition(position.lineNumber, position.column), + ); + + if (links.length === 0) { + return undefined; + } + + const currentUri = model.uri; + const results = links + .filter((link: LocationLink) => { + const linkUri = monaco.Uri.parse(link.path); + return linkUri.path === currentUri.path; + }) + .map((link: LocationLink) => ({ + uri: currentUri, + range: tyRangeToMonacoRange(link.full_range), + targetSelectionRange: + link.selection_range == null + ? undefined + : tyRangeToMonacoRange(link.selection_range), + originSelectionRange: + link.origin_selection_range == null + ? undefined + : tyRangeToMonacoRange(link.origin_selection_range), + })); + + return results.length > 0 ? results : undefined; + } catch (err) { + console.warn("Error providing definition:", err); + return undefined; + } + } + + provideInlayHints( + _model: monaco.editor.ITextModel, + range: monaco.IRange, + ): monaco.languages.ProviderResult { + try { + const inlayHints = this.workspace.inlayHints( + this.fileHandle, + monacoRangeToTyRange(range), + ); + + if (inlayHints.length === 0) { + return undefined; + } + + return { + dispose: () => {}, + hints: inlayHints.map((hint) => ({ + label: hint.label.map((part) => ({ + label: part.label, + })), + position: { + lineNumber: hint.position.line, + column: hint.position.column, + }, + kind: mapInlayHintKind(hint.kind), + textEdits: hint.text_edits.map((edit: TextEdit) => ({ + range: tyRangeToMonacoRange(edit.range), + text: edit.new_text, + })), + })), + }; + } catch (err) { + console.warn("Error providing inlay hints:", err); + return undefined; + } + } + + provideCodeActions( + model: monaco.editor.ITextModel, + range: monaco.Range, + ): monaco.languages.ProviderResult { + const actions: monaco.languages.CodeAction[] = []; + + for (const diagnostic of this.diagnostics) { + const diagnosticRange = diagnostic.range; + if (diagnosticRange == null) { + continue; + } + + const monacoRange = tyRangeToMonacoRange(diagnosticRange); + if (!monaco.Range.areIntersecting(range, new monaco.Range( + monacoRange.startLineNumber, + monacoRange.startColumn, + monacoRange.endLineNumber, + monacoRange.endColumn, + ))) { + continue; + } + + try { + const codeActions = this.workspace.codeActions( + this.fileHandle, + diagnostic.raw, + ); + if (codeActions == null) { + continue; + } + + for (const codeAction of codeActions) { + actions.push({ + title: codeAction.title, + kind: "quickfix", + isPreferred: codeAction.preferred, + edit: { + edits: codeAction.edits.map((edit) => ({ + resource: model.uri, + textEdit: { + range: tyRangeToMonacoRange(edit.range), + text: edit.new_text, + }, + versionId: model.getVersionId(), + })), + }, + }); + } + } catch (err) { + console.warn("Error getting code actions:", err); + } + } + + if (actions.length === 0) { + return undefined; + } + + return { + actions, + dispose: () => {}, + }; + } + + dispose() { + this.definitionDisposable.dispose(); + this.hoverDisposable.dispose(); + this.completionDisposable.dispose(); + this.inlayHintsDisposable.dispose(); + this.codeActionDisposable.dispose(); + } +} + +// Helper functions +function tyRangeToMonacoRange(range: TyRange): monaco.IRange { + return { + startLineNumber: range.start.line, + startColumn: range.start.column, + endLineNumber: range.end.line, + endColumn: range.end.column, + }; +} + +function monacoRangeToTyRange(range: monaco.IRange): TyRange { + return new TyRange( + new TyPosition(range.startLineNumber, range.startColumn), + new TyPosition(range.endLineNumber, range.endColumn), + ); +} + +function mapInlayHintKind(kind: InlayHintKind): monaco.languages.InlayHintKind { + switch (kind) { + case InlayHintKind.Type: + return monaco.languages.InlayHintKind.Type; + case InlayHintKind.Parameter: + return monaco.languages.InlayHintKind.Parameter; + } +} + +function mapCompletionKind( + kind: CompletionKind, +): monaco.languages.CompletionItemKind { + switch (kind) { + case CompletionKind.Text: + return monaco.languages.CompletionItemKind.Text; + case CompletionKind.Method: + return monaco.languages.CompletionItemKind.Method; + case CompletionKind.Function: + return monaco.languages.CompletionItemKind.Function; + case CompletionKind.Constructor: + return monaco.languages.CompletionItemKind.Constructor; + case CompletionKind.Field: + return monaco.languages.CompletionItemKind.Field; + case CompletionKind.Variable: + return monaco.languages.CompletionItemKind.Variable; + case CompletionKind.Class: + return monaco.languages.CompletionItemKind.Class; + case CompletionKind.Interface: + return monaco.languages.CompletionItemKind.Interface; + case CompletionKind.Module: + return monaco.languages.CompletionItemKind.Module; + case CompletionKind.Property: + return monaco.languages.CompletionItemKind.Property; + case CompletionKind.Unit: + return monaco.languages.CompletionItemKind.Unit; + case CompletionKind.Value: + return monaco.languages.CompletionItemKind.Value; + case CompletionKind.Enum: + return monaco.languages.CompletionItemKind.Enum; + case CompletionKind.Keyword: + return monaco.languages.CompletionItemKind.Keyword; + case CompletionKind.Snippet: + return monaco.languages.CompletionItemKind.Snippet; + case CompletionKind.Color: + return monaco.languages.CompletionItemKind.Color; + case CompletionKind.File: + return monaco.languages.CompletionItemKind.File; + case CompletionKind.Reference: + return monaco.languages.CompletionItemKind.Reference; + case CompletionKind.Folder: + return monaco.languages.CompletionItemKind.Folder; + case CompletionKind.EnumMember: + return monaco.languages.CompletionItemKind.EnumMember; + case CompletionKind.Constant: + return monaco.languages.CompletionItemKind.Constant; + case CompletionKind.Struct: + return monaco.languages.CompletionItemKind.Struct; + case CompletionKind.Event: + return monaco.languages.CompletionItemKind.Event; + case CompletionKind.Operator: + return monaco.languages.CompletionItemKind.Operator; + case CompletionKind.TypeParameter: + return monaco.languages.CompletionItemKind.TypeParameter; + } +} + diff --git a/playground/ty-embed/src/index.css b/playground/ty-embed/src/index.css new file mode 100644 index 0000000000..8a7750d689 --- /dev/null +++ b/playground/ty-embed/src/index.css @@ -0,0 +1,44 @@ +/* Basic reset for the editor container */ +.ty-embed-container { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + box-sizing: border-box; +} + +.ty-embed-container *, +.ty-embed-container *::before, +.ty-embed-container *::after { + box-sizing: inherit; +} + +/* Loading state */ +.ty-embed-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: #666; +} + +/* Error message */ +.ty-embed-error { + padding: 12px; + background-color: #ff4444; + color: #fff; + border-radius: 4px; + margin: 10px; +} + +/* Diagnostics panel */ +.ty-embed-diagnostics { + font-family: "Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro", + monospace; +} + +.ty-embed-diagnostic-item { + transition: background-color 0.15s ease; +} + +.ty-embed-diagnostic-item:hover { + filter: brightness(0.95); +} diff --git a/playground/ty-embed/src/index.ts b/playground/ty-embed/src/index.ts new file mode 100644 index 0000000000..980e2c3bd5 --- /dev/null +++ b/playground/ty-embed/src/index.ts @@ -0,0 +1,85 @@ +import { EmbeddableEditor, EditorOptions } from "./EmbeddableEditor"; + +export interface TyEditorOptions extends EditorOptions { + container: HTMLElement | string; +} + +/** + * Initialize a ty editor instance in the specified container. + * + * @param options Configuration options for the editor + * @returns A handle to control the editor instance + * + * @example + * ```javascript + * import { createTyEditor } from 'ty-embed'; + * + * const editor = createTyEditor({ + * container: '#editor', + * initialCode: 'print("Hello, ty!")', + * theme: 'dark', + * height: '500px' + * }); + * ``` + */ +export function createTyEditor(options: TyEditorOptions) { + const editor = new EmbeddableEditor(options.container, options); + + return { + /** + * Unmount and cleanup the editor instance + */ + dispose() { + editor.dispose(); + }, + }; +} + +/** + * Initialize multiple ty editor instances at once. + * + * @param selector CSS selector for containers (e.g., '.ty-editor') + * @param defaultOptions Default options to apply to all editors + * @returns Array of editor handles + * + * @example + * ```html + *
+ *
+ * + * + * ``` + */ +export function createTyEditors( + selector: string, + defaultOptions: Partial = {}, +) { + const containers = document.querySelectorAll(selector); + const editors = []; + + for (const container of Array.from(containers)) { + const dataCode = container.getAttribute("data-code"); + const dataTheme = container.getAttribute("data-theme"); + const dataHeight = container.getAttribute("data-height"); + const dataFile = container.getAttribute("data-file"); + + const options: TyEditorOptions = { + container: container as HTMLElement, + ...defaultOptions, + initialCode: dataCode ?? defaultOptions.initialCode, + theme: + (dataTheme as "light" | "dark") ?? defaultOptions.theme ?? "light", + height: dataHeight ?? defaultOptions.height ?? "400px", + fileName: dataFile ?? defaultOptions.fileName, + }; + + editors.push(createTyEditor(options)); + } + + return editors; +} + +// Re-export types +export type { EditorOptions }; diff --git a/playground/ty-embed/tsconfig.json b/playground/ty-embed/tsconfig.json new file mode 100644 index 0000000000..3f993887c2 --- /dev/null +++ b/playground/ty-embed/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types" + }, + "include": ["src"] +} diff --git a/playground/ty-embed/vite.config.ts b/playground/ty-embed/vite.config.ts new file mode 100644 index 0000000000..add68277ec --- /dev/null +++ b/playground/ty-embed/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from "vite"; +import { viteStaticCopy } from "vite-plugin-static-copy"; + +export default defineConfig({ + plugins: [ + viteStaticCopy({ + targets: [ + { + src: ["ty_wasm/*", "!ty_wasm/.gitignore"], + dest: "ty_wasm", + }, + ], + }), + ], + build: { + lib: { + entry: "./src/index.ts", + name: "TyEmbed", + formats: ["es", "umd"], + fileName: (format) => `ty-embed.${format}.js`, + }, + rollupOptions: { + external: ["ty_wasm"], + output: { + assetFileNames: (assetInfo) => { + if (assetInfo.name === "style.css") return "ty-embed.css"; + return assetInfo.name ?? "asset"; + }, + paths: { + ty_wasm: "./ty_wasm/ty_wasm.js", + }, + }, + }, + copyPublicDir: false, + }, + optimizeDeps: { + exclude: ["ty_wasm"], + }, + server: { + port: 3001, + open: "/example-dev.html", + }, +});