diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..58d9cf7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Solid Monaco Development", + "image": "node:24-bullseye", + "features": { + "ghcr.io/devcontainers/features/git:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "esbenp.prettier-vscode", + "ms-vscode.vscode-typescript-next" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.preferences.importModuleSpecifier": "relative" + } + } + }, + "postCreateCommand": "corepack enable pnpm && pnpm install", + "remoteUser": "root", + "workspaceFolder": "/workspace", + "mounts": [ + "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached" + ], + "forwardPorts": [5173], + "portsAttributes": { + "5173": { + "label": "Vite Dev Server", + "onAutoForward": "notify" + } + } +} \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3f37208..53f6635 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,35 +12,50 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 360 permissions: security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: - languages: javascript + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 with: + category: "/language:${{ matrix.language }}" upload: false output: sarif-results # Only include files that are public - name: filter-sarif - uses: advanced-security/filter-sarif@main + uses: advanced-security/filter-sarif@v1 with: patterns: | - /src/**/*.* + +src/**/*.ts + +src/**/*.tsx -**/*.test.* - input: sarif-results/javascript.sarif - output: sarif-results/javascript.sarif + -**/*.spec.* + input: sarif-results/${{ matrix.language }}.sarif + output: sarif-results/${{ matrix.language }}.sarif - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v1 + uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: sarif-results/javascript.sarif + sarif_file: sarif-results/${{ matrix.language }}.sarif diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 4280668..37de4be 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -14,13 +14,16 @@ jobs: contents: write steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2.2.4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 8 - name: Setup Node.js environment - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 + cache: pnpm # "git restore ." discards changes to package-lock.json - name: Install dependencies @@ -32,6 +35,6 @@ jobs: run: pnpm run format - name: Add, Commit and Push - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: 'Format' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e4cd7e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm run test + + - name: Build package + run: pnpm run build + + - name: Publish to NPM + run: pnpm publish --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f31b0a3..2e741f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,25 +7,39 @@ on: branches: [main] jobs: - build: + test: + name: Test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest - + + strategy: + matrix: + node-version: [18, 20, 22] + steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - - uses: pnpm/action-setup@v2.2.4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 - - name: Setup Node.js environment - uses: actions/setup-node@v3 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm run lint:types + + - name: Lint + run: pnpm run lint:code - name: Build run: pnpm run build @@ -35,5 +49,52 @@ jobs: env: CI: true - - name: Lint - run: pnpm run lint + build-check: + name: Build Check + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build library + run: pnpm run build + + - name: Check build output + run: | + if [ ! -d "dist" ]; then + echo "❌ Build failed: dist directory not found" + exit 1 + fi + if [ ! -f "dist/index.js" ]; then + echo "❌ Build failed: index.js not found" + exit 1 + fi + if [ ! -f "dist/index.d.ts" ]; then + echo "❌ Build failed: index.d.ts not found" + exit 1 + fi + echo "✅ Build output verified" + + - name: Test package installation + run: | + # Create a test project to verify the package can be installed + mkdir test-install + cd test-install + npm init -y + npm install ../ + echo "✅ Package installation test passed" diff --git a/.gitignore b/.gitignore index fb13f66..5a1d742 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist gitignore .idea .vscode +.pnpm-store # tsup tsup.config.bundled_*.{m,c,}s diff --git a/README.md b/README.md index 39f3522..3e15896 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ Install it: ```bash npm i solid-monaco ``` -*or* + +_or_ ```bash yarn add solid-monaco ``` -*or* + +_or_ + ```bash pnpm add solid-monaco ``` @@ -44,9 +47,10 @@ function MyEditor() { The `MonacoEditor` component accepts the following props: | Prop | Type | Default | Description | -|--------------------|--------------------------------------------------------------------|--------------|--------------------------------------------------------------------------------| +| ------------------ | ------------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | | `language` | `string` | - | The programming language for the editor. E.g., `"javascript"`, `"typescript"`. | | `value` | `string` | - | Content of the editor. | +| `line` | `number` | - | Jump to specific line number in the editor. | | `loadingState` | `JSX.Element` | `"Loading…"` | JSX element to be displayed during the loading state. | | `class` | `string` | - | CSS class for the editor container. | | `theme` | `BuiltinTheme` or `string` | `"vs"` | The theme to be applied to the editor. | @@ -57,8 +61,10 @@ The `MonacoEditor` component accepts the following props: | `options` | `object` | - | Additional options for the Monaco editor. | | `saveViewState` | `string` | `true` | Whether to save the model view state for a given path of the editor. | | `onChange` | `(value: string, event: editor.IModelContentChangedEvent) => void` | - | Callback triggered when the content of the editor changes. | +| `onBeforeMount` | `(monaco: Monaco) => void` | - | Callback triggered before editor creation for setup. | | `onMount` | `(monaco: Monaco, editor: editor.IStandaloneCodeEditor) => void` | - | Callback triggered when the editor mounts. | | `onBeforeUnmount` | `(monaco: Monaco, editor: editor.IStandaloneCodeEditor) => void` | - | Callback triggered before the editor unmounts. | +| `onValidate` | `(markers: editor.IMarker[]) => void` | - | Callback triggered when validation markers change. | ### Getting Monaco and Editor Instances @@ -72,16 +78,102 @@ function MyEditor() { // Use monaco and editor instances here }; + return ( + + ); +} +``` + +#### Line Positioning + +Jump to a specific line number in the editor: + +```jsx +import { MonacoEditor } from 'solid-monaco'; +import { createSignal } from 'solid-js'; + +function MyEditor() { + const [currentLine, setCurrentLine] = createSignal(42); + + return ( +
+ + +
+ ); +} +``` + +#### Pre-Editor Setup + +Use the `beforeMount` callback to configure Monaco before the editor is created: + +```jsx +import { MonacoEditor } from 'solid-monaco'; + +function MyEditor() { + const handleBeforeMount = monaco => { + // Configure Monaco before editor creation + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: false, + }); + + // Register custom themes, languages, etc. + monaco.editor.defineTheme('myCustomTheme', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + 'editor.background': '#1e1e1e', + }, + }); + }; + return ( ); } ``` +#### Validation Markers + +Monitor validation errors and warnings in real-time: + +```jsx +import { MonacoEditor } from 'solid-monaco'; +import { createSignal } from 'solid-js'; + +function MyEditor() { + const [errors, setErrors] = createSignal([]); + + const handleValidate = markers => { + setErrors(markers.filter(marker => marker.severity === 8)); // Errors only + console.log('Validation markers:', markers); + }; + + return ( +
+
Errors: {errors().length}
+ +
+ ); +} +``` + ## MonacoDiffEditor For a side-by-side comparison view of code, the package provides a `MonacoDiffEditor` component. @@ -109,25 +201,214 @@ function MyDiffEditor() { The `MonacoDiffEditor` component accepts the following props: -| Prop | Type | Default | Description | -|--------------------|------------------------------------------------------------------|--------------|------------------------------------------------------------------------| -| `original` | `string` | - | Original content to be displayed on the left side of the diff editor. | -| `modified` | `string` | - | Modified content to be displayed on the right side of the diff editor. | -| `originalLanguage` | `string` | - | Language for the original content. | -| `modifiedLanguage` | `string` | - | Language for the modified content. | -| `originalPath` | `string` | - | Path for the original content used in Monaco model management. | -| `modifiedPath` | `string` | - | Path for the modified content used in Monaco model management. | -| `loadingState` | `JSX.Element` | `"Loading…"` | JSX element displayed during the loading state. | -| `class` | `string` | - | CSS class for the diff editor container. | -| `theme` | `BuiltinTheme` or `string` | `"vs"` | Theme applied to the diff editor. | -| `overrideServices` | `object` | - | Services to override the default ones provided by Monaco. | -| `width` | `string` | `"100%"` | Width of the diff editor container. | -| `height` | `string` | `"100%"` | Height of the diff editor container. | -| `options` | `object` | - | Additional options for the Monaco diff editor. | -| `saveViewState` | `boolean` | `true` | Whether to save the model view state. | -| `onChange` | `(value: string) => void` | - | Callback triggered when the content of the modified editor changes. | -| `onMount` | `(monaco: Monaco, editor: editor.IStandaloneDiffEditor) => void` | - | Callback triggered when the diff editor mounts. | -| `onBeforeUnmount` | `(monaco: Monaco, editor: editor.IStandaloneDiffEditor) => void` | - | Callback triggered before the diff editor unmounts. | +| Prop | Type | Default | Description | +| ------------------ | ---------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------------- | +| `original` | `string` | - | Original content to be displayed on the left side of the diff editor. | +| `modified` | `string` | - | Modified content to be displayed on the right side of the diff editor. | +| `originalLanguage` | `string` | - | Language for the original content. | +| `modifiedLanguage` | `string` | - | Language for the modified content. | +| `originalPath` | `string` | - | Path for the original content used in Monaco model management. | +| `modifiedPath` | `string` | - | Path for the modified content used in Monaco model management. | +| `loadingState` | `JSX.Element` | `"Loading…"` | JSX element displayed during the loading state. | +| `class` | `string` | - | CSS class for the diff editor container. | +| `theme` | `BuiltinTheme` or `string` | `"vs"` | Theme applied to the diff editor. | +| `overrideServices` | `object` | - | Services to override the default ones provided by Monaco. | +| `width` | `string` | `"100%"` | Width of the diff editor container. | +| `height` | `string` | `"100%"` | Height of the diff editor container. | +| `options` | `IStandaloneDiffEditorConstructionOptions` | - | Correct diff editor options type (was incorrectly using regular editor options). | +| `saveViewState` | `boolean` | `true` | Whether to save the model view state. | +| `onChange` | `(value: string) => void` | - | Callback triggered when the content of the modified editor changes. | +| `onMount` | `(monaco: Monaco, editor: editor.IStandaloneDiffEditor) => void` | - | Callback triggered when the diff editor mounts. | +| `onBeforeUnmount` | `(monaco: Monaco, editor: editor.IStandaloneDiffEditor) => void` | - | Callback triggered before the diff editor unmounts. | +| `onBeforeMount` | `(monaco: Monaco) => void` | - | Callback triggered before diff editor creation for setup. | + +## Production Setup Guide + +### Asset Loading Configuration + +The library supports both CDN and local asset loading strategies. + +#### CDN Loading (Default) + +```jsx +// Uses CDN by default - no configuration needed + +``` + +#### Local Asset Loading + +**1. Install Dependencies** + +Ensure `monaco-editor` is a regular dependency (not devDependency) in your `package.json`: + +```json +{ + "dependencies": { + "monaco-editor": "^0.48.0", + "solid-monaco": "^0.x.x" + } +} +``` + +**2. Environment Configuration** + +Create environment-specific configurations: + +```bash +# .env (development) +# This allows vite to serve monaco assets directly from +# node_modules when in dev mode. +VITE_MONACO_ASSETS_PATH=/node_modules/monaco-editor/dev/vs + +# .env.production +# Configure the path where minified monaco assets will be +# found. See `monacoAssetsPlugin` below. +VITE_MONACO_ASSETS_PATH=/monaco-assets/vs +``` + +**3. Vite Configuration** + +Add a custom plugin to copy Monaco assets during build: + +```typescript +// vite.config.ts +import { cpSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +const monacoAssetsPlugin = () => { + return { + name: 'monaco-assets-plugin', + generateBundle() { + // Copy Monaco Editor assets to build output directory + const monacoSrc = join(process.cwd(), 'node_modules/monaco-editor/min/vs'); + const buildDest = join(process.cwd(), 'dist/monaco-assets/vs'); + + if (existsSync(monacoSrc)) { + cpSync(monacoSrc, buildDest, { recursive: true }); + console.log('✓ Monaco Editor assets copied to dist directory'); + } + }, + }; +}; + +export default defineConfig({ + plugins: [solidPlugin(), monacoAssetsPlugin()], + // ... other config +}); +``` + +**4. Component Usage** + +Use the environment variable to configure asset loading: + +```jsx +import { MonacoDiffEditor } from 'solid-monaco'; + +function MyDiffEditor() { + const configureDiffEditor = monaco => { + // Configure JSON language features + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + allowComments: false, + schemas: [], + enableSchemaRequest: false, + }); + + // Configure JSON formatting + monaco.languages.json.jsonDefaults.setModeConfiguration({ + documentFormattingEdits: true, + documentRangeFormattingEdits: true, + completionItems: true, + hovers: true, + documentSymbols: true, + tokens: true, + colors: true, + foldingRanges: true, + diagnostics: true, + selectionRanges: true, + }); + }; + + return ( + + ); +} +``` + +### Build Optimization + +#### Bundle Splitting + +Configure Vite to split Monaco into separate chunks for better loading performance: + +```typescript +// vite.config.ts +export default defineConfig({ + build: { + rollupOptions: { + output: { + manualChunks: { + 'solid-monaco': ['solid-monaco'], + 'monaco-editor': ['monaco-editor'], + }, + }, + }, + }, +}); +``` + +### Troubleshooting + +#### Common Issues + +**Monaco assets not loading in production:** + +- Ensure `monaco-editor` is in `dependencies`, not `devDependencies` +- Verify the Vite plugin is copying assets to the correct build directory +- Check that `VITE_MONACO_ASSETS_PATH` matches your actual asset path + +**Large bundle size:** + +- Use `import type` for Monaco types to avoid bundling the entire library +- Configure bundle splitting to separate Monaco into its own chunk +- Consider lazy loading Monaco for non-critical editor instances + +**Web workers failing:** + +- Ensure web worker files are accessible at the configured asset path +- Check browser console for 404 errors on worker files +- Verify CORS settings if serving assets from a different domain ## Contributing diff --git a/dev/vite.config.ts b/dev/vite.config.ts index a198a70..d43f8c3 100644 --- a/dev/vite.config.ts +++ b/dev/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ plugins: [ solidPlugin(), { - name: 'Reaplace env variables', + name: 'Replace env variables', transform(code, id) { if (id.includes('node_modules')) { return code diff --git a/src/MonacoDiffEditor.tsx b/src/MonacoDiffEditor.tsx index 8967f36..4445c2a 100644 --- a/src/MonacoDiffEditor.tsx +++ b/src/MonacoDiffEditor.tsx @@ -1,5 +1,5 @@ import { createSignal, createEffect, onCleanup, JSX, onMount, mergeProps, on } from 'solid-js' -import * as monacoEditor from 'monaco-editor' +import type * as monacoEditor from 'monaco-editor' import loader, { Monaco } from '@monaco-editor/loader' import { Loader } from './Loader' import { MonacoContainer } from './MonacoContainer' @@ -24,10 +24,12 @@ export interface MonacoDiffEditorProps { overrideServices?: monacoEditor.editor.IEditorOverrideServices width?: string height?: string - options?: monacoEditor.editor.IStandaloneEditorConstructionOptions + options?: monacoEditor.editor.IStandaloneDiffEditorConstructionOptions saveViewState?: boolean loaderParams?: LoaderParams + onChange?: (value: string) => void + onBeforeMount?: (monaco: Monaco) => void onMount?: (monaco: Monaco, editor: monacoEditor.editor.IStandaloneDiffEditor) => void onBeforeUnmount?: (monaco: Monaco, editor: monacoEditor.editor.IStandaloneDiffEditor) => void } @@ -50,17 +52,25 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { const [editor, setEditor] = createSignal() let abortInitialization: (() => void) | undefined - let monacoOnChangeSubscription: any + let monacoOnChangeSubscription: monacoEditor.IDisposable | undefined let isOnChangeSuppressed = false onMount(async () => { - loader.config(inputProps.loaderParams ?? { monaco: monacoEditor }) + loader.config(inputProps.loaderParams ?? { + paths: { + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.48.0/min/vs' + } + }) const loadMonaco = loader.init() abortInitialization = () => loadMonaco.cancel() try { const monaco = await loadMonaco + + // Call beforeMount callback before editor creation + props.onBeforeMount?.(monaco) + const editor = createEditor(monaco) setMonaco(monaco) setEditor(editor) @@ -97,7 +107,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { createEffect( on( () => props.modified, - modified => { + (modified: string | undefined) => { const _editor = editor()?.getModifiedEditor() if (!_editor || typeof modified === 'undefined') { return @@ -130,7 +140,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { createEffect( on( () => props.original, - original => { + (original: string | undefined) => { const _editor = editor()?.getOriginalEditor() if (!_editor || typeof original === 'undefined') { return @@ -147,7 +157,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { createEffect( on( () => props.options, - options => { + (options: monacoEditor.editor.IStandaloneDiffEditorConstructionOptions | undefined) => { editor()?.updateOptions(options ?? {}) }, { defer: true }, @@ -157,7 +167,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { createEffect( on( () => props.theme, - theme => { + (theme: monacoEditor.editor.BuiltinTheme | string) => { monaco()?.editor.setTheme(theme) }, { defer: true }, @@ -167,7 +177,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { createEffect( on( () => props.originalLanguage, - language => { + (language: string | undefined) => { const model = editor()?.getModel() if (!language || !model) { return @@ -182,7 +192,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { createEffect( on( () => props.modifiedLanguage, - language => { + (language: string | undefined) => { const model = editor()?.getModel() if (!language || !model) { return @@ -247,6 +257,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { ) const createEditor = (monaco: Monaco) => { + const originalModel = getOrCreateModel( monaco, props.original ?? '', @@ -261,7 +272,7 @@ export const MonacoDiffEditor = (inputProps: MonacoDiffEditorProps) => { ) const editor = monaco.editor.createDiffEditor( - containerRef, + containerRef!, { automaticLayout: true, ...props.options, diff --git a/src/MonacoEditor.test.tsx b/src/MonacoEditor.test.tsx index 0d8346d..324f097 100644 --- a/src/MonacoEditor.test.tsx +++ b/src/MonacoEditor.test.tsx @@ -2,7 +2,6 @@ import { createRoot } from 'solid-js' import { describe, expect, it } from 'vitest' import { MonacoEditor } from '../src' -// TODO: add real tests describe('MonacoEditor', () => { it('renders a MonacoEditor component', async () => { createRoot(() => { diff --git a/src/MonacoEditor.tsx b/src/MonacoEditor.tsx index dc75b4e..184f9e4 100644 --- a/src/MonacoEditor.tsx +++ b/src/MonacoEditor.tsx @@ -1,5 +1,5 @@ import { createSignal, createEffect, onCleanup, JSX, onMount, mergeProps, on } from 'solid-js' -import * as monacoEditor from 'monaco-editor' +import type * as monacoEditor from 'monaco-editor' import loader, { Monaco } from '@monaco-editor/loader' import { Loader } from './Loader' import { MonacoContainer } from './MonacoContainer' @@ -11,6 +11,7 @@ const viewStates = new Map() export interface MonacoEditorProps { language?: string value?: string + line?: number loadingState?: JSX.Element class?: string theme?: monacoEditor.editor.BuiltinTheme | string @@ -22,8 +23,10 @@ export interface MonacoEditorProps { saveViewState?: boolean loaderParams?: LoaderParams onChange?: (value: string, event: monacoEditor.editor.IModelContentChangedEvent) => void + onBeforeMount?: (monaco: Monaco) => void onMount?: (monaco: Monaco, editor: monacoEditor.editor.IStandaloneCodeEditor) => void onBeforeUnmount?: (monaco: Monaco, editor: monacoEditor.editor.IStandaloneCodeEditor) => void + onValidate?: (markers: monacoEditor.editor.IMarker[]) => void } export const MonacoEditor = (inputProps: MonacoEditorProps) => { @@ -45,26 +48,55 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { let abortInitialization: (() => void) | undefined let monacoOnChangeSubscription: any + let validationSubscription: monacoEditor.IDisposable | undefined let isOnChangeSuppressed = false onMount(async () => { - loader.config(inputProps.loaderParams ?? { monaco: monacoEditor }) + loader.config(inputProps.loaderParams ?? { + paths: { + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.48.0/min/vs' + } + }) const loadMonaco = loader.init() abortInitialization = () => loadMonaco.cancel() try { const monaco = await loadMonaco + + // Call beforeMount callback before editor creation + props.onBeforeMount?.(monaco) + const editor = createEditor(monaco) setMonaco(monaco) setEditor(editor) + + // Handle initial line positioning + if (props.line !== undefined) { + editor.revealLine(props.line) + } + props.onMount?.(monaco, editor) - monacoOnChangeSubscription = editor.onDidChangeModelContent(event => { + monacoOnChangeSubscription = editor.onDidChangeModelContent((event: monacoEditor.editor.IModelContentChangedEvent) => { if (!isOnChangeSuppressed) { props.onChange?.(editor.getValue(), event) } }) + + // Setup validation subscription if onValidate is provided + if (props.onValidate) { + validationSubscription = monaco.editor.onDidChangeMarkers((uris: readonly monacoEditor.Uri[]) => { + const editorUri = editor.getModel()?.uri + if (editorUri) { + const currentEditorHasMarkerChanges = uris.find((uri: monacoEditor.Uri) => uri.path === editorUri.path) + if (currentEditorHasMarkerChanges) { + const markers = monaco.editor.getModelMarkers({ resource: editorUri }) + props.onValidate!(markers) + } + } + }) + } } catch (error: any) { if (error?.type === 'cancelation') { return @@ -83,6 +115,7 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { props.onBeforeUnmount?.(monaco()!, _editor) monacoOnChangeSubscription?.dispose() + validationSubscription?.dispose() _editor.getModel()?.dispose() _editor.dispose() }) @@ -90,7 +123,7 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { createEffect( on( () => props.value, - value => { + (value: string | undefined) => { const _editor = editor() if (!_editor || typeof value === 'undefined') { return @@ -123,7 +156,7 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { createEffect( on( () => props.options, - options => { + (options: monacoEditor.editor.IStandaloneEditorConstructionOptions | undefined) => { editor()?.updateOptions(options ?? {}) }, { defer: true }, @@ -133,7 +166,7 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { createEffect( on( () => props.theme, - theme => { + (theme: monacoEditor.editor.BuiltinTheme | string) => { monaco()?.editor.setTheme(theme) }, { defer: true }, @@ -143,7 +176,7 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { createEffect( on( () => props.language, - language => { + (language: string | undefined) => { const model = editor()?.getModel() if (!language || !model) { return @@ -158,7 +191,7 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { createEffect( on( () => props.path, - (path, prevPath) => { + (path: string | undefined, prevPath: string | undefined) => { const _monaco = monaco() if (!_monaco) { return @@ -180,11 +213,24 @@ export const MonacoEditor = (inputProps: MonacoEditorProps) => { ), ) - const createEditor = (monaco: Monaco) => { + createEffect( + on( + () => props.line, + (line: number | undefined) => { + const currentEditor = editor() + if (line !== undefined && currentEditor) { + currentEditor.revealLine(line) + } + }, + { defer: true }, + ), + ) + + const createEditor = (monaco: Monaco) => { const model = getOrCreateModel(monaco, props.value ?? '', props.language, props.path) return monaco.editor.create( - containerRef, + containerRef!, { model: model, automaticLayout: true,