@vmz/plugin-monaco 0.0.4 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,74 +3,182 @@
3
3
  </template>
4
4
 
5
5
  <style>
6
+ /*
7
+ Isolate Monaco glyph metrics from page CSS.
8
+ Inherited letter-spacing / proportional fonts desync caret + selection overlays.
9
+ */
6
10
  .monaco-host {
7
11
  display: block;
8
12
  width: 100%;
9
13
  height: 100%;
10
14
  min-height: 0;
15
+ overflow: hidden;
16
+ position: relative;
17
+ box-sizing: border-box;
18
+ font: 14px/21px Consolas, "Courier New", Menlo, Monaco, monospace !important;
19
+ letter-spacing: 0 !important;
20
+ word-spacing: 0 !important;
21
+ font-variant-ligatures: none !important;
22
+ text-rendering: auto;
23
+ -webkit-font-smoothing: antialiased;
24
+ }
25
+ .monaco-host .monaco-editor {
26
+ width: 100% !important;
27
+ height: 100% !important;
28
+ outline: none;
29
+ font-family: Consolas, "Courier New", Menlo, Monaco, monospace !important;
30
+ font-size: 14px !important;
31
+ line-height: 21px !important;
32
+ letter-spacing: 0 !important;
33
+ }
34
+ .monaco-host .monaco-editor .view-lines,
35
+ .monaco-host .monaco-editor .view-line,
36
+ .monaco-host .monaco-editor .view-line span,
37
+ .monaco-host .monaco-editor .margin-view-overlays,
38
+ .monaco-host .monaco-editor .lines-content {
39
+ font-family: Consolas, "Courier New", Menlo, Monaco, monospace !important;
40
+ font-size: 14px !important;
41
+ line-height: 21px !important;
42
+ letter-spacing: 0 !important;
43
+ word-spacing: 0 !important;
44
+ }
45
+ /*
46
+ Keep the hidden textarea from painting native OS selection
47
+ (shows up as yellow boxes / black bands when metrics desync).
48
+ */
49
+ .monaco-host .monaco-editor .inputarea,
50
+ .monaco-host .monaco-editor textarea.inputarea,
51
+ .monaco-host .monaco-editor .native-edit-context {
52
+ opacity: 0 !important;
53
+ color: transparent !important;
54
+ background: transparent !important;
55
+ caret-color: transparent !important;
56
+ outline: none !important;
57
+ box-shadow: none !important;
58
+ border: 0 !important;
59
+ -webkit-user-select: text;
60
+ user-select: text;
61
+ }
62
+ .monaco-host .monaco-editor .inputarea::selection,
63
+ .monaco-host .monaco-editor textarea.inputarea::selection,
64
+ .monaco-host .monaco-editor .native-edit-context::selection {
65
+ background: transparent !important;
66
+ color: transparent !important;
67
+ }
68
+ .monaco-host .monaco-editor .inputarea::-moz-selection,
69
+ .monaco-host .monaco-editor textarea.inputarea::-moz-selection,
70
+ .monaco-host .monaco-editor .native-edit-context::-moz-selection {
71
+ background: transparent !important;
72
+ color: transparent !important;
11
73
  }
12
74
  </style>
13
75
 
14
76
  <script client>
