@lapismd/mira-editor 0.0.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE.md +4 -0
  3. package/README.md +70 -0
  4. package/dist/assets/logo.png +0 -0
  5. package/dist/assets.d.ts +4 -0
  6. package/dist/features.d.ts +164 -0
  7. package/dist/features.d.ts.map +1 -0
  8. package/dist/features.js +348 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +7 -0
  12. package/dist/index.test.d.ts +2 -0
  13. package/dist/index.test.d.ts.map +1 -0
  14. package/dist/index.test.js +179 -0
  15. package/dist/mira-editor-about-dialog.svelte +28 -0
  16. package/dist/mira-editor-about-dialog.svelte.d.ts +7 -0
  17. package/dist/mira-editor-about-dialog.svelte.d.ts.map +1 -0
  18. package/dist/mira-editor-toolbar.svelte +805 -0
  19. package/dist/mira-editor-toolbar.svelte.d.ts +6 -0
  20. package/dist/mira-editor-toolbar.svelte.d.ts.map +1 -0
  21. package/dist/mira-editor-toolbar.test.d.ts +2 -0
  22. package/dist/mira-editor-toolbar.test.d.ts.map +1 -0
  23. package/dist/mira-editor-toolbar.test.js +199 -0
  24. package/dist/mira-editor.svelte +492 -0
  25. package/dist/mira-editor.svelte.d.ts +24 -0
  26. package/dist/mira-editor.svelte.d.ts.map +1 -0
  27. package/dist/mira-editor.test.d.ts +2 -0
  28. package/dist/mira-editor.test.d.ts.map +1 -0
  29. package/dist/mira-editor.test.js +208 -0
  30. package/dist/styles.css +165 -0
  31. package/dist/test-setup.d.ts +8 -0
  32. package/dist/test-setup.d.ts.map +1 -0
  33. package/dist/test-setup.js +31 -0
  34. package/dist/toolbar-model.d.ts +32 -0
  35. package/dist/toolbar-model.d.ts.map +1 -0
  36. package/dist/toolbar-model.js +148 -0
  37. package/dist/toolbar-model.test.d.ts +2 -0
  38. package/dist/toolbar-model.test.d.ts.map +1 -0
  39. package/dist/toolbar-model.test.js +46 -0
  40. package/dist/types.d.ts +88 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +1 -0
  43. package/dist/version.d.ts +3 -0
  44. package/dist/version.d.ts.map +1 -0
  45. package/dist/version.js +2 -0
  46. package/package.json +56 -0
