mrmd-editor 0.7.1 → 0.8.0

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.
Files changed (58) hide show
  1. package/package.json +3 -1
  2. package/src/commands.js +112 -4
  3. package/src/comment-syntax.js +364 -39
  4. package/src/config/handlers.js +1 -2
  5. package/src/config/schema.js +46 -4
  6. package/src/document-template.js +2236 -0
  7. package/src/frontmatter-updater.js +204 -74
  8. package/src/grammar.js +758 -0
  9. package/src/index.js +1074 -55
  10. package/src/keymap.js +11 -2
  11. package/src/markdown/block-decorations.js +108 -5
  12. package/src/markdown/facets.js +37 -0
  13. package/src/markdown/html-inline.js +9 -5
  14. package/src/markdown/index.js +13 -3
  15. package/src/markdown/inline-commands.js +256 -0
  16. package/src/markdown/inline-model.js +578 -0
  17. package/src/markdown/inline-state.js +103 -0
  18. package/src/markdown/renderer.js +219 -12
  19. package/src/markdown/styles.js +290 -3
  20. package/src/markdown/widgets/alert-title.js +10 -8
  21. package/src/markdown/widgets/frontmatter.js +0 -6
  22. package/src/markdown/widgets/index.js +1 -0
  23. package/src/markdown/widgets/list-marker.js +29 -0
  24. package/src/markdown/wysiwyg.js +1158 -0
  25. package/src/mrp-types.js +2 -0
  26. package/src/output-widget.js +532 -18
  27. package/src/page-view-pagination.js +127 -0
  28. package/src/runtime-lsp.js +1757 -150
  29. package/src/section-controls/commands.js +617 -0
  30. package/src/section-controls/index.js +63 -0
  31. package/src/section-controls/plugin.js +165 -0
  32. package/src/section-controls/widgets.js +936 -0
  33. package/src/shell/ai-menu.js +11 -0
  34. package/src/shell/components/context-panel.js +572 -0
  35. package/src/shell/components/status-bar.js +10 -2
  36. package/src/shell/layouts/studio.js +206 -14
  37. package/src/shell/orchestrator-client.js +69 -0
  38. package/src/spellcheck.js +166 -0
  39. package/src/tables/README.md +97 -0
  40. package/src/tables/commands/insert-linked-table.js +122 -0
  41. package/src/tables/commands/open-table-workspace.js +43 -0
  42. package/src/tables/index.js +24 -0
  43. package/src/tables/jobs/client.js +158 -0
  44. package/src/tables/parsing/anchors.js +82 -0
  45. package/src/tables/parsing/linked-table-blocks.js +61 -0
  46. package/src/tables/state/linked-table-state.js +68 -0
  47. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  48. package/src/tables/widgets/linked-table-widget.js +256 -0
  49. package/src/tables/workspace/controller.js +616 -0
  50. package/src/term-pty-client.js +51 -2
  51. package/src/term-widget.js +43 -3
  52. package/src/widgets/theme-utils.js +24 -16
  53. package/src/widgets/theme.js +1015 -1
  54. package/src/runtime-codelens/detector.js +0 -279
  55. package/src/runtime-codelens/index.js +0 -76
  56. package/src/runtime-codelens/plugin.js +0 -142
  57. package/src/runtime-codelens/styles.js +0 -184
  58. package/src/runtime-codelens/widgets.js +0 -216