15
- import { mountMonaco } from '@vmz/plugin-monaco/runtime';
16
-
17
77
  export default class Monaco {
18
- public value: string = '';
19
- public language: string = 'typescript';
20
- public theme: string = 'vs-dark';
21
- public readOnly: boolean = false;
22
- /** Parent callback keeps page state authoritative (same pattern as Switch.onChange). */
23
- public onChange: ((v: string) => void) | null = null;
78
+ public value: string = ""
79
+ public language: string = "typescript"
80
+ public theme: string = "vs"
81
+ public readOnly: boolean = false
82
+ /** Optional JSON Schema URL (IR completion + diagnostics). */
83
+ public jsonSchemaUrl: string = ""
84
+ public onChange: ((v: string) => void) | null = null
24
85
 
25
86
  #api: {
26
- getValue: () => string;
27
- setValue: (v: string) => void;
28
- dispose: () => void;
29
- } | null = null;
30
- #applyingExternal = false;
31
- #valueWatch: (() => void) | null = null;
87
+ getValue: () => string
88
+ setValue: (v: string) => void
89
+ dispose: () => void
90
+ monaco?: { editor?: { setTheme?: (t: string) => void } }
91
+ } | null = null
92
+ #applyingExternal = false
93
+ #valueWatch: (() => void) | null = null
94
+ #mountGen = 0
95
+ #onScheme: ((e: Event) => void) | null = null
96
+
97
+ #resolveTheme() {
98
+ if (typeof document === "undefined") return this.theme || "vs"
99
+ const dark =
100
+ document.documentElement.getAttribute("data-theme") === "dark" ||
101
+ document.documentElement.dataset.theme === "dark"
102
+ return dark ? "vs-dark" : "vs"
103
+ }
32
104
 