@@ -0,0 +1,805 @@
1
+ <script lang="ts">
2
+ import BoldIcon from "@lucide/svelte/icons/bold";
3
+ import BookOpenIcon from "@lucide/svelte/icons/book-open";
4
+ import BracesIcon from "@lucide/svelte/icons/braces";
5
+ import CheckIcon from "@lucide/svelte/icons/check";
6
+ import CodeIcon from "@lucide/svelte/icons/code";
7
+ import CodeXmlIcon from "@lucide/svelte/icons/code-xml";
8
+ import Columns2Icon from "@lucide/svelte/icons/columns-2";
9
+ import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
10
+ import FileCodeIcon from "@lucide/svelte/icons/file-code";
11
+ import Heading1Icon from "@lucide/svelte/icons/heading-1";
12
+ import ImageIcon from "@lucide/svelte/icons/image";
13
+ import InfoIcon from "@lucide/svelte/icons/info";
14
+ import IndentIncreaseIcon from "@lucide/svelte/icons/indent-increase";
15
+ import ItalicIcon from "@lucide/svelte/icons/italic";
16
+ import LinkIcon from "@lucide/svelte/icons/link";
17
+ import ListIcon from "@lucide/svelte/icons/list";
18
+ import ListChecksIcon from "@lucide/svelte/icons/list-checks";
19
+ import ListOrderedIcon from "@lucide/svelte/icons/list-ordered";
20
+ import ListTreeIcon from "@lucide/svelte/icons/list-tree";
21
+ import PencilLineIcon from "@lucide/svelte/icons/pencil-line";
22
+ import QuoteIcon from "@lucide/svelte/icons/quote";
23
+ import SpaceIcon from "@lucide/svelte/icons/space";
24
+ import StrikethroughIcon from "@lucide/svelte/icons/strikethrough";
25
+ import Table2Icon from "@lucide/svelte/icons/table-2";
26
+ import TableCellsSplitIcon from "@lucide/svelte/icons/table-cells-split";
27
+ import WorkflowIcon from "@lucide/svelte/icons/workflow";
28
+ import { Separator } from "@lapismd/mira/ui/separator";
29
+ import * as DropdownMenu from "@lapismd/mira/ui/dropdown-menu";
30
+ import * as Toolbar from "@lapismd/mira/ui/toolbar";
31
+ import * as Tooltip from "@lapismd/mira/ui/tooltip";
32
+ import type { Component } from "svelte";
33
+ import type { MiraEditorSelection } from "@lapismd/mira/core";
34
+ import type { MiraMode } from "@lapismd/mira/extensions";
35
+ import {
36
+ MiraFeature,
37
+ defaultMiraEditorEditMode,
38
+ resolveMiraEditorFeatures,
39
+ resolveMiraEditorEditMode,
40
+ resolveMiraEditorModes,
41
+ resolveMiraEditorToolbarActions,
42
+ resolveMiraEditorToolbarDefinitions,
43
+ resolveMiraEditorToolbarItems,
44
+ type MiraEditorToolbarAction,
45
+ type MiraEditorToolbarActionContext,
46
+ type MiraEditorToolbarDefinition,
47
+ type MiraEditorToolbarDropdownAction,
48
+ type MiraEditorToolbarItem,
49
+ type MiraEditorToolbarMenuItem,
50
+ } from "./features";
51
+ import {
52
+ isMiraEditMode,
53
+ markdownActionForMiraToolbarItem,
54
+ miraEditorToolbarItemLabels,
55
+ miraViewOptionsLabel,
56
+ miraViewToggleLabel,
57
+ resolveMiraModeAfterSplit,
58
+ resolveMiraViewModeMenuItems,
59
+ resolveMiraViewToggleMode,
60
+ templateForMiraToolbarItem,
61
+ } from "./toolbar-model";
62
+ import type { MiraEditorToolbarProps } from "./types";
63
+ import MiraEditorAboutDialog from "./mira-editor-about-dialog.svelte";
64
+
65
+ let {
66
+ value = "",
67
+ mode = $bindable("live-preview"),
68
+ defaultEditMode = defaultMiraEditorEditMode,
69
+ readonly = false,
70
+ features = {},
71
+ featureConfigs = {},
72
+ toolbarActions = [],
73
+ toolbars = [],
74
+ modeOptions: modeOptionsProp,
75
+ showModeSwitch = true,
76
+ indentGuides = true,
77
+ indentWithTabs = true,
78
+ indentWidth = 4,
79
+ context,
80
+ class: className = "",
81
+ onModeChange,
82
+ onInsertMarkdown,
83
+ onInsertImage,
84
+ onIndentGuidesChange,
85
+ onIndentWidthChange,
86
+ onIndentWithTabsChange,
87
+ }: MiraEditorToolbarProps = $props();
88
+
89
+ const resolvedFeatures = $derived(resolveMiraEditorFeatures(features));
90
+ const modeOptions = $derived(
91
+ modeOptionsProp ?? resolveMiraEditorModes(features),
92
+ );
93
+ const toolbarItems = $derived(
94
+ resolveMiraEditorToolbarItems({ features, featureConfigs }),
95
+ );
96
+ const customToolbarActions = $derived(
97
+ resolveMiraEditorToolbarActions({ featureConfigs, toolbarActions }).filter(
98
+ hasTopToolbarPlacement,
99
+ ),
100
+ );
101
+ const customToolbars = $derived(
102
+ resolveMiraEditorToolbarDefinitions({ featureConfigs, toolbars }).map(
103
+ (toolbar) => ({
104
+ ...toolbar,
105
+ items: toolbar.items.filter(hasTopToolbarPlacement),
106
+ }),
107
+ ),
108
+ );
109
+ const startToolbars = $derived(
110
+ customToolbars.filter((toolbar) => toolbar.align !== "end"),
111
+ );
112
+ const endToolbars = $derived(
113
+ customToolbars.filter((toolbar) => toolbar.align === "end"),
114
+ );
115
+ const visibleStartToolbars = $derived(
116
+ startToolbars.filter(toolbarHasRenderableActions),
117
+ );
118
+ const visibleEndToolbars = $derived(
119
+ endToolbars.filter(toolbarHasRenderableActions),
120
+ );
121
+ const toolbarItemsVisible = $derived(toolbarItems.length > 0);
122
+ const customToolbarActionsVisible = $derived(customToolbarActions.length > 0);
123
+
124
+ const modeSwitchVisible = $derived(
125
+ showModeSwitch &&
126
+ resolvedFeatures[MiraFeature.ModeSwitch] &&
127
+ modeOptions.length > 1,
128
+ );
129
+ const toolbarHasLeadingContent = $derived(
130
+ visibleStartToolbars.length > 0 ||
131
+ toolbarItemsVisible ||
132
+ customToolbarActionsVisible,
133
+ );
134
+
135
+ let lastNonSplitMode = $state<MiraMode | null>(null);
136
+ let aboutDialogOpen = $state(false);
137
+
138
+ function hasTopToolbarPlacement(action: MiraEditorToolbarAction): boolean {
139
+ return (
140
+ action.type === "dropdown" ||
141
+ !action.placements ||
142
+ action.placements.includes("toolbar")
143
+ );
144
+ }
145
+
146
+ const modeIcons = {
147
+ source: FileCodeIcon,
148
+ "live-preview": PencilLineIcon,
149
+ preview: BookOpenIcon,
150
+ split: Columns2Icon,
151
+ };
152
+
153
+ const toolbarItemIcons = {
154
+ heading: Heading1Icon,
155
+ bold: BoldIcon,
156
+ italic: ItalicIcon,
157
+ strikethrough: StrikethroughIcon,
158
+ inlineCode: CodeXmlIcon,
159
+ quote: QuoteIcon,
160
+ bulletList: ListIcon,
161
+ numberedList: ListOrderedIcon,
162
+ taskList: ListChecksIcon,
163
+ link: LinkIcon,
164
+ image: ImageIcon,
165
+ table: Table2Icon,
166
+ gridTable: TableCellsSplitIcon,
167
+ code: CodeIcon,
168
+ math: BracesIcon,
169
+ mermaid: WorkflowIcon,
170
+ };
171
+
172
+ const resolvedDefaultEditMode = $derived(
173
+ resolveMiraEditorEditMode(defaultEditMode, modeOptions),
174
+ );
175
+ const editModeOptions = $derived(
176
+ modeOptions.filter((modeOption) => isMiraEditMode(modeOption)),
177
+ );
178
+ const viewModeMenuItems = $derived(
179
+ resolveMiraViewModeMenuItems({
180
+ mode,
181
+ modeOptions,
182
+ resolvedDefaultEditMode,
183
+ }),
184
+ );
185
+ const viewModeToggleVisible = $derived(
186
+ modeSwitchVisible &&
187
+ modeOptions.includes("preview") &&
188
+ editModeOptions.length > 0,
189
+ );
190
+ const splitModeVisible = $derived(
191
+ modeSwitchVisible && modeOptions.includes("split"),
192
+ );
193
+ const editorSettingsMenuVisible = $derived(true);
194
+ const viewModeOverflowVisible = $derived(
195
+ (modeSwitchVisible && viewModeMenuItems.length > 0) ||
196
+ editorSettingsMenuVisible,
197
+ );
198
+ const viewControlsVisible = $derived(
199
+ viewModeToggleVisible || splitModeVisible || viewModeOverflowVisible,
200
+ );
201
+ const tabSizeOptions = [2, 4, 8];
202
+
203
+ function toolbarHasRenderableActions(
204
+ toolbar: MiraEditorToolbarDefinition,
205
+ ): boolean {
206
+ return toolbar.items.length > 0;
207
+ }
208
+
209
+ function fallbackContext(): MiraEditorToolbarActionContext {
210
+ return {
211
+ value,
212
+ mode,
213
+ readonly,
214
+ focus: () => undefined,
215
+ getIndentGuides: () => indentGuides,
216
+ getIndentWidth: () => indentWidth,
217
+ getIndentWithTabs: () => indentWithTabs,
218
+ getMarkdown: () => value,
219
+ getMode: () => mode,
220
+ getSelection: () => null,
221
+ insertMarkdown(markdown) {
222
+ onInsertMarkdown?.(markdown);
223
+ },
224
+ insertImage() {
225
+ onInsertImage?.();
226
+ },
227
+ setIndentGuides(nextEnabled) {
228
+ indentGuides = nextEnabled;
229
+ onIndentGuidesChange?.(nextEnabled);
230
+ },
231
+ setIndentWidth(nextWidth) {
232
+ indentWidth = nextWidth;
233
+ onIndentWidthChange?.(nextWidth);
234
+ },
235
+ setIndentWithTabs(nextEnabled) {
236
+ indentWithTabs = nextEnabled;
237
+ onIndentWithTabsChange?.(nextEnabled);
238
+ },
239
+ setMarkdown: () => undefined,
240
+ setMode(nextMode) {
241
+ mode = nextMode;
242
+ onModeChange?.(nextMode);
243
+ },
244
+ setReadonly: () => undefined,
245
+ setSelection: () => undefined,
246
+ };
247
+ }
248
+
249
+ function actionContext(): MiraEditorToolbarActionContext {
250
+ return context ?? fallbackContext();
251
+ }
252
+
253
+ function dynamicBoolean(
254
+ value:
255
+ | boolean
256
+ | ((context: MiraEditorToolbarActionContext) => boolean)
257
+ | undefined,
258
+ ): boolean {
259
+ if (typeof value === "function") {
260
+ return value(actionContext());
261
+ }
262
+ return value ?? false;
263
+ }
264
+
265
+ function isDropdownAction(
266
+ action: MiraEditorToolbarAction,
267
+ ): action is MiraEditorToolbarDropdownAction {
268
+ return action.type === "dropdown";
269
+ }
270
+
271
+ function isToolbarActionDisabled(action: MiraEditorToolbarAction): boolean {
272
+ return dynamicBoolean(action.disabled);
273
+ }
274
+
275
+ function isToolbarActionPressed(action: MiraEditorToolbarAction): boolean {
276
+ return !isDropdownAction(action) && dynamicBoolean(action.pressed);
277
+ }
278
+
279
+ function runToolbarAction(action: MiraEditorToolbarAction): void {
280
+ if (isDropdownAction(action) || isToolbarActionDisabled(action)) {
281
+ return;
282
+ }
283
+ action.run(actionContext());
284
+ }
285
+
286
+ function findToolbarAction(
287
+ actionId: string,
288
+ ): MiraEditorToolbarAction | undefined {
289
+ return [
290
+ ...customToolbarActions,
291
+ ...customToolbars.flatMap((toolbar) => toolbar.items),
292
+ ].find((action) => action.id === actionId);
293
+ }
294
+
295
+ function handleToolbarActionButtonClick(event: MouseEvent): void {
296
+ const actionId = (event.currentTarget as HTMLElement | null)?.dataset
297
+ .toolbarActionId;
298
+ const action = actionId ? findToolbarAction(actionId) : undefined;
299
+ if (action) {
300
+ runToolbarAction(action);
301
+ }
302
+ }
303
+
304
+ function modeToolbarDelegate(node: HTMLElement): { destroy: () => void } {
305
+ node.dataset.miraToolbarDelegate = "ready";
306
+
307
+ return {
308
+ destroy() {
309
+ delete node.dataset.miraToolbarDelegate;
310
+ },
311
+ };
312
+ }
313
+
314
+ function menuItemKey(item: MiraEditorToolbarMenuItem, index: number): string {
315
+ const fallback = item.type === "separator" ? index : item.label;
316
+ return `${item.type ?? "item"}-${item.id ?? fallback}`;
317
+ }
318
+
319
+ function isMenuItemDisabled(item: MiraEditorToolbarMenuItem): boolean {
320
+ return (
321
+ item.type !== "label" &&
322
+ item.type !== "separator" &&
323
+ dynamicBoolean(item.disabled)
324
+ );
325
+ }
326
+
327
+ function isMenuItemChecked(item: MiraEditorToolbarMenuItem): boolean {
328
+ return (
329
+ item.type !== "label" &&
330
+ item.type !== "separator" &&
331
+ dynamicBoolean(item.checked)
332
+ );
333
+ }
334
+
335
+ function menuItemIcon(
336
+ item: MiraEditorToolbarMenuItem,
337
+ ): Component<Record<string, unknown>> | undefined {
338
+ if (item.type === "label" || item.type === "separator") {
339
+ return undefined;
340
+ }
341
+ return item.icon;
342
+ }
343
+
344
+ function runMenuItem(item: MiraEditorToolbarMenuItem): void {
345
+ if (
346
+ item.type === "label" ||
347
+ item.type === "separator" ||
348
+ isMenuItemDisabled(item)
349
+ ) {
350
+ return;
351
+ }
352
+ item.run(actionContext());
353
+ }
354
+
355
+ function withTooltipClickHandler(
356
+ props: Record<string, unknown>,
357
+ handler: (event: MouseEvent) => void,
358
+ ): Record<string, unknown> {
359
+ const originalClick =
360
+ typeof props.onclick === "function"
361
+ ? (props.onclick as (event: MouseEvent) => void)
362
+ : undefined;
363
+
364
+ return {
365
+ ...props,
366
+ onclick(event: MouseEvent) {
367
+ handler(event);
368
+ originalClick?.(event);
369
+ },
370
+ };
371
+ }
372
+
373
+ function applyMode(nextMode: string): void {
374
+ const typedMode = nextMode as MiraMode;
375
+ if (!modeOptions.includes(typedMode)) {
376
+ return;
377
+ }
378
+ mode = typedMode;
379
+ context?.setMode(typedMode);
380
+ onModeChange?.(typedMode);
381
+ }
382
+
383
+ function getIndentGuidesSetting(): boolean {
384
+ return actionContext().getIndentGuides?.() ?? indentGuides;
385
+ }
386
+
387
+ function getIndentWidthSetting(): number {
388
+ return actionContext().getIndentWidth?.() ?? indentWidth;
389
+ }
390
+
391
+ function getIndentWithTabsSetting(): boolean {
392
+ return actionContext().getIndentWithTabs?.() ?? indentWithTabs;
393
+ }
394
+
395
+ function setIndentGuidesSetting(nextEnabled: boolean): void {
396
+ const ctx = actionContext();
397
+ if (ctx.setIndentGuides) {
398
+ ctx.setIndentGuides(nextEnabled);
399
+ } else {
400
+ indentGuides = nextEnabled;
401
+ onIndentGuidesChange?.(nextEnabled);
402
+ }
403
+ }
404
+
405
+ function setIndentWidthSetting(nextWidth: number): void {
406
+ const ctx = actionContext();
407
+ if (ctx.setIndentWidth) {
408
+ ctx.setIndentWidth(nextWidth);
409
+ } else {
410
+ indentWidth = nextWidth;
411
+ onIndentWidthChange?.(nextWidth);
412
+ }
413
+ }
414
+
415
+ function setIndentWithTabsSetting(nextEnabled: boolean): void {
416
+ const ctx = actionContext();
417
+ if (ctx.setIndentWithTabs) {
418
+ ctx.setIndentWithTabs(nextEnabled);
419
+ } else {
420
+ indentWithTabs = nextEnabled;
421
+ onIndentWithTabsChange?.(nextEnabled);
422
+ }
423
+ }
424
+
425
+ function switchToSplitMode(): void {
426
+ if (mode === "split") {
427
+ applyMode(
428
+ resolveMiraModeAfterSplit({
429
+ lastNonSplitMode,
430
+ modeOptions,
431
+ resolvedDefaultEditMode,
432
+ }),
433
+ );
434
+ } else {
435
+ applyMode("split");
436
+ }
437
+ }
438
+
439
+ function viewToggleIcon(): Component<Record<string, unknown>> {
440
+ return mode === "preview" ? PencilLineIcon : BookOpenIcon;
441
+ }
442
+
443
+ function handleViewToggle(): void {
444
+ applyMode(resolveMiraViewToggleMode(mode, resolvedDefaultEditMode));
445
+ }
446
+
447
+ $effect(() => {
448
+ if (
449
+ mode === "preview" &&
450
+ !modeOptions.includes("preview") &&
451
+ editModeOptions.length > 0
452
+ ) {
453
+ applyMode(resolvedDefaultEditMode);
454
+ }
455
+ });
456
+
457
+ $effect(() => {
458
+ if (mode !== "split" && modeOptions.includes(mode)) {
459
+ lastNonSplitMode = mode;
460
+ }
461
+ });
462
+
463
+ function insertTemplate(item: MiraEditorToolbarItem): void {
464
+ const activeContext = actionContext();
465
+ if (item === "image" && activeContext.insertImage) {
466
+ activeContext.insertImage();
467
+ return;
468
+ }
469
+ const action = markdownActionForMiraToolbarItem(item);
470
+ if (action && activeContext.applyMarkdownAction) {
471
+ activeContext.applyMarkdownAction(action);
472
+ return;
473
+ }
474
+ const markdown = templateForMiraToolbarItem(item);
475
+ if (markdown) {
476
+ activeContext.insertMarkdown(markdown);
477
+ }
478
+ }
479
+ </script>
480
+
481
+ {#snippet renderAction(action: MiraEditorToolbarAction)}
482
+ {@const Icon = action.icon}
483
+ {#if isDropdownAction(action)}
484
+ <DropdownMenu.Root>
485
+ <Tooltip.Root>
486
+ <Tooltip.Trigger>
487
+ {#snippet child({ props }: { props: Record<string, unknown> })}
488
+ <DropdownMenu.Trigger
489
+ {...props}
490
+ class="mira-toolbar__button"
491
+ aria-label={action.label}
492
+ disabled={isToolbarActionDisabled(action)}
493
+ >
494
+ <Icon class="mira-editor__icon" aria-hidden="true" />
495
+ </DropdownMenu.Trigger>
496
+ {/snippet}
497
+ </Tooltip.Trigger>
498
+ <Tooltip.Content>{action.tooltip ?? action.label}</Tooltip.Content>
499
+ </Tooltip.Root>
500
+ <DropdownMenu.Content align="start">
501
+ {#each action.items as item, index (menuItemKey(item, index))}
502
+ {#if item.type === "separator"}
503
+ <DropdownMenu.Separator />
504
+ {:else if item.type === "label"}
505
+ <DropdownMenu.Label>{item.label}</DropdownMenu.Label>
506
+ {:else}
507
+ {@const ItemIcon = menuItemIcon(item)}
508
+ <DropdownMenu.Item
509
+ disabled={isMenuItemDisabled(item)}
510
+ onclick={() => runMenuItem(item)}
511
+ >
512
+ {#if ItemIcon}
513
+ <ItemIcon
514
+ class="mira-editor-toolbar__menu-icon"
515
+ aria-hidden="true"
516
+ />
517
+ {:else}
518
+ <span
519
+ class="mira-editor-toolbar__menu-icon-placeholder"
520
+ aria-hidden="true"
521
+ ></span>
522
+ {/if}
523
+ <span>{item.label}</span>
524
+ {#if item.shortcut}
525
+ <DropdownMenu.Shortcut>{item.shortcut}</DropdownMenu.Shortcut>
526
+ {/if}
527
+ {#if isMenuItemChecked(item)}
528
+ <CheckIcon
529
+ class="mira-editor-toolbar__menu-check"
530
+ aria-hidden="true"
531
+ />
532
+ {/if}
533
+ </DropdownMenu.Item>
534
+ {/if}
535
+ {/each}
536
+ </DropdownMenu.Content>
537
+ </DropdownMenu.Root>
538
+ {:else}
539
+ <Tooltip.Root>
540
+ <Tooltip.Trigger>
541
+ {#snippet child({ props }: { props: Record<string, unknown> })}
542
+ {@const triggerProps = withTooltipClickHandler(
543
+ props,
544
+ handleToolbarActionButtonClick,
545
+ )}
546
+ <Toolbar.Button
547
+ {...triggerProps}
548
+ aria-label={action.label}
549
+ aria-pressed={isToolbarActionPressed(action) ? "true" : undefined}
550
+ data-toolbar-action-id={action.id}
551
+ disabled={isToolbarActionDisabled(action)}
552
+ >
553
+ <Icon class="mira-editor__icon" aria-hidden="true" />
554
+ </Toolbar.Button>
555
+ {/snippet}
556
+ </Tooltip.Trigger>
557
+ <Tooltip.Content>{action.tooltip ?? action.label}</Tooltip.Content>
558
+ </Tooltip.Root>
559
+ {/if}
560
+ {/snippet}
561
+
562
+ {#snippet renderToolbarSection(toolbar: MiraEditorToolbarDefinition)}
563
+ <div
564
+ class="mira-editor__toolbar-section"
565
+ role="group"
566
+ aria-label={toolbar.label ?? toolbar.id}
567
+ >
568
+ {#each toolbar.items as action (action.id)}
569
+ {@render renderAction(action)}
570
+ {/each}
571
+ </div>
572
+ {/snippet}
573
+
574
+ {#snippet renderViewModeControl()}
575
+ {@const ToggleIcon = viewToggleIcon()}
576
+ <Tooltip.Root>
577
+ <Tooltip.Trigger>
578
+ {#snippet child({ props }: { props: Record<string, unknown> })}
579
+ {@const triggerProps = withTooltipClickHandler(props, () =>
580
+ handleViewToggle(),
581
+ )}
582
+ <Toolbar.Button
583
+ {...triggerProps}
584
+ aria-label={miraViewToggleLabel(mode)}
585
+ >
586
+ <ToggleIcon class="mira-editor__icon" aria-hidden="true" />
587
+ </Toolbar.Button>
588
+ {/snippet}
589
+ </Tooltip.Trigger>
590
+ <Tooltip.Content>{miraViewToggleLabel(mode)}</Tooltip.Content>
591
+ </Tooltip.Root>
592
+ {/snippet}
593
+
594
+ {#snippet renderSplitModeButton()}
595
+ <Tooltip.Root>
596
+ <Tooltip.Trigger>
597
+ {#snippet child({ props }: { props: Record<string, unknown> })}
598
+ {@const triggerProps = withTooltipClickHandler(props, () =>
599
+ switchToSplitMode(),
600
+ )}
601
+ <Toolbar.Button
602
+ {...triggerProps}
603
+ aria-label="Split"
604
+ aria-pressed={mode === "split" ? "true" : undefined}
605
+ >
606
+ <Columns2Icon class="mira-editor__icon" aria-hidden="true" />
607
+ </Toolbar.Button>
608
+ {/snippet}
609
+ </Tooltip.Trigger>
610
+ <Tooltip.Content>Split</Tooltip.Content>
611
+ </Tooltip.Root>
612
+ {/snippet}
613
+
614
+ {#snippet renderViewModeOverflow()}
615
+ <DropdownMenu.Root>
616
+ <Tooltip.Root>
617
+ <Tooltip.Trigger>
618
+ {#snippet child({ props }: { props: Record<string, unknown> })}
619
+ <DropdownMenu.Trigger
620
+ {...props}
621
+ class="mira-toolbar__button"
622
+ aria-label={miraViewOptionsLabel}
623
+ >
624
+ <EllipsisIcon class="mira-editor__icon" aria-hidden="true" />
625
+ </DropdownMenu.Trigger>
626
+ {/snippet}
627
+ </Tooltip.Trigger>
628
+ <Tooltip.Content>{miraViewOptionsLabel}</Tooltip.Content>
629
+ </Tooltip.Root>
630
+ <DropdownMenu.Content align="end">
631
+ {#if viewModeMenuItems.length > 0}
632
+ {#each viewModeMenuItems as item (item.mode)}
633
+ {@const ItemIcon = modeIcons[item.mode]}
634
+ <DropdownMenu.Item onclick={() => applyMode(item.mode)}>
635
+ <ItemIcon
636
+ class="mira-editor-toolbar__menu-icon"
637
+ aria-hidden="true"
638
+ />
639
+ <span>{item.label}</span>
640
+ {#if item.checked}
641
+ <CheckIcon
642
+ class="mira-editor-toolbar__menu-check"
643
+ aria-hidden="true"
644
+ />
645
+ {/if}
646
+ </DropdownMenu.Item>
647
+ {/each}
648
+ <DropdownMenu.Separator />
649
+ {/if}
650
+ <DropdownMenu.Label>Editor</DropdownMenu.Label>
651
+ <DropdownMenu.Item
652
+ onclick={() => setIndentGuidesSetting(!getIndentGuidesSetting())}
653
+ >
654
+ <ListTreeIcon
655
+ class="mira-editor-toolbar__menu-icon"
656
+ aria-hidden="true"
657
+ />
658
+ <span>Indentation guides</span>
659
+ {#if getIndentGuidesSetting()}
660
+ <CheckIcon
661
+ class="mira-editor-toolbar__menu-check"
662
+ aria-hidden="true"
663
+ />
664
+ {/if}
665
+ </DropdownMenu.Item>
666
+ <DropdownMenu.Item
667
+ onclick={() => setIndentWithTabsSetting(!getIndentWithTabsSetting())}
668
+ >
669
+ <IndentIncreaseIcon
670
+ class="mira-editor-toolbar__menu-icon"
671
+ aria-hidden="true"
672
+ />
673
+ <span>Use tabs for indentation</span>
674
+ {#if getIndentWithTabsSetting()}
675
+ <CheckIcon
676
+ class="mira-editor-toolbar__menu-check"
677
+ aria-hidden="true"
678
+ />
679
+ {/if}
680
+ </DropdownMenu.Item>
681
+ <DropdownMenu.Separator />
682
+ <DropdownMenu.Label>Tab size</DropdownMenu.Label>
683
+ {#each tabSizeOptions as size}
684
+ <DropdownMenu.Item onclick={() => setIndentWidthSetting(size)}>
685
+ <SpaceIcon
686
+ class="mira-editor-toolbar__menu-icon"
687
+ aria-hidden="true"
688
+ />
689
+ <span>{size} spaces</span>
690
+ {#if getIndentWidthSetting() === size}
691
+ <CheckIcon
692
+ class="mira-editor-toolbar__menu-check"
693
+ aria-hidden="true"
694
+ />
695
+ {/if}
696
+ </DropdownMenu.Item>
697
+ {/each}
698
+ <DropdownMenu.Separator />
699
+ <DropdownMenu.Item onclick={() => (aboutDialogOpen = true)}>
700
+ <InfoIcon class="mira-editor-toolbar__menu-icon" aria-hidden="true" />
701
+ <span>About Mira</span>
702
+ </DropdownMenu.Item>
703
+ </DropdownMenu.Content>
704
+ </DropdownMenu.Root>
705
+ {/snippet}
706
+
707
+ <Tooltip.Provider delayDuration={350}>
708
+ <div
709
+ class="mira-editor-toolbar__event-root"
710
+ role="presentation"
711
+ use:modeToolbarDelegate
712
+ >
713
+ <Toolbar.Root
714
+ class={`mira-editor-toolbar mira-editor__toolbar ${className}`.trim()}
715
+ aria-label="Markdown editor toolbar"
716
+ >
717
+ {#each visibleStartToolbars as toolbar, index (toolbar.id)}
718
+ {#if index > 0}
719
+ <Separator orientation="vertical" class="mira-editor__separator" />
720
+ {/if}
721
+ {@render renderToolbarSection(toolbar)}
722
+ {/each}
723
+
724
+ {#if toolbarItemsVisible}
725
+ {#if visibleStartToolbars.length > 0}
726
+ <Separator orientation="vertical" class="mira-editor__separator" />
727
+ {/if}
728
+ <div
729
+ class="mira-editor__toolbar-section"
730
+ role="group"
731
+ aria-label="Insert"
732
+ >
733
+ {#each toolbarItems as item}
734
+ {@const Icon = toolbarItemIcons[item]}
735
+ <Tooltip.Root>
736
+ <Tooltip.Trigger>
737
+ {#snippet child({ props }: { props: Record<string, unknown> })}
738
+ {@const triggerProps = withTooltipClickHandler(props, () =>
739
+ insertTemplate(item),
740
+ )}
741
+ <Toolbar.Button
742
+ {...triggerProps}
743
+ aria-label={miraEditorToolbarItemLabels[item]}
744
+ disabled={readonly || mode === "preview"}
745
+ >
746
+ <Icon class="mira-editor__icon" aria-hidden="true" />
747
+ </Toolbar.Button>
748
+ {/snippet}
749
+ </Tooltip.Trigger>
750
+ <Tooltip.Content
751
+ >{miraEditorToolbarItemLabels[item]}</Tooltip.Content
752
+ >
753
+ </Tooltip.Root>
754
+ {/each}
755
+ </div>
756
+ {/if}
757
+
758
+ {#if customToolbarActionsVisible}
759
+ {#if visibleStartToolbars.length > 0 || toolbarItemsVisible}
760
+ <Separator orientation="vertical" class="mira-editor__separator" />
761
+ {/if}
762
+ <div
763
+ class="mira-editor__toolbar-section"
764
+ role="group"
765
+ aria-label="Custom actions"
766
+ >
767
+ {#each customToolbarActions as action (action.id)}
768
+ {@render renderAction(action)}
769
+ {/each}
770
+ </div>
771
+ {/if}
772
+
773
+ <div class="mira-editor-toolbar__spacer"></div>
774
+
775
+ {#each visibleEndToolbars as toolbar, index (toolbar.id)}
776
+ {#if index > 0}
777
+ <Separator orientation="vertical" class="mira-editor__separator" />
778
+ {/if}
779
+ {@render renderToolbarSection(toolbar)}
780
+ {/each}
781
+
782
+ {#if viewControlsVisible}
783
+ {#if toolbarHasLeadingContent || visibleEndToolbars.length > 0}
784
+ <Separator orientation="vertical" class="mira-editor__separator" />
785
+ {/if}
786
+ <div
787
+ class="mira-editor__toolbar-section"
788
+ role="group"
789
+ aria-label="View controls"
790
+ >
791
+ {#if viewModeToggleVisible}
792
+ {@render renderViewModeControl()}
793
+ {/if}
794
+ {#if splitModeVisible}
795
+ {@render renderSplitModeButton()}
796
+ {/if}
797
+ {#if viewModeOverflowVisible}
798
+ {@render renderViewModeOverflow()}
799
+ {/if}
800
+ </div>
801
+ {/if}
802
+ </Toolbar.Root>
803
+ </div>
804
+ <MiraEditorAboutDialog bind:open={aboutDialogOpen} />
805
+ </Tooltip.Provider>