@@ -0,0 +1,936 @@
1
+ /**
2
+ * Section Controls Floating DOM
3
+ */
4
+
5
+ import { showCtrlKModal } from '../ctrl-k-modal.js';
6
+ import { findCodeBlockAtPosition } from '../cells.js';
7
+ import { extractComments, findNearestComment } from '../comment-syntax.js';
8
+ import {
9
+ toggleBold,
10
+ toggleItalic,
11
+ toggleUnderline,
12
+ toggleStrikethrough,
13
+ toggleInlineCode,
14
+ fixGrammar,
15
+ finishLine,
16
+ finishSection,
17
+ FORMATTING_COMMAND_DEFINITIONS,
18
+ executeFormattingDefinition,
19
+ AI_COMMAND_DEFINITIONS,
20
+ executeAiDefinition,
21
+ } from './commands.js';
22
+
23
+ let activeCommandMenu = null;
24
+ let commandMenuItemOrder = 0;
25
+
26
+ function isMacLikePlatform() {
27
+ const platform = navigator?.platform || '';
28
+ const ua = navigator?.userAgent || '';
29
+ return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X/i.test(ua);
30
+ }
31
+
32
+ function formatShortcut(shortcut) {
33
+ if (!shortcut) return '';
34
+ const mod = isMacLikePlatform() ? 'Cmd' : 'Ctrl';
35
+ return shortcut.replace(/Mod-/g, `${mod}+`).replace(/-/g, '+');
36
+ }
37
+
38
+ function normalizeSearchText(value) {
39
+ return String(value || '')
40
+ .toLowerCase()
41
+ .normalize('NFKD')
42
+ .replace(/[\u0300-\u036f]/g, '')
43
+ .trim();
44
+ }
45
+
46
+ function findLiteralMatchIndex(query, target) {
47
+ if (!query || !target) return -1;
48
+ return target.indexOf(query);
49
+ }
50
+
51
+ function compactFuzzyScore(query, target) {
52
+ let qi = 0;
53
+ let score = 0;
54
+ let consecutive = 0;
55
+ let prevMatch = -1;
56
+ const matches = [];
57
+
58
+ for (let i = 0; i < target.length && qi < query.length; i++) {
59
+ if (target[i] !== query[qi]) continue;
60
+
61
+ matches.push(i);
62
+ score += 8;
63
+ if (prevMatch === i - 1) {
64
+ consecutive += 1;
65
+ score += 10 + consecutive * 2;
66
+ } else {
67
+ consecutive = 0;
68
+ }
69
+ if (i === 0 || /[\s(/:+-]/.test(target[i - 1])) {
70
+ score += 12;
71
+ }
72
+
73
+ prevMatch = i;
74
+ qi += 1;
75
+ }
76
+
77
+ if (qi !== query.length || !matches.length) return 0;
78
+
79
+ const spread = matches[matches.length - 1] - matches[0] + 1;
80
+ const maxSpread = Math.max(query.length + 2, Math.ceil(query.length * 1.6));
81
+ if (spread > maxSpread) return 0;
82
+
83
+ return score - spread;
84
+ }
85
+
86
+ function scoreCommandSearch(query, target) {
87
+ const q = normalizeSearchText(query);
88
+ const text = normalizeSearchText(target);
89
+ if (!q) return 1;
90
+ if (!text) return 0;
91
+
92
+ const exactIdx = findLiteralMatchIndex(q, text);
93
+ if (exactIdx >= 0) {
94
+ return 1000 - exactIdx * 8 - Math.max(0, text.length - q.length);
95
+ }
96
+
97
+ if (q.length < 3) return 0;
98
+ return compactFuzzyScore(q, text);
99
+ }
100
+
101
+ /**
102
+ * Build floating section-controls DOM.
103
+ * @param {import('@codemirror/view').EditorView} view
104
+ * @param {{editor: any, showAi: boolean, showFormatting: boolean}} options
105
+ */
106
+ export function createSectionControlsDom(view, options) {
107
+ const mode = options.mode || 'dots-click';
108
+ const root = document.createElement('div');
109
+ root.className = `cm-section-controls-floating-root mode-${mode}`;
110
+ root.__cmSectionControlsView = view;
111
+
112
+ // Dots indicator (used by dots-hover and dots-click modes)
113
+ if (mode !== 'full') {
114
+ const dots = document.createElement('button');
115
+ dots.className = 'cm-section-controls-dots';
116
+ dots.type = 'button';
117
+ dots.textContent = '⋯';
118
+ dots.title = `Commands (${formatShortcut("Mod-'")})`;
119
+ wireButtonEvents(dots, () => {
120
+ openSectionControlsMenu(view, options.editor, { anchorEl: dots, root });
121
+ });
122
+ root.appendChild(dots);
123
+ }
124
+
125
+ // Toolbar (used by full and dots-hover modes)
126
+ if (mode !== 'dots-click') {
127
+ const toolbar = document.createElement('div');
128
+ toolbar.className = 'cm-section-controls-toolbar';
129
+ root.appendChild(toolbar);
130
+
131
+ if (options.showFormatting) {
132
+ const group = document.createElement('div');
133
+ group.className = 'cm-section-controls-group formatting';
134
+ group.append(
135
+ createButton('B', `Bold (${formatShortcut('Mod-B')})`, () => toggleBold(view), 'bold'),
136
+ createButton('I', `Italic (${formatShortcut('Mod-I')})`, () => toggleItalic(view), 'italic'),
137
+ createButton('U', `Underline (${formatShortcut('Mod-U')})`, () => toggleUnderline(view), 'underline'),
138
+ createButton('S', 'Strikethrough', () => toggleStrikethrough(view), 'strikethrough'),
139
+ createButton('</>', 'Inline Code (' + formatShortcut('Mod-`') + ')', () => toggleInlineCode(view), 'code'),
140
+ );
141
+ toolbar.appendChild(group);
142
+ }
143
+
144
+ if (options.showAi) {
145
+ const group = document.createElement('div');
146
+ group.className = 'cm-section-controls-group ai';
147
+ group.append(
148
+ createIconButton('grammar', `Fix Grammar (${formatShortcut('Mod-G')})`, () => fixGrammar(options.editor)(view), 'ai-grammar'),
149
+ createIconButton('line', `Finish Line (${formatShortcut('Mod-L')})`, () => finishLine(options.editor)(view), 'ai-finish-line'),
150
+ createIconButton('section', `Finish Section (${formatShortcut('Mod-O')})`, () => finishSection(options.editor)(view), 'ai-finish-section'),
151
+ createButton('…', `All Commands (${formatShortcut("Mod-'")})`, () => {
152
+ openSectionControlsMenu(view, options.editor, { anchorEl: group, root });
153
+ }, 'more'),
154
+ );
155
+ toolbar.appendChild(group);
156
+ }
157
+ }
158
+
159
+ return root;
160
+ }
161
+
162
+ function createButton(text, title, onClick, className) {
163
+ const btn = document.createElement('button');
164
+ btn.className = `cm-section-controls-btn ${className || ''}`;
165
+ btn.type = 'button';
166
+ btn.textContent = text;
167
+ btn.title = title;
168
+
169
+ wireButtonEvents(btn, onClick);
170
+ return btn;
171
+ }
172
+
173
+ function createIconButton(iconName, title, onClick, className) {
174
+ const btn = document.createElement('button');
175
+ btn.className = `cm-section-controls-btn ${className || ''}`;
176
+ btn.type = 'button';
177
+ btn.title = title;
178
+ btn.appendChild(createIconSvg(iconName));
179
+
180
+ wireButtonEvents(btn, onClick);
181
+ return btn;
182
+ }
183
+
184
+ function wireButtonEvents(btn, onClick) {
185
+ // Keep cursor/selection stable on click.
186
+ btn.addEventListener('mousedown', (e) => {
187
+ e.preventDefault();
188
+ e.stopPropagation();
189
+ });
190
+
191
+ btn.addEventListener('click', (e) => {
192
+ e.preventDefault();
193
+ e.stopPropagation();
194
+ onClick(e);
195
+ });
196
+ }
197
+
198
+ function createIconSvg(name) {
199
+ const ns = 'http://www.w3.org/2000/svg';
200
+ const svg = document.createElementNS(ns, 'svg');
201
+ svg.setAttribute('viewBox', '0 0 16 16');
202
+ svg.setAttribute('fill', 'none');
203
+ svg.setAttribute('stroke', 'currentColor');
204
+ svg.setAttribute('stroke-width', '1.5');
205
+ svg.setAttribute('stroke-linecap', 'round');
206
+ svg.setAttribute('stroke-linejoin', 'round');
207
+ svg.setAttribute('aria-hidden', 'true');
208
+
209
+ const path = (d) => {
210
+ const p = document.createElementNS(ns, 'path');
211
+ p.setAttribute('d', d);
212
+ svg.appendChild(p);
213
+ };
214
+
215
+ if (name === 'grammar') {
216
+ const c = document.createElementNS(ns, 'circle');
217
+ c.setAttribute('cx', '8');
218
+ c.setAttribute('cy', '8');
219
+ c.setAttribute('r', '5');
220
+ svg.appendChild(c);
221
+ path('M5.8 8.1l1.5 1.5 3-3');
222
+ } else if (name === 'search') {
223
+ const c = document.createElementNS(ns, 'circle');
224
+ c.setAttribute('cx', '7');
225
+ c.setAttribute('cy', '7');
226
+ c.setAttribute('r', '3.75');
227
+ svg.appendChild(c);
228
+ path('M10 10l2.5 2.5');
229
+ } else if (name === 'line') {
230
+ path('M3 8h8');
231
+ path('M9 5l3 3-3 3');
232
+ } else if (name === 'section') {
233
+ path('M8 3v8');
234
+ path('M5 9.5L8 12.5l3-3');
235
+ } else if (name === 'wand') {
236
+ path('M3.5 12.5l9-9');
237
+ path('M10.5 2.5v2');
238
+ path('M12.5 4.5h-2');
239
+ } else if (name === 'format') {
240
+ path('M3 4h10');
241
+ path('M5 8h6');
242
+ path('M7 12h2');
243
+ } else if (name === 'doc') {
244
+ path('M5 2.5h4l2 2v9H5z');
245
+ path('M9 2.5v2h2');
246
+ } else if (name === 'code') {
247
+ path('M6 5L3.5 8 6 11');
248
+ path('M10 5l2.5 3-2.5 3');
249
+ } else if (name === 'type') {
250
+ path('M4 4h8');
251
+ path('M8 4v8');
252
+ } else if (name === 'rename') {
253
+ path('M3 12h3l6-6-3-3-6 6z');
254
+ } else if (name === 'comment') {
255
+ path('M3 4h10v6H7l-3 2z');
256
+ } else if (name === 'refactor') {
257
+ path('M4.5 6.5A3.5 3.5 0 1 1 8 11.5');
258
+ path('M3.5 6.5h2v-2');
259
+ } else if (name === 'quote') {
260
+ path('M4.5 5.5h2v2h-2v3h3v-5h-3z');
261
+ path('M9.5 5.5h2v2h-2v3h3v-5h-3z');
262
+ } else if (name === 'table') {
263
+ path('M2.5 3.5h11v9h-11z');
264
+ path('M2.5 6.5h11');
265
+ path('M2.5 9.5h11');
266
+ path('M6.5 3.5v9');
267
+ path('M9.5 3.5v9');
268
+ } else if (name === 'list') {
269
+ path('M5.5 4h7');
270
+ path('M5.5 8h7');
271
+ path('M5.5 12h7');
272
+ path('M3.2 4h.2');
273
+ path('M3.2 8h.2');
274
+ path('M3.2 12h.2');
275
+ } else if (name === 'list-number') {
276
+ path('M6 4h6.5');
277
+ path('M6 8h6.5');
278
+ path('M6 12h6.5');
279
+ path('M2.8 4h1v2h-1');
280
+ path('M2.7 8.8c.2-.7 1.3-.7 1.5 0 .1.5-.2.9-.8 1.3-.5.3-.8.6-.8.9h1.7');
281
+ path('M2.7 11.7h1.5l-.7 1.2h.7');
282
+ } else if (name === 'checklist') {
283
+ path('M2.5 3.5h11v9h-11z');
284
+ path('M4.3 6.8l1 1 1.8-1.8');
285
+ path('M8.5 7h3');
286
+ path('M8.5 10h3');
287
+ } else if (name === 'heading') {
288
+ path('M3.5 4v8');
289
+ path('M7.5 4v8');
290
+ path('M3.5 8h4');
291
+ path('M10 12V4l2 2');
292
+ } else if (name === 'minus') {
293
+ path('M3 8h10');
294
+ }
295
+
296
+ return svg;
297
+ }
298
+
299
+ function getRootForView(view) {
300
+ const roots = document.querySelectorAll('.cm-section-controls-floating-root');
301
+ for (const root of roots) {
302
+ if (root.__cmSectionControlsView === view) return root;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ export function closeSectionControlsMenu() {
308
+ if (!activeCommandMenu) return;
309
+ const { menu, onDocPointerDown, onDocKeydown, root } = activeCommandMenu;
310
+ document.removeEventListener('pointerdown', onDocPointerDown, true);
311
+ document.removeEventListener('keydown', onDocKeydown, true);
312
+ root?.classList.remove('menu-open');
313
+ menu.remove();
314
+ activeCommandMenu = null;
315
+ }
316
+
317
+ export function openSectionControlsMenu(view, editor, options = {}) {
318
+ // Toggle behavior: if already open for this view, close.
319
+ if (activeCommandMenu?.view === view) {
320
+ closeSectionControlsMenu();
321
+ return true;
322
+ }
323
+
324
+ if (activeCommandMenu) closeSectionControlsMenu();
325
+
326
+ const root = options.root || getRootForView(view);
327
+ const anchorEl = options.anchorEl
328
+ || root?.querySelector('.cm-section-controls-group.ai')
329
+ || root?.querySelector('.cm-section-controls-toolbar')
330
+ || root;
331
+
332
+ if (!root || !anchorEl) return false;
333
+
334
+ const menu = document.createElement('div');
335
+ menu.className = 'cm-section-controls-menu';
336
+
337
+ const header = document.createElement('div');
338
+ header.className = 'cm-section-controls-menu-header';
339
+ header.textContent = 'All Commands';
340
+ menu.appendChild(header);
341
+
342
+ const search = document.createElement('div');
343
+ search.className = 'cm-section-controls-menu-search';
344
+ const searchIcon = document.createElement('span');
345
+ searchIcon.className = 'cm-section-controls-menu-search-icon';
346
+ searchIcon.appendChild(createIconSvg('search'));
347
+ search.appendChild(searchIcon);
348
+
349
+ const searchInput = document.createElement('input');
350
+ searchInput.className = 'cm-section-controls-menu-search-input';
351
+ searchInput.type = 'text';
352
+ searchInput.placeholder = 'Search commands…';
353
+ searchInput.autocomplete = 'off';
354
+ searchInput.spellcheck = false;
355
+ searchInput.setAttribute('aria-label', 'Search commands');
356
+ search.appendChild(searchInput);
357
+ menu.appendChild(search);
358
+
359
+ const formattingSection = document.createElement('div');
360
+ formattingSection.className = 'cm-section-controls-menu-section';
361
+ formattingSection.appendChild(sectionTitle('Formatting'));
362
+ const hasSelection = !view.state.selection.main.empty;
363
+ for (const def of FORMATTING_COMMAND_DEFINITIONS) {
364
+ const requiresSelection = def.id === 'uppercase' || def.id === 'lowercase' || def.id === 'titlecase';
365
+ formattingSection.appendChild(menuItem({
366
+ label: def.label,
367
+ shortcut: formatShortcut(def.shortcut || ''),
368
+ icon: def.icon || 'format',
369
+ disabled: requiresSelection && !hasSelection,
370
+ onClick: () => executeFormattingDefinition(view, def),
371
+ }));
372
+ }
373
+ menu.appendChild(formattingSection);
374
+
375
+ const docText = view.state.doc.toString();
376
+ const cursorPos = view.state.selection.main.head;
377
+ const inCode = !!findCodeBlockAtPosition(docText, cursorPos);
378
+ const hasComments = extractComments(docText).length > 0;
379
+ const hasNearbyComment = !!findNearestComment(docText, cursorPos);
380
+
381
+ const aiSection = document.createElement('div');
382
+ aiSection.className = 'cm-section-controls-menu-section';
383
+ aiSection.appendChild(sectionTitle('AI'));
384
+
385
+ for (const def of AI_COMMAND_DEFINITIONS) {
386
+ aiSection.appendChild(menuItem({
387
+ label: def.label,
388
+ shortcut: formatShortcut(def.shortcut || ''),
389
+ icon: def.icon || 'wand',
390
+ disabled: (!!def.codeOnly && !inCode) || (!!def.requiresComments && !hasComments) || (!!def.requiresNearbyComment && !hasNearbyComment),
391
+ onClick: () => { void executeAiDefinition(view, editor, def); },
392
+ }));
393
+ }
394
+
395
+ aiSection.appendChild(menuItem({
396
+ label: 'Custom Prompt…',
397
+ shortcut: formatShortcut('Mod-K'),
398
+ icon: 'wand',
399
+ onClick: () => showCtrlKModal(view),
400
+ }));
401
+
402
+ menu.appendChild(aiSection);
403
+
404
+ const emptyState = document.createElement('div');
405
+ emptyState.className = 'cm-section-controls-menu-empty';
406
+ emptyState.textContent = 'No matching commands';
407
+ emptyState.hidden = true;
408
+ menu.appendChild(emptyState);
409
+
410
+ document.body.appendChild(menu);
411
+
412
+ // Position near the existing toolbar for a "grow" feel.
413
+ const anchorRect = anchorEl.getBoundingClientRect();
414
+ menu.style.visibility = 'hidden';
415
+ menu.style.left = `${anchorRect.right}px`;
416
+ menu.style.top = `${anchorRect.bottom + 6}px`;
417
+
418
+ const rect = menu.getBoundingClientRect();
419
+ const viewportPadding = 8;
420
+ const gap = 6;
421
+
422
+ let left = anchorRect.right - rect.width;
423
+ let top = anchorRect.bottom + gap;
424
+ let flippedY = false;
425
+
426
+ if (left < viewportPadding) {
427
+ const alternateLeft = anchorRect.left;
428
+ if (alternateLeft + rect.width <= window.innerWidth - viewportPadding) {
429
+ left = alternateLeft;
430
+ }
431
+ }
432
+
433
+ if (top + rect.height > window.innerHeight - viewportPadding) {
434
+ const alternateTop = anchorRect.top - rect.height - gap;
435
+ if (alternateTop >= viewportPadding) {
436
+ top = alternateTop;
437
+ flippedY = true;
438
+ }
439
+ }
440
+
441
+ left = Math.max(viewportPadding, Math.min(left, window.innerWidth - rect.width - viewportPadding));
442
+ top = Math.max(viewportPadding, Math.min(top, window.innerHeight - rect.height - viewportPadding));
443
+
444
+ const anchorCenterX = anchorRect.left + (anchorRect.width / 2);
445
+ const originX = Math.max(12, Math.min(rect.width - 12, anchorCenterX - left));
446
+ const originY = flippedY ? rect.height : 0;
447
+
448
+ menu.style.left = `${left}px`;
449
+ menu.style.top = `${top}px`;
450
+ menu.style.transformOrigin = `${originX}px ${originY}px`;
451
+ menu.style.visibility = '';
452
+
453
+ root.classList.add('menu-open');
454
+
455
+ const focusableItems = () => Array.from(menu.querySelectorAll('.cm-section-controls-menu-item:not(:disabled):not([hidden])'));
456
+ const setActive = (idx, { focus = true } = {}) => {
457
+ const items = focusableItems();
458
+ if (!items.length) return;
459
+ const clamped = Math.max(0, Math.min(idx, items.length - 1));
460
+ items.forEach((item) => item.classList.remove('is-active'));
461
+ items[clamped].classList.add('is-active');
462
+ if (focus) items[clamped].focus({ preventScroll: true });
463
+ return clamped;
464
+ };
465
+
466
+ const applyFilter = (query) => {
467
+ const normalizedQuery = normalizeSearchText(query);
468
+ let visibleCount = 0;
469
+ let shownCount = 0;
470
+ const hasQuery = !!normalizedQuery;
471
+ const maxVisible = hasQuery ? 8 : Number.POSITIVE_INFINITY;
472
+
473
+ for (const section of menu.querySelectorAll('.cm-section-controls-menu-section')) {
474
+ const title = section.querySelector('.cm-section-controls-menu-title');
475
+ const scoredItems = Array.from(section.querySelectorAll('.cm-section-controls-menu-item')).map((item, index) => ({
476
+ item,
477
+ index,
478
+ order: Number(item.dataset.searchOrder || index),
479
+ score: scoreCommandSearch(normalizedQuery, item.dataset.searchText || ''),
480
+ }));
481
+
482
+ const visibleItems = hasQuery
483
+ ? scoredItems.filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score || a.order - b.order)
484
+ : scoredItems.sort((a, b) => a.order - b.order);
485
+
486
+ scoredItems.forEach(({ item }) => { item.hidden = true; });
487
+
488
+ const itemsToShow = visibleItems.slice(0, Math.max(0, maxVisible - shownCount));
489
+ for (const { item } of itemsToShow) {
490
+ item.hidden = false;
491
+ section.appendChild(item);
492
+ }
493
+
494
+ const sectionVisible = itemsToShow.length;
495
+ section.hidden = sectionVisible === 0;
496
+ if (title) title.hidden = sectionVisible === 0;
497
+ visibleCount += sectionVisible;
498
+ shownCount += sectionVisible;
499
+ }
500
+
501
+ emptyState.hidden = visibleCount !== 0;
502
+ activeIndex = setActive(0, { focus: false }) ?? 0;
503
+ };
504
+
505
+ let activeIndex = 0;
506
+ searchInput.addEventListener('input', () => applyFilter(searchInput.value));
507
+ applyFilter('');
508
+ queueMicrotask(() => {
509
+ searchInput.focus({ preventScroll: true });
510
+ searchInput.select();
511
+ });
512
+
513
+ const onDocPointerDown = (e) => {
514
+ if (!menu.contains(e.target)) closeSectionControlsMenu();
515
+ };
516
+
517
+ const onDocKeydown = (e) => {
518
+ if (!activeCommandMenu || activeCommandMenu.menu !== menu) return;
519
+
520
+ const focusIsSearch = document.activeElement === searchInput;
521
+
522
+ if (e.key === 'Escape') {
523
+ e.preventDefault();
524
+ if (focusIsSearch && searchInput.value) {
525
+ searchInput.value = '';
526
+ applyFilter('');
527
+ return;
528
+ }
529
+ closeSectionControlsMenu();
530
+ return;
531
+ }
532
+
533
+ const items = focusableItems();
534
+ if (!items.length) return;
535
+
536
+ if (e.key === 'ArrowDown') {
537
+ e.preventDefault();
538
+ activeIndex = setActive((activeIndex + 1) % items.length, { focus: !focusIsSearch });
539
+ } else if (e.key === 'ArrowUp') {
540
+ e.preventDefault();
541
+ activeIndex = setActive((activeIndex - 1 + items.length) % items.length, { focus: !focusIsSearch });
542
+ } else if (e.key === 'Home') {
543
+ e.preventDefault();
544
+ activeIndex = setActive(0, { focus: !focusIsSearch });
545
+ } else if (e.key === 'End') {
546
+ e.preventDefault();
547
+ activeIndex = setActive(items.length - 1, { focus: !focusIsSearch });
548
+ } else if ((e.key === 'Enter' || e.key === ' ') && document.activeElement?.classList.contains('cm-section-controls-menu-item')) {
549
+ e.preventDefault();
550
+ document.activeElement.click();
551
+ } else if (e.key === 'Enter' && focusIsSearch) {
552
+ e.preventDefault();
553
+ items[activeIndex]?.click();
554
+ }
555
+ };
556
+
557
+ document.addEventListener('pointerdown', onDocPointerDown, true);
558
+ document.addEventListener('keydown', onDocKeydown, true);
559
+
560
+ activeCommandMenu = { menu, onDocPointerDown, onDocKeydown, root, view };
561
+ return true;
562
+ }
563
+
564
+ function sectionTitle(text) {
565
+ const el = document.createElement('div');
566
+ el.className = 'cm-section-controls-menu-title';
567
+ el.textContent = text;
568
+ return el;
569
+ }
570
+
571
+ function menuItem({ label, shortcut, icon, onClick, disabled = false }) {
572
+ const btn = document.createElement('button');
573
+ btn.className = 'cm-section-controls-menu-item';
574
+ btn.type = 'button';
575
+ btn.disabled = !!disabled;
576
+
577
+ const left = document.createElement('span');
578
+ left.className = 'cm-section-controls-menu-item-main';
579
+
580
+ const iconWrap = document.createElement('span');
581
+ iconWrap.className = 'cm-section-controls-menu-item-icon';
582
+ iconWrap.appendChild(createIconSvg(icon || 'wand'));
583
+ left.appendChild(iconWrap);
584
+
585
+ const labelEl = document.createElement('span');
586
+ labelEl.className = 'cm-section-controls-menu-item-label';
587
+ labelEl.textContent = label;
588
+ left.appendChild(labelEl);
589
+
590
+ btn.appendChild(left);
591
+
592
+ if (shortcut) {
593
+ const keyEl = document.createElement('span');
594
+ keyEl.className = 'cm-section-controls-menu-item-shortcut';
595
+ keyEl.textContent = shortcut;
596
+ btn.appendChild(keyEl);
597
+ }
598
+
599
+ btn.dataset.searchText = `${label} ${shortcut || ''}`;
600
+ btn.dataset.searchOrder = String(commandMenuItemOrder++);
601
+
602
+ btn.addEventListener('mousedown', (e) => {
603
+ e.preventDefault();
604
+ e.stopPropagation();
605
+ });
606
+
607
+ btn.addEventListener('click', (e) => {
608
+ if (btn.disabled) return;
609
+ e.preventDefault();
610
+ e.stopPropagation();
611
+ onClick();
612
+ closeSectionControlsMenu();
613
+ });
614
+
615
+ return btn;
616
+ }
617
+
618
+ export const sectionControlsStyles = `
619
+ .cm-section-controls-floating-root {
620
+ position: fixed;
621
+ z-index: 50;
622
+ pointer-events: auto;
623
+ }
624
+
625
+ /* ---- Dots indicator ---- */
626
+ .cm-section-controls-dots {
627
+ display: inline-flex;
628
+ align-items: center;
629
+ justify-content: center;
630
+ width: 24px;
631
+ height: 24px;
632
+ border: none;
633
+ border-radius: 50%;
634
+ background: transparent;
635
+ color: var(--text-dim, #6e7681);
636
+ font-size: 18px;
637
+ line-height: 1;
638
+ letter-spacing: 1px;
639
+ cursor: pointer;
640
+ opacity: 0.45;
641
+ transition: opacity 0.18s ease, background 0.15s ease, color 0.15s ease;
642
+ pointer-events: auto;
643
+ }
644
+
645
+ .cm-section-controls-dots:hover {
646
+ opacity: 0.75;
647
+ background: color-mix(in srgb, var(--hover-bg, #30363d) 50%, transparent);
648
+ color: var(--text-muted, #8b949e);
649
+ }
650
+
651
+ /* ---- Toolbar ---- */
652
+ .cm-section-controls-toolbar {
653
+ display: inline-flex;
654
+ align-items: center;
655
+ gap: 8px;
656
+ opacity: 0.84;
657
+ transition: opacity 0.16s ease, transform 0.16s ease;
658
+ transform: translateY(0) scale(1);
659
+ background: color-mix(in srgb, var(--bg-secondary, #1f2328) 90%, transparent);
660
+ border: 1px solid color-mix(in srgb, var(--border, #3d444d) 78%, transparent);
661
+ border-radius: 10px;
662
+ padding: 4px 8px;
663
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24);
664
+ backdrop-filter: blur(3px);
665
+ }
666
+
667
+ .cm-section-controls-toolbar:hover {
668
+ opacity: 1;
669
+ transform: translateY(-1px);
670
+ }
671
+
672
+ /* ===========================================================================
673
+ MODE: full — always show toolbar, no dots
674
+ =========================================================================== */
675
+ .cm-section-controls-floating-root.mode-full .cm-section-controls-dots {
676
+ display: none;
677
+ }
678
+
679
+ .cm-section-controls-floating-root.mode-full .cm-section-controls-toolbar {
680
+ display: inline-flex;
681
+ }
682
+
683
+ /* ===========================================================================
684
+ MODE: dots-hover — dots by default, toolbar on hover
685
+ =========================================================================== */
686
+ .cm-section-controls-floating-root.mode-dots-hover .cm-section-controls-toolbar {
687
+ display: none;
688
+ opacity: 0;
689
+ transform: translateY(2px) scale(0.96);
690
+ pointer-events: none;
691
+ }
692
+
693
+ .cm-section-controls-floating-root.mode-dots-hover:hover .cm-section-controls-dots {
694
+ display: none;
695
+ }
696
+
697
+ .cm-section-controls-floating-root.mode-dots-hover:hover .cm-section-controls-toolbar {
698
+ display: inline-flex;
699
+ opacity: 1;
700
+ transform: translateY(0) scale(1);
701
+ pointer-events: auto;
702
+ }
703
+
704
+ /* ===========================================================================
705
+ MODE: dots-click — dots only, click opens palette (no toolbar)
706
+ =========================================================================== */
707
+ /* (dots-click has no toolbar in DOM, so no extra rules needed) */
708
+
709
+ /* ===========================================================================
710
+ SHARED: menu-open state hides both dots and toolbar
711
+ =========================================================================== */
712
+ .cm-section-controls-floating-root.menu-open .cm-section-controls-dots {
713
+ display: none;
714
+ }
715
+
716
+ .cm-section-controls-floating-root.menu-open .cm-section-controls-toolbar {
717
+ opacity: 0;
718
+ transform: translateY(-2px) scale(0.96);
719
+ pointer-events: none;
720
+ }
721
+
722
+ .cm-section-controls-group {
723
+ display: inline-flex;
724
+ align-items: center;
725
+ gap: 3px;
726
+ }
727
+
728
+ .cm-section-controls-btn {
729
+ width: 26px;
730
+ height: 26px;
731
+ border: none;
732
+ border-radius: 8px;
733
+ background: transparent;
734
+ color: var(--text-muted, #8b949e);
735
+ display: inline-flex;
736
+ align-items: center;
737
+ justify-content: center;
738
+ font-size: 13px;
739
+ line-height: 1;
740
+ cursor: pointer;
741
+ transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
742
+ }
743
+
744
+ .cm-section-controls-btn svg {
745
+ width: 14px;
746
+ height: 14px;
747
+ display: block;
748
+ }
749
+
750
+ .cm-section-controls-btn:hover {
751
+ background: color-mix(in srgb, var(--hover-bg, #30363d) 80%, transparent);
752
+ color: var(--text, #e6edf3);
753
+ transform: scale(1.05);
754
+ }
755
+
756
+ .cm-section-controls-btn.is-active {
757
+ background: color-mix(in srgb, var(--accent, #58a6ff) 18%, transparent);
758
+ border-color: color-mix(in srgb, var(--accent, #58a6ff) 45%, transparent);
759
+ color: var(--accent, #58a6ff);
760
+ }
761
+
762
+ .cm-section-controls-btn.is-mixed {
763
+ background: color-mix(in srgb, var(--accent, #58a6ff) 10%, transparent);
764
+ border-color: color-mix(in srgb, var(--accent, #58a6ff) 28%, transparent);
765
+ }
766
+
767
+ .cm-section-controls-btn.bold { font-weight: 700; }
768
+ .cm-section-controls-btn.italic { font-style: italic; }
769
+ .cm-section-controls-btn.underline { text-decoration: underline; }
770
+ .cm-section-controls-btn.strikethrough { text-decoration: line-through; }
771
+ .cm-section-controls-btn.code {
772
+ font-family: var(--widget-font-mono, monospace);
773
+ font-size: 0.82em;
774
+ }
775
+
776
+ .cm-section-controls-menu {
777
+ position: fixed;
778
+ z-index: 1002;
779
+ min-width: 320px;
780
+ max-width: 420px;
781
+ max-height: min(72vh, 620px);
782
+ overflow: auto;
783
+ overscroll-behavior: contain;
784
+ background: var(--bg-secondary, #1f2328);
785
+ border: 1px solid var(--border, #3d444d);
786
+ border-radius: 10px;
787
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.35);
788
+ animation: cm-section-controls-menu-grow 120ms ease-out;
789
+ }
790
+
791
+ @keyframes cm-section-controls-menu-grow {
792
+ from {
793
+ opacity: 0;
794
+ transform: scale(0.96);
795
+ }
796
+ to {
797
+ opacity: 1;
798
+ transform: scale(1);
799
+ }
800
+ }
801
+
802
+ .cm-section-controls-menu-header {
803
+ padding: 10px 12px;
804
+ border-bottom: 1px solid var(--border, #3d444d);
805
+ color: var(--text, #e6edf3);
806
+ font-size: 12px;
807
+ font-weight: 600;
808
+ text-transform: uppercase;
809
+ letter-spacing: 0.4px;
810
+ }
811
+
812
+ .cm-section-controls-menu-search {
813
+ display: flex;
814
+ align-items: center;
815
+ gap: 8px;
816
+ padding: 8px 10px;
817
+ margin: 8px 8px 0;
818
+ border: 1px solid color-mix(in srgb, var(--border, #3d444d) 82%, transparent);
819
+ border-radius: 8px;
820
+ background: color-mix(in srgb, var(--bg, #161b22) 72%, transparent);
821
+ }
822
+
823
+ .cm-section-controls-menu-search-icon {
824
+ width: 14px;
825
+ height: 14px;
826
+ color: var(--text-muted, #8b949e);
827
+ display: inline-flex;
828
+ flex-shrink: 0;
829
+ }
830
+
831
+ .cm-section-controls-menu-search-icon svg {
832
+ width: 14px;
833
+ height: 14px;
834
+ }
835
+
836
+ .cm-section-controls-menu-search-input {
837
+ width: 100%;
838
+ border: none;
839
+ background: transparent;
840
+ color: var(--text, #e6edf3);
841
+ font-size: 12px;
842
+ outline: none;
843
+ }
844
+
845
+ .cm-section-controls-menu-search-input::placeholder {
846
+ color: var(--text-muted, #8b949e);
847
+ }
848
+
849
+ .cm-section-controls-menu-section {
850
+ padding: 8px;
851
+ }
852
+
853
+ .cm-section-controls-menu-empty {
854
+ padding: 12px 14px 14px;
855
+ color: var(--text-muted, #8b949e);
856
+ font-size: 12px;
857
+ }
858
+
859
+ .cm-section-controls-menu-title {
860
+ padding: 4px 6px 8px;
861
+ font-size: 11px;
862
+ color: var(--text-muted, #8b949e);
863
+ text-transform: uppercase;
864
+ letter-spacing: 0.4px;
865
+ }
866
+
867
+ .cm-section-controls-menu-item {
868
+ width: 100%;
869
+ display: flex;
870
+ align-items: center;
871
+ justify-content: space-between;
872
+ gap: 8px;
873
+ border: none;
874
+ border-radius: 8px;
875
+ background: transparent;
876
+ color: var(--text, #e6edf3);
877
+ padding: 8px 10px;
878
+ text-align: left;
879
+ cursor: pointer;
880
+ }
881
+
882
+ .cm-section-controls-menu-item[hidden] {
883
+ display: none !important;
884
+ }
885
+
886
+ .cm-section-controls-menu-item-main {
887
+ display: inline-flex;
888
+ align-items: center;
889
+ gap: 8px;
890
+ }
891
+
892
+ .cm-section-controls-menu-item-icon {
893
+ width: 16px;
894
+ height: 16px;
895
+ color: var(--text-muted, #8b949e);
896
+ display: inline-flex;
897
+ }
898
+
899
+ .cm-section-controls-menu-item-icon svg {
900
+ width: 16px;
901
+ height: 16px;
902
+ }
903
+
904
+ .cm-section-controls-menu-item:hover,
905
+ .cm-section-controls-menu-item.is-active {
906
+ background: var(--hover-bg, rgba(80, 90, 110, 0.35));
907
+ }
908
+
909
+ .cm-section-controls-menu-item:disabled {
910
+ opacity: 0.45;
911
+ cursor: not-allowed;
912
+ }
913
+
914
+ .cm-section-controls-menu-item:disabled:hover {
915
+ background: transparent;
916
+ }
917
+
918
+ .cm-section-controls-menu-item-shortcut {
919
+ color: var(--text-muted, #8b949e);
920
+ font-size: 11px;
921
+ }
922
+
923
+ @media (max-width: 768px) {
924
+ .cm-section-controls-floating-root { display: none !important; }
925
+ }
926
+ `;
927
+
928
+ let stylesInjected = false;
929
+ export function injectSectionControlsStyles() {
930
+ if (stylesInjected || document.querySelector('#cm-section-controls-styles')) return;
931
+ stylesInjected = true;
932
+ const style = document.createElement('style');
933
+ style.id = 'cm-section-controls-styles';
934
+ style.textContent = sectionControlsStyles;
935
+ document.head.appendChild(style);
936
+ }