33
105
  async onMount() {
34
- if (typeof window === 'undefined') return;
35
- const root = this.__vmzDomRoot;
106
+ if (typeof window === "undefined") return
107
+ const gen = ++this.#mountGen
108
+ const root = this.__vmzDomRoot
36
109
  const el =
37
- (root && root.nodeType === 1 && root.matches?.('[data-vmz-monaco]') && root) ||
38
- root?.querySelector?.('[data-vmz-monaco]') ||
39
- root;
40
- if (!el || el.nodeType !== 1) return;
41
- this.#api = await mountMonaco(el, {
110
+ (root && root.nodeType === 1 && root.matches?.("[data-vmz-monaco]") && root) ||
111
+ root?.querySelector?.("[data-vmz-monaco]") ||
112
+ root
113
+ if (!el || el.nodeType !== 1) return
114
+
115
+ this.#api?.dispose()
116
+ this.#api = null
117
+
118
+ // Dynamic import — never evaluate monaco on the Node/SSR route path.
119
+ const { mountMonaco } = await import("@vmz/plugin-monaco/runtime")
120
+ if (gen !== this.#mountGen || this.__vmzDestroyed) return
121
+
122
+ const theme = this.#resolveTheme()
123
+ this.theme = theme
124
+
125
+ const api = await mountMonaco(el, {
42
126
  value: this.value,
43
127
  language: this.language,
44
- theme: this.theme,
128
+ theme,
45
129
  readOnly: this.readOnly,
130
+ jsonSchemaUrl: this.jsonSchemaUrl || undefined,
46
131
  onChange: (v) => {
47
- this.#applyingExternal = true;
48
- this.value = v;
49
- this.#applyingExternal = false;
50
- if (typeof this.onChange === 'function') this.onChange(v);
132
+ this.#applyingExternal = true
133
+ this.value = v
134
+ this.#applyingExternal = false
135
+ if (typeof this.onChange === "function") this.onChange(v)
51
136
  },
52
- });
53
- // Parent may push a new `value` after mount (loadSpec). Sync into the editor.
54
- let last = this.value;
137
+ })
138
+ if (gen !== this.#mountGen || this.__vmzDestroyed) {
139
+ api.dispose()
140
+ return
141
+ }
142
+ this.#api = api
143
+
144
+ this.#onScheme = () => {
145
+ const next = this.#resolveTheme()
146
+ this.theme = next
147
+ try {
148
+ this.#api?.monaco?.editor?.setTheme?.(next)
149
+ } catch {
150
+ /* ignore */
151
+ }
152
+ }
153
+ window.addEventListener("davinci-theme", this.#onScheme)
154
+ window.addEventListener("storage", this.#onScheme)
155
+
156
+ let last = this.value
55
157
  const tick = () => {
56
- if (!this.#api || this.__vmzDestroyed) return;
57
- if (this.#applyingExternal) return;
158
+ if (!this.#api || this.__vmzDestroyed) return
159
+ if (this.#applyingExternal) return
58
160
  if (this.value !== last) {
59
- last = this.value;
161
+ last = this.value
60
162
  if (this.#api.getValue() !== this.value) {
61
- this.#api.setValue(this.value ?? '');
163
+ this.#api.setValue(this.value ?? "")
62
164
  }
63
165
  }
64
- };
65
- const id = window.setInterval(tick, 50);
66
- this.#valueWatch = () => window.clearInterval(id);
166
+ }
167
+ const id = window.setInterval(tick, 50)
168
+ this.#valueWatch = () => window.clearInterval(id)
67
169
  }
68
170
 
69
171
  onDestroy() {
70
- this.#valueWatch?.();
71
- this.#valueWatch = null;
72
- this.#api?.dispose();
73
- this.#api = null;
172
+ this.#mountGen++
173
+ if (this.#onScheme && typeof window !== "undefined") {
174
+ window.removeEventListener("davinci-theme", this.#onScheme)
175
+ window.removeEventListener("storage", this.#onScheme)
176
+ }
177
+ this.#onScheme = null
178
+ this.#valueWatch?.()
179
+ this.#valueWatch = null
180
+ this.#api?.dispose()
181
+ this.#api = null
74
182
  }
75
183
  }
76
184
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/plugin-monaco",
3
- "version": "0.0.4",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "VMZ Monaco editor adapter - <Monaco>",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
- "@vmz/plugin": "0.0.4"
20
+ "@vmz/plugin": "0.1.1"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "monaco-editor": ">=0.52"
package/runtime.ts CHANGED
@@ -1,35 +1,280 @@
1
1
  /**
2
- * Monaco mount helper (browser). First slice: imperative create/dispose.
3
- * Peer: monaco-editor.
2
+ * Monaco mount helper (browser). Peer: monaco-editor.
3
+ *
4
+ * Dynamic-imports monaco so Node/SSR route collection never evaluates the
5
+ * browser bundle (`window is not defined`).
6
+ *
7
+ * Embedded VMZ hosts desync selection overlays unless we pin fonts, disable
8
+ * EditContext, remasure fonts, and drive layout via ResizeObserver.
4
9
  */
5
10
 
11
+ export type JsonSchemaRegistration = {
12
+ uri: string;
13
+ fileMatch?: string[];
14
+ schema: object;
15
+ };
16
+
6
17
  export type MountMonacoOptions = {
7
18
  value?: string;
8
19
  language?: string;
9
20
  theme?: string;
10
21
  readOnly?: boolean;
11
22
  onChange?: (v: string) => void;
23
+ jsonSchemaUrl?: string;
24
+ jsonSchemas?: JsonSchemaRegistration[];
25
+ };
26
+
27
+ type MonacoEnv = {
28
+ getWorkerUrl?: (moduleId: string, label: string) => string;
29
+ getWorker?: (moduleId: string, label: string) => Worker;
12
30
  };
13
31
 
32
+ let mountSeq = 0;
33
+
34
+ function ensureMonacoEnvironment(): void {
35
+ const g = globalThis as typeof globalThis & { MonacoEnvironment?: MonacoEnv };
36
+ if (g.MonacoEnvironment?.getWorker || g.MonacoEnvironment?.getWorkerUrl) return;
37
+ g.MonacoEnvironment = {
38
+ getWorkerUrl(_moduleId: string, label: string) {
39
+ if (label === 'json') return '/vendor/monaco-json.worker.js';
40
+ return '/vendor/monaco-editor.worker.js';
41
+ },
42
+ };
43
+ }
44
+
45
+ function ensureMonacoCss(): void {
46
+ if (typeof document === 'undefined') return;
47
+ if (document.querySelector('link[data-vmz-monaco-css]')) return;
48
+ const link = document.createElement('link');
49
+ link.rel = 'stylesheet';
50
+ link.href = '/vendor/plugin-monaco-runtime.css';
51
+ link.setAttribute('data-vmz-monaco-css', '');
52
+ document.head.appendChild(link);
53
+ }
54
+
55
+ async function loadJsonSchema(url: string): Promise<JsonSchemaRegistration | null> {
56
+ try {
57
+ const res = await fetch(url);
58
+ if (!res.ok) return null;
59
+ const schema = (await res.json()) as object;
60
+ const id = typeof (schema as { $id?: unknown }).$id === 'string' ? String((schema as { $id: string }).$id) : url;
61
+ return {
62
+ uri: id,
63
+ fileMatch: ['*'],
64
+ schema,
65
+ };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function disposeHostEditor(el: HTMLElement): void {
72
+ const prior = (el as HTMLElement & { __vmzMonaco?: { dispose: () => void } }).__vmzMonaco;
73
+ if (!prior) return;
74
+ try {
75
+ prior.dispose();
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ delete (el as HTMLElement & { __vmzMonaco?: unknown }).__vmzMonaco;
80
+ el.replaceChildren();
81
+ }
82
+
83
+ function deadApi() {
84
+ return {
85
+ editor: null as unknown as never,
86
+ monaco: null as unknown as never,
87
+ getValue: () => '',
88
+ setValue: (_v: string) => {},
89
+ dispose: () => {},
90
+ };
91
+ }
92
+
14
93
  export async function mountMonaco(el: HTMLElement, opts: MountMonacoOptions = {}) {
15
- const monaco = await import('monaco-editor');
94
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
95
+ return deadApi();
96
+ }
97
+
98
+ const seq = ++mountSeq;
99
+ ensureMonacoEnvironment();
100
+ ensureMonacoCss();
101
+
102
+ // Prefer vendor-injected monaco (single ESM chunk); otherwise dynamic-import.
103
+ const injected = (globalThis as { __VMZ_MONACO__?: unknown }).__VMZ_MONACO__;
104
+ let MonacoNS: unknown = injected;
105
+ if (!MonacoNS) {
106
+ MonacoNS = await import('monaco-editor');
107
+ await import('monaco-editor/language/json/monaco.contribution.js');
108
+ }
109
+ const monaco = (MonacoNS as { languages?: unknown; default?: unknown }).languages
110
+ ? MonacoNS
111
+ : ((MonacoNS as { default: typeof MonacoNS }).default ?? MonacoNS);
112
+
113
+ const language = opts.language ?? 'typescript';
114
+ disposeHostEditor(el);
115
+
116
+ if (language === 'json') {
117
+ const jsonDefaults =
118
+ (monaco as { json?: { jsonDefaults?: { setDiagnosticsOptions: (o: unknown) => void } } }).json?.jsonDefaults ??
119
+ (
120
+ monaco as {
121
+ languages?: {
122
+ json?: { jsonDefaults?: { setDiagnosticsOptions: (o: unknown) => void } };
123
+ };
124
+ }
125
+ ).languages?.json?.jsonDefaults;
126
+ if (jsonDefaults) {
127
+ const schemas: JsonSchemaRegistration[] = [...(opts.jsonSchemas ?? [])];
128
+ if (opts.jsonSchemaUrl) {
129
+ const loaded = await loadJsonSchema(opts.jsonSchemaUrl);
130
+ if (loaded) schemas.push(loaded);
131
+ }
132
+ if (seq !== mountSeq) return deadApi();
133
+ if (schemas.length) {
134
+ jsonDefaults.setDiagnosticsOptions({
135
+ validate: true,
136
+ allowComments: true,
137
+ schemas: schemas.map((s) => ({
138
+ uri: s.uri,
139
+ fileMatch: s.fileMatch ?? ['*'],
140
+ schema: s.schema,
141
+ })),
142
+ });
143
+ }
144
+ }
145
+ }
146
+
147
+ if (seq !== mountSeq) return deadApi();
148
+
149
+ const uri = monaco.Uri.parse(`inmemory://vmz-monaco/${seq}/${language}.json`);
150
+ const model = monaco.editor.createModel(opts.value ?? '', language, uri);
151
+
16
152
  const editor = monaco.editor.create(el, {
17
- value: opts.value ?? '',
18
- language: opts.language ?? 'typescript',
19
- theme: opts.theme ?? 'vs-dark',
153
+ model,
154
+ theme: opts.theme ?? 'vs',
20
155
  readOnly: !!opts.readOnly,
21
- automaticLayout: true,
156
+ automaticLayout: false,
22
157
  minimap: { enabled: false },
158
+ scrollBeyondLastLine: false,
159
+ tabSize: 2,
160
+ editContext: false,
161
+ fontFamily: 'Consolas, "Courier New", Menlo, Monaco, monospace',
162
+ fontSize: 14,
163
+ lineHeight: 21,
164
+ letterSpacing: 0,
165
+ fontLigatures: false,
166
+ fixedOverflowWidgets: true,
167
+ renderLineHighlight: 'none',
168
+ renderValidationDecorations: 'on',
169
+ occurrencesHighlight: 'off',
170
+ selectionHighlight: false,
171
+ matchBrackets: 'near',
172
+ guides: { indentation: true, highlightActiveIndentation: false },
173
+ padding: { top: 4, bottom: 4 },
174
+ scrollbar: {
175
+ verticalScrollbarSize: 10,
176
+ horizontalScrollbarSize: 10,
177
+ useShadows: false,
178
+ },
23
179
  });
180
+
181
+ const layout = () => {
182
+ if (seq !== mountSeq) return;
183
+ try {
184
+ const rect = el.getBoundingClientRect();
185
+ editor.layout({
186
+ width: Math.max(0, Math.floor(rect.width)),
187
+ height: Math.max(0, Math.floor(rect.height)),
188
+ });
189
+ } catch {
190
+ /* ignore */
191
+ }
192
+ };
193
+
194
+ try {
195
+ monaco.editor.remeasureFonts();
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ layout();
200
+ requestAnimationFrame(() => {
201
+ try {
202
+ monaco.editor.remeasureFonts();
203
+ } catch {
204
+ /* ignore */
205
+ }
206
+ layout();
207
+ });
208
+ if (document.fonts?.ready) {
209
+ void document.fonts.ready.then(() => {
210
+ if (seq !== mountSeq) return;
211
+ try {
212
+ monaco.editor.remeasureFonts();
213
+ } catch {
214
+ /* ignore */
215
+ }
216
+ layout();
217
+ });
218
+ }
219
+
220
+ let ro: ResizeObserver | null = null;
221
+ if (typeof ResizeObserver !== 'undefined') {
222
+ ro = new ResizeObserver(() => layout());
223
+ ro.observe(el);
224
+ }
225
+
24
226
  if (typeof opts.onChange === 'function') {
25
227
  editor.onDidChangeModelContent(() => {
26
228
  opts.onChange?.(editor.getValue());
27
229
  });
28
230
  }
29
- return {
231
+
232
+ const api = {
30
233
  editor,
234
+ monaco,
31
235
  getValue: () => editor.getValue(),
32
- setValue: (v: string) => editor.setValue(v ?? ''),
33
- dispose: () => editor.dispose(),
236
+ setValue: (v: string) => {
237
+ const next = v ?? '';
238
+ if (editor.getValue() === next) return;
239
+ // Full replace must NOT restore the previous selection — stale ranges
240
+ // paint ghost overlays (yellow bars / black bands) after content swaps.
241
+ editor.pushUndoStop();
242
+ editor.executeEdits(
243
+ 'vmz-setValue',
244
+ [
245
+ {
246
+ range: model.getFullModelRange(),
247
+ text: next,
248
+ forceMoveMarkers: true,
249
+ },
250
+ ],
251
+ [new monaco.Selection(1, 1, 1, 1)],
252
+ );
253
+ editor.pushUndoStop();
254
+ editor.revealPositionInCenterIfOutsideViewport({
255
+ lineNumber: 1,
256
+ column: 1,
257
+ });
258
+ },
259
+ dispose: () => {
260
+ try {
261
+ ro?.disconnect();
262
+ } catch {
263
+ /* ignore */
264
+ }
265
+ ro = null;
266
+ try {
267
+ editor.dispose();
268
+ } catch {
269
+ /* ignore */
270
+ }
271
+ try {
272
+ model.dispose();
273
+ } catch {
274
+ /* ignore */
275
+ }
276
+ },
34
277
  };
278
+ (el as HTMLElement & { __vmzMonaco?: typeof api }).__vmzMonaco = api;
279
+ return api;
35
280
  }