@heyramzi/ui 3.1.0 → 3.3.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 (168) hide show
  1. package/dist/lib/sliding-pill.js +2 -2
  2. package/dist/record/measure-clamp.js +23 -0
  3. package/dist/shadcn/button-group/index.js +6 -0
  4. package/dist/shadcn/checkbox/checkbox-box.js +3 -0
  5. package/dist/shadcn/drawer/index.js +14 -0
  6. package/dist/shadcn/input-group/index.js +9 -0
  7. package/dist/shadcn/item/index.js +13 -0
  8. package/dist/shadcn/native-select/index.js +6 -0
  9. package/dist/shadcn/pagination/index.js +12 -0
  10. package/package.json +7 -2
  11. package/scripts/sync-shadcn.mjs +0 -6
  12. package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
  13. package/src/canvas/pen/PenOverlay.svelte +1 -30
  14. package/src/canvas/screen/PartView.svelte +13 -8
  15. package/src/canvas/screen/ScreenBody.svelte +18 -11
  16. package/src/canvas/ui/BarTooltip.svelte +46 -0
  17. package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
  18. package/src/canvas/ui/CanvasToolbar.svelte +170 -156
  19. package/src/canvas/ui/StylePanel.svelte +179 -102
  20. package/src/canvas/ui/canvas-ui.css +12 -76
  21. package/src/charts/dashboard/data-table.svelte +80 -80
  22. package/src/charts/dashboard/hint.svelte +34 -0
  23. package/src/charts/dashboard/hint.svelte.d.ts +10 -0
  24. package/src/charts/dashboard/rating-chip.svelte +4 -3
  25. package/src/charts/legend.css +0 -8
  26. package/src/charts/legend.svelte +26 -30
  27. package/src/components/copy/copy-button.svelte +10 -2
  28. package/src/components/copy/copy-button.test.ts +7 -0
  29. package/src/components/data-table/table-skeleton.svelte +15 -7
  30. package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
  31. package/src/components/docs/on-this-page.svelte +2 -2
  32. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
  33. package/src/components/list-page/filter-item.svelte +20 -7
  34. package/src/components/list-page/filter-popover.svelte +41 -16
  35. package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
  36. package/src/components/list-page/grouped-list.svelte +41 -23
  37. package/src/components/picker/picker.svelte +8 -3
  38. package/src/components/picker/picker.svelte.d.ts +2 -1
  39. package/src/components/segmented/segmented-filter.svelte +19 -10
  40. package/src/components/segmented/segmented-track.svelte +11 -3
  41. package/src/components/table-view/table-view.svelte +22 -6
  42. package/src/components/tags-input/tags-input.svelte +94 -77
  43. package/src/components/toolbar/toolbar.svelte +2 -1
  44. package/src/components/toolbar/toolbar.test.ts +5 -4
  45. package/src/components/work-parts/group-head.svelte +28 -9
  46. package/src/components/work-parts/group-head.svelte.d.ts +5 -2
  47. package/src/editor/editor-toolbar.svelte +223 -193
  48. package/src/editor/editor.svelte +56 -38
  49. package/src/editor/image-toolbar.svelte +99 -37
  50. package/src/editor/image-toolbar.svelte.d.ts +4 -5
  51. package/src/editor/url-dialog.svelte +96 -0
  52. package/src/editor/url-dialog.svelte.d.ts +15 -0
  53. package/src/effects/theme-toggle/ThemeToggle.css +8 -8
  54. package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
  55. package/src/inbox/thread-inbox.svelte +9 -1
  56. package/src/lib/live-region.svelte +12 -4
  57. package/src/lib/live-region.svelte.d.ts +5 -1
  58. package/src/lib/sliding-pill.ts +3 -2
  59. package/src/record/info-list.svelte +33 -59
  60. package/src/record/measure-clamp.ts +31 -0
  61. package/src/record/record-shell.svelte +38 -39
  62. package/src/record/record-timeline.svelte +10 -5
  63. package/src/record/steps-rail.svelte +50 -36
  64. package/src/record/where-we-are.svelte +5 -19
  65. package/src/shadcn/button-group/button-group-separator.svelte +23 -0
  66. package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
  67. package/src/shadcn/button-group/button-group-text.svelte +28 -0
  68. package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
  69. package/src/shadcn/button-group/button-group.svelte +46 -0
  70. package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
  71. package/src/shadcn/button-group/index.ts +15 -0
  72. package/src/shadcn/checkbox/checkbox-box.ts +4 -0
  73. package/src/shadcn/checkbox/checkbox.svelte +2 -4
  74. package/src/shadcn/drawer/drawer-close.svelte +7 -0
  75. package/src/shadcn/drawer/drawer-close.svelte.d.ts +4 -0
  76. package/src/shadcn/drawer/drawer-content.svelte +36 -0
  77. package/src/shadcn/drawer/drawer-content.svelte.d.ts +10 -0
  78. package/src/shadcn/drawer/drawer-description.svelte +17 -0
  79. package/src/shadcn/drawer/drawer-description.svelte.d.ts +4 -0
  80. package/src/shadcn/drawer/drawer-footer.svelte +20 -0
  81. package/src/shadcn/drawer/drawer-footer.svelte.d.ts +5 -0
  82. package/src/shadcn/drawer/drawer-header.svelte +23 -0
  83. package/src/shadcn/drawer/drawer-header.svelte.d.ts +5 -0
  84. package/src/shadcn/drawer/drawer-nested.svelte +12 -0
  85. package/src/shadcn/drawer/drawer-nested.svelte.d.ts +3 -0
  86. package/src/shadcn/drawer/drawer-overlay.svelte +20 -0
  87. package/src/shadcn/drawer/drawer-overlay.svelte.d.ts +4 -0
  88. package/src/shadcn/drawer/drawer-portal.svelte +7 -0
  89. package/src/shadcn/drawer/drawer-portal.svelte.d.ts +3 -0
  90. package/src/shadcn/drawer/drawer-title.svelte +17 -0
  91. package/src/shadcn/drawer/drawer-title.svelte.d.ts +4 -0
  92. package/src/shadcn/drawer/drawer-trigger.svelte +7 -0
  93. package/src/shadcn/drawer/drawer-trigger.svelte.d.ts +4 -0
  94. package/src/shadcn/drawer/drawer.svelte +12 -0
  95. package/src/shadcn/drawer/drawer.svelte.d.ts +3 -0
  96. package/src/shadcn/drawer/index.ts +38 -0
  97. package/src/shadcn/input-group/index.ts +22 -0
  98. package/src/shadcn/input-group/input-group-addon.svelte +51 -0
  99. package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
  100. package/src/shadcn/input-group/input-group-button.svelte +49 -0
  101. package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
  102. package/src/shadcn/input-group/input-group-input.svelte +20 -0
  103. package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
  104. package/src/shadcn/input-group/input-group-text.svelte +19 -0
  105. package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
  106. package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
  107. package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
  108. package/src/shadcn/input-group/input-group.svelte +24 -0
  109. package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
  110. package/src/shadcn/item/index.ts +34 -0
  111. package/src/shadcn/item/item-actions.svelte +20 -0
  112. package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
  113. package/src/shadcn/item/item-content.svelte +23 -0
  114. package/src/shadcn/item/item-content.svelte.d.ts +5 -0
  115. package/src/shadcn/item/item-description.svelte +23 -0
  116. package/src/shadcn/item/item-description.svelte.d.ts +5 -0
  117. package/src/shadcn/item/item-footer.svelte +20 -0
  118. package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
  119. package/src/shadcn/item/item-group.svelte +21 -0
  120. package/src/shadcn/item/item-group.svelte.d.ts +5 -0
  121. package/src/shadcn/item/item-header.svelte +20 -0
  122. package/src/shadcn/item/item-header.svelte.d.ts +5 -0
  123. package/src/shadcn/item/item-media.svelte +42 -0
  124. package/src/shadcn/item/item-media.svelte.d.ts +17 -0
  125. package/src/shadcn/item/item-separator.svelte +19 -0
  126. package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
  127. package/src/shadcn/item/item-title.svelte +20 -0
  128. package/src/shadcn/item/item-title.svelte.d.ts +5 -0
  129. package/src/shadcn/item/item.svelte +61 -0
  130. package/src/shadcn/item/item.svelte.d.ts +28 -0
  131. package/src/shadcn/native-select/index.ts +13 -0
  132. package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
  133. package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
  134. package/src/shadcn/native-select/native-select-option.svelte +20 -0
  135. package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
  136. package/src/shadcn/native-select/native-select.svelte +45 -0
  137. package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
  138. package/src/shadcn/pagination/index.ts +31 -0
  139. package/src/shadcn/pagination/pagination-content.svelte +20 -0
  140. package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
  141. package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
  142. package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
  143. package/src/shadcn/pagination/pagination-item.svelte +14 -0
  144. package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
  145. package/src/shadcn/pagination/pagination-link.svelte +41 -0
  146. package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
  147. package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
  148. package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
  149. package/src/shadcn/pagination/pagination-next.svelte +26 -0
  150. package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
  151. package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
  152. package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
  153. package/src/shadcn/pagination/pagination-previous.svelte +30 -0
  154. package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
  155. package/src/shadcn/pagination/pagination.svelte +27 -0
  156. package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
  157. package/src/slides/Controls.svelte +39 -36
  158. package/src/slides/Deck.svelte +11 -8
  159. package/src/thread/thread.svelte +15 -8
  160. package/src/work/detail/task-detail.svelte +1 -1
  161. package/src/work/gantt/gantt-task-row.svelte +3 -6
  162. package/src/work/inbox/inbox-card.svelte +7 -5
  163. package/src/work/inbox/inbox-trace.svelte +14 -10
  164. package/src/work/kanban-board.svelte +40 -25
  165. package/src/work/kanban-hidden-rail.svelte +67 -65
  166. package/src/work/task-list.svelte +145 -124
  167. package/src/work/task-list.svelte.test.ts +5 -2
  168. package/src/work/time/time-tracker.svelte +35 -10
@@ -3,8 +3,12 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import type { Editor } from "@tiptap/core";
6
+ import { Toolbar } from "bits-ui";
6
7
  import { Button } from "../shadcn/button/index.js";
7
8
  import { Input } from "../shadcn/input/index.js";
9
+ import { Kbd } from "../shadcn/kbd/index.js";
10
+ import * as Popover from "../shadcn/popover/index.js";
11
+ import * as Tooltip from "../shadcn/tooltip/index.js";
8
12
  import {
9
13
  TextB,
10
14
  TextItalic,
@@ -28,32 +32,40 @@
28
32
  onInsertYoutube: () => void;
29
33
  }
30
34
 
35
+ type Tool = {
36
+ label: string;
37
+ keys?: string;
38
+ icon: typeof TextB;
39
+ bold?: boolean;
40
+ pressed?: boolean;
41
+ run: () => void;
42
+ };
43
+
31
44
  // =========================
32
45
  // PROPS
33
46
  // =========================
34
47
  let { editor: editorProp, tick, preset, onInsertYoutube }: Props = $props();
35
48
 
49
+ // =========================
50
+ // CONSTANTS
51
+ // =========================
52
+ const TOOL = "text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand";
53
+ const LEVELS = [1, 2, 3] as const;
54
+
36
55
  // =========================
37
56
  // STATE
38
57
  // =========================
39
58
  let linkUrl = $state("");
40
59
  let showLinkPopover = $state(false);
41
- // null, not undefined: Input's ref falls back to null, and binding undefined to it throws.
42
- let linkInputEl = $state<HTMLInputElement | null>(null);
60
+ let linkButton = $state<HTMLElement | null>(null);
61
+ let linkContent = $state<HTMLElement | null>(null);
43
62
 
44
63
  // =========================
45
64
  // DERIVED
46
65
  // =========================
47
66
  let editor: Editor | null = $derived(tick >= 0 ? editorProp : null);
48
-
49
- // =========================
50
- // EFFECTS
51
- // =========================
52
- $effect(() => {
53
- if (showLinkPopover && linkInputEl) {
54
- linkInputEl.focus();
55
- }
56
- });
67
+ let linkActive = $derived(isActive("link"));
68
+ let heading = $derived(LEVELS.find((level) => isActive("heading", { level }))?.toString() ?? "");
57
69
 
58
70
  // =========================
59
71
  // FUNCTIONS
@@ -66,6 +78,12 @@
66
78
  return tick >= 0 && (editor?.isActive(name, attributes) ?? false);
67
79
  }
68
80
 
81
+ /** The heading group's value: a level, or "" when the pressed one is pressed again. */
82
+ function setHeading(value: string) {
83
+ const level = LEVELS.find((l) => l.toString() === (value || heading));
84
+ if (level) editor?.chain().focus().toggleHeading({ level }).run();
85
+ }
86
+
69
87
  /** Opens the link field, or removes the link under the cursor. The editor binds Cmd+K to it. */
70
88
  export function toggleLink() {
71
89
  if (!editor) return;
@@ -73,215 +91,227 @@
73
91
  editor.chain().focus().unsetLink().run();
74
92
  return;
75
93
  }
76
- const previousUrl = editor.getAttributes("link").href;
77
- linkUrl = previousUrl || "";
94
+ if (showLinkPopover) {
95
+ showLinkPopover = false;
96
+ return;
97
+ }
98
+ linkUrl = editor.getAttributes("link").href || "";
78
99
  showLinkPopover = true;
79
100
  }
80
101
 
81
102
  function applyLink() {
82
- if (!editor) return;
83
- if (linkUrl.trim()) {
84
- editor.chain().focus().setLink({ href: linkUrl.trim() }).run();
85
- }
86
- linkUrl = "";
103
+ if (linkUrl.trim()) editor?.chain().setLink({ href: linkUrl.trim() }).run();
87
104
  showLinkPopover = false;
88
105
  }
89
106
 
90
- function cancelLink() {
107
+ /** Closing the field puts focus back in the text, unless a click already sent it elsewhere. */
108
+ function returnToText(e: Event) {
109
+ e.preventDefault();
91
110
  linkUrl = "";
92
- showLinkPopover = false;
93
- editor?.chain().focus().run();
111
+ const active = document.activeElement;
112
+ if (!active || active === document.body || linkContent?.contains(active)) {
113
+ editor?.commands.focus();
114
+ }
94
115
  }
95
116
  </script>
96
117
 
97
118
  <!-- =========================== -->
98
119
  <!-- MARKUP -->
99
120
  <!-- =========================== -->
100
- <div class="editor-toolbar" role="toolbar">
101
- {#if editor}
102
- {#if showLinkPopover}
103
- <div class="link-popover">
104
- <Input
105
- bind:ref={linkInputEl}
106
- type="url"
107
- bind:value={linkUrl}
108
- placeholder="Paste link..."
109
- class="h-7 w-52 text-[0.8125rem]"
110
- onkeydown={(e) => {
111
- if (e.key === "Enter") {
112
- e.preventDefault();
113
- applyLink();
114
- }
115
- if (e.key === "Escape") cancelLink();
116
- }}
117
- />
118
- <Button size="sm" icon onclick={applyLink} aria-label="Apply link">
119
- <LinkSimple aria-hidden="true" size={14} />
120
- </Button>
121
- <Button
122
- variant="ghost"
123
- size="sm"
124
- icon
125
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
126
- onclick={cancelLink}
127
- title="Cancel"
128
- aria-label="Cancel link"
129
- >
130
- <span aria-hidden="true">&times;</span>
131
- </Button>
132
- </div>
133
- {:else}
134
- <Button
135
- variant="ghost"
136
- size="sm"
137
- icon
138
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
139
- aria-pressed={isActive("heading", { level: 1 })}
140
- onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
141
- title="Heading 1"
142
- >
143
- H1
144
- </Button>
145
- <Button
146
- variant="ghost"
147
- size="sm"
148
- icon
149
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
150
- aria-pressed={isActive("heading", { level: 2 })}
151
- onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
152
- title="Heading 2"
153
- >
154
- H2
155
- </Button>
156
- <Button
157
- variant="ghost"
158
- size="sm"
159
- icon
160
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
161
- aria-pressed={isActive("heading", { level: 3 })}
162
- onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
163
- title="Heading 3"
164
- >
165
- H3
166
- </Button>
121
+ {#snippet tip(label: string, keys?: string)}
122
+ {label}
123
+ {#if keys}<Kbd>{keys}</Kbd>{/if}
124
+ {/snippet}
167
125
 
168
- <div class="toolbar-divider"></div>
126
+ {#snippet tool(t: Tool)}
127
+ <Tooltip.Root>
128
+ <Tooltip.Trigger aria-label={t.label} aria-pressed={t.pressed} onclick={t.run}>
129
+ {#snippet child({ props: trigger })}
130
+ <Toolbar.Button {...trigger}>
131
+ {#snippet child({ props })}
132
+ <Button {...props} variant="ghost" size="sm" icon class={TOOL}>
133
+ <t.icon aria-hidden="true" size={15} weight={t.bold ? "bold" : undefined} />
134
+ </Button>
135
+ {/snippet}
136
+ </Toolbar.Button>
137
+ {/snippet}
138
+ </Tooltip.Trigger>
139
+ <Tooltip.Content>{@render tip(t.label, t.keys)}</Tooltip.Content>
140
+ </Tooltip.Root>
141
+ {/snippet}
169
142
 
170
- <Button
171
- variant="ghost"
172
- size="sm"
173
- icon
174
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
175
- aria-pressed={isActive("bold")}
176
- onclick={() => editor?.chain().focus().toggleBold().run()}
177
- title="Bold (Cmd+B)"
178
- >
179
- <TextB aria-hidden="true" size={15} weight="bold" />
180
- </Button>
181
- <Button
182
- variant="ghost"
183
- size="sm"
184
- icon
185
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
186
- aria-pressed={isActive("italic")}
187
- onclick={() => editor?.chain().focus().toggleItalic().run()}
188
- title="Italic (Cmd+I)"
189
- >
190
- <TextItalic aria-hidden="true" size={15} />
191
- </Button>
192
- <Button
193
- variant="ghost"
194
- size="sm"
195
- icon
196
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
197
- aria-pressed={isActive("strike")}
198
- onclick={() => editor?.chain().focus().toggleStrike().run()}
199
- title="Strikethrough"
200
- >
201
- <TextStrikethrough aria-hidden="true" size={15} />
202
- </Button>
143
+ <Tooltip.Provider>
144
+ <Toolbar.Root aria-label="Formatting">
145
+ {#snippet child({ props: root })}
146
+ <div {...root} class="editor-toolbar">
147
+ {#if editor}
148
+ <Toolbar.Group
149
+ type="single"
150
+ class="flex items-center gap-px"
151
+ aria-label="Heading"
152
+ bind:value={() => heading, setHeading}
153
+ >
154
+ {#each LEVELS as level (level)}
155
+ <Tooltip.Root>
156
+ <Tooltip.Trigger aria-label="Heading {level}">
157
+ {#snippet child({ props: trigger })}
158
+ <Toolbar.GroupItem {...trigger} value={level.toString()}>
159
+ {#snippet child({ props })}
160
+ <Button
161
+ {...props}
162
+ variant="ghost"
163
+ size="sm"
164
+ icon
165
+ class="text-muted-foreground aria-checked:bg-brand/10 aria-checked:text-brand font-heading text-xs font-bold"
166
+ >
167
+ H{level}
168
+ </Button>
169
+ {/snippet}
170
+ </Toolbar.GroupItem>
171
+ {/snippet}
172
+ </Tooltip.Trigger>
173
+ <Tooltip.Content
174
+ >{@render tip(`Heading ${level}`, `Cmd+Alt+${level}`)}</Tooltip.Content
175
+ >
176
+ </Tooltip.Root>
177
+ {/each}
178
+ </Toolbar.Group>
203
179
 
204
- <div class="toolbar-divider"></div>
180
+ <div class="toolbar-divider"></div>
205
181
 
206
- {#if isActive("link")}
207
- <Button
208
- variant="ghost"
209
- size="sm"
210
- icon
211
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
212
- aria-pressed={true}
213
- onclick={() => editor?.chain().focus().unsetLink().run()}
214
- title="Remove link"
215
- >
216
- <LinkBreak aria-hidden="true" size={15} />
217
- </Button>
218
- {:else}
219
- <Button
220
- variant="ghost"
221
- size="sm"
222
- icon
223
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
224
- onclick={toggleLink}
225
- title="Add link (Cmd+K)"
226
- >
227
- <LinkSimple aria-hidden="true" size={15} />
228
- </Button>
229
- {/if}
182
+ {@render tool({
183
+ label: "Bold",
184
+ keys: "Cmd+B",
185
+ icon: TextB,
186
+ bold: true,
187
+ pressed: isActive("bold"),
188
+ run: () => editor?.chain().focus().toggleBold().run(),
189
+ })}
190
+ {@render tool({
191
+ label: "Italic",
192
+ keys: "Cmd+I",
193
+ icon: TextItalic,
194
+ pressed: isActive("italic"),
195
+ run: () => editor?.chain().focus().toggleItalic().run(),
196
+ })}
197
+ {@render tool({
198
+ label: "Strikethrough",
199
+ keys: "Cmd+Shift+S",
200
+ icon: TextStrikethrough,
201
+ pressed: isActive("strike"),
202
+ run: () => editor?.chain().focus().toggleStrike().run(),
203
+ })}
230
204
 
231
- <div class="toolbar-divider"></div>
205
+ <div class="toolbar-divider"></div>
232
206
 
233
- <Button
234
- variant="ghost"
235
- size="sm"
236
- icon
237
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
238
- aria-pressed={isActive("bulletList")}
239
- onclick={() => editor?.chain().focus().toggleBulletList().run()}
240
- title="Bullet list"
241
- >
242
- <ListBullets aria-hidden="true" size={15} />
243
- </Button>
244
- <Button
245
- variant="ghost"
246
- size="sm"
247
- icon
248
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
249
- aria-pressed={isActive("orderedList")}
250
- onclick={() => editor?.chain().focus().toggleOrderedList().run()}
251
- title="Ordered list"
252
- >
253
- <ListNumbers aria-hidden="true" size={15} />
207
+ <!-- One button that changes its job, so the toolbar's Tab stop never lands on a removed node. -->
208
+ <Tooltip.Root>
209
+ <Tooltip.Trigger
210
+ aria-label={linkActive ? "Remove link" : "Add link"}
211
+ aria-pressed={linkActive || undefined}
212
+ aria-expanded={linkActive ? undefined : showLinkPopover}
213
+ onclick={toggleLink}
214
+ >
215
+ {#snippet child({ props: trigger })}
216
+ <Toolbar.Button {...trigger} bind:ref={linkButton}>
217
+ {#snippet child({ props })}
218
+ <Button {...props} variant="ghost" size="sm" icon class={TOOL}>
219
+ {#if linkActive}
220
+ <LinkBreak aria-hidden="true" size={15} />
221
+ {:else}
222
+ <LinkSimple aria-hidden="true" size={15} />
223
+ {/if}
224
+ </Button>
225
+ {/snippet}
226
+ </Toolbar.Button>
227
+ {/snippet}
228
+ </Tooltip.Trigger>
229
+ <Tooltip.Content>
230
+ {@render tip(linkActive ? "Remove link" : "Add link", "Cmd+K")}
231
+ </Tooltip.Content>
232
+ </Tooltip.Root>
233
+
234
+ <div class="toolbar-divider"></div>
235
+
236
+ {@render tool({
237
+ label: "Bullet list",
238
+ keys: "Cmd+Shift+8",
239
+ icon: ListBullets,
240
+ pressed: isActive("bulletList"),
241
+ run: () => editor?.chain().focus().toggleBulletList().run(),
242
+ })}
243
+ {@render tool({
244
+ label: "Ordered list",
245
+ keys: "Cmd+Shift+7",
246
+ icon: ListNumbers,
247
+ pressed: isActive("orderedList"),
248
+ run: () => editor?.chain().focus().toggleOrderedList().run(),
249
+ })}
250
+ {@render tool({
251
+ label: "Quote",
252
+ keys: "Cmd+Shift+B",
253
+ icon: Quotes,
254
+ pressed: isActive("blockquote"),
255
+ run: () => editor?.chain().focus().toggleBlockquote().run(),
256
+ })}
257
+
258
+ {#if preset === "full"}
259
+ <div class="toolbar-divider"></div>
260
+
261
+ {@render tool({
262
+ label: "Insert YouTube video",
263
+ icon: VideoCamera,
264
+ pressed: isActive("youtube"),
265
+ run: onInsertYoutube,
266
+ })}
267
+ {/if}
268
+ {/if}
269
+ </div>
270
+ {/snippet}
271
+ </Toolbar.Root>
272
+ </Tooltip.Provider>
273
+
274
+ <Popover.Root bind:open={showLinkPopover}>
275
+ <Popover.Content
276
+ bind:ref={linkContent}
277
+ customAnchor={linkButton}
278
+ align="start"
279
+ class="w-auto rounded-xl p-1.5"
280
+ onCloseAutoFocus={returnToText}
281
+ onInteractOutside={(e) => {
282
+ if (linkButton?.contains(e.target as Node)) e.preventDefault();
283
+ }}
284
+ >
285
+ <form
286
+ class="link-popover"
287
+ novalidate
288
+ onsubmit={(e) => {
289
+ e.preventDefault();
290
+ applyLink();
291
+ }}
292
+ >
293
+ <Input
294
+ type="url"
295
+ bind:value={linkUrl}
296
+ placeholder="Paste link..."
297
+ class="h-7 w-52 text-[0.8125rem]"
298
+ />
299
+ <Button type="submit" size="sm" icon aria-label="Apply link">
300
+ <LinkSimple aria-hidden="true" size={14} />
254
301
  </Button>
255
302
  <Button
256
303
  variant="ghost"
257
304
  size="sm"
258
305
  icon
259
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
260
- aria-pressed={isActive("blockquote")}
261
- onclick={() => editor?.chain().focus().toggleBlockquote().run()}
262
- title="Quote"
306
+ class="text-muted-foreground"
307
+ onclick={() => (showLinkPopover = false)}
308
+ aria-label="Cancel link"
263
309
  >
264
- <Quotes aria-hidden="true" size={15} />
310
+ <span aria-hidden="true">&times;</span>
265
311
  </Button>
266
-
267
- {#if preset === "full"}
268
- <div class="toolbar-divider"></div>
269
-
270
- <Button
271
- variant="ghost"
272
- size="sm"
273
- icon
274
- class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
275
- aria-pressed={isActive("youtube")}
276
- onclick={onInsertYoutube}
277
- title="Insert YouTube video"
278
- >
279
- <VideoCamera aria-hidden="true" size={15} />
280
- </Button>
281
- {/if}
282
- {/if}
283
- {/if}
284
- </div>
312
+ </form>
313
+ </Popover.Content>
314
+ </Popover.Root>
285
315
 
286
316
  <!-- =========================== -->
287
317
  <!-- STYLE -->
@@ -11,9 +11,11 @@
11
11
  import { buildSlashItems, type SlashItem } from "./slash-items.js";
12
12
  import { SLASH_WINDOW, slashQueryAt } from "./slash-query.js";
13
13
  import { parseYoutubeUrl } from "./youtube-url.js";
14
+ import { safeImageUrl } from "./safe-url.js";
14
15
  import EditorToolbar from "./editor-toolbar.svelte";
15
16
  import SlashMenu from "./slash-menu.svelte";
16
17
  import ImageToolbar from "./image-toolbar.svelte";
18
+ import UrlDialog from "./url-dialog.svelte";
17
19
 
18
20
  // =========================
19
21
  // TYPES
@@ -74,9 +76,12 @@
74
76
  const slashMenuId = `${uid}-slash`;
75
77
  let toolbar: EditorToolbar | undefined = $state();
76
78
 
79
+ // Insert dialogs
80
+ let showImageDialog = $state(false);
81
+ let showVideoDialog = $state(false);
82
+
77
83
  // Image toolbar
78
84
  let showImageToolbar = $state(false);
79
- let imageToolbarPos = $state({ top: 0, left: 0 });
80
85
  let activeImageElement: HTMLImageElement | null = $state(null);
81
86
 
82
87
  // =========================
@@ -121,51 +126,45 @@
121
126
  }
122
127
  });
123
128
 
124
- // Close menus on outside click
129
+ // Close the slash menu on outside click. The image toolbar's Popover closes itself.
125
130
  $effect(() => {
126
- if (!showSlashMenu && !showImageToolbar) return;
127
- function handleClick(e: MouseEvent) {
128
- const target = e.target as Node;
129
- if (slashMenuEl && !slashMenuEl.contains(target)) {
130
- closeSlashMenu();
131
- }
132
- // Close image toolbar when clicking outside editor or on non-image
133
- if (showImageToolbar && editorElement && !editorElement.contains(target)) {
134
- showImageToolbar = false;
135
- activeImageElement = null;
136
- }
137
- }
138
- document.addEventListener("mousedown", handleClick);
139
- return () => document.removeEventListener("mousedown", handleClick);
131
+ if (!showSlashMenu) return;
132
+ document.addEventListener("mousedown", closeSlashMenuOutside);
133
+ return () => document.removeEventListener("mousedown", closeSlashMenuOutside);
140
134
  });
141
135
 
142
136
  // =========================
143
137
  // FUNCTIONS
144
138
  // =========================
145
139
  function insertImageFromSlash() {
146
- const url = prompt("Image URL:");
147
- if (url?.trim()) {
148
- editor?.chain().focus().setImage({ src: url.trim() }).run();
149
- }
140
+ showImageDialog = true;
150
141
  }
151
142
 
152
143
  function insertYoutubeVideo() {
153
- const url = prompt("YouTube URL:");
154
- if (!url?.trim()) return;
155
- const parsed = parseYoutubeUrl(url.trim());
156
- if (!parsed) {
157
- alert("That is not a recognized YouTube URL.");
158
- return;
159
- }
160
- const title = prompt("Video title:")?.trim() || "";
144
+ showVideoDialog = true;
145
+ }
146
+
147
+ // The dialog holds focus while these run, so they leave focusing the text to its close.
148
+ function insertImage(url: string): string | null {
149
+ if (!url) return "Paste an image link.";
150
+ const src = safeImageUrl(url);
151
+ if (!src) return "That is not a link to an image.";
152
+ editor?.chain().setImage({ src }).run();
153
+ return null;
154
+ }
155
+
156
+ function insertVideo(url: string, title: string): string | null {
157
+ if (!url) return "Paste a YouTube link.";
158
+ const parsed = parseYoutubeUrl(url);
159
+ if (!parsed) return "That is not a recognized YouTube URL.";
161
160
  editor
162
161
  ?.chain()
163
- .focus()
164
162
  .insertContent({
165
163
  type: "youtube",
166
164
  attrs: { videoId: parsed.videoId, title, start: parsed.start },
167
165
  })
168
166
  .run();
167
+ return null;
169
168
  }
170
169
 
171
170
  function closeSlashMenu() {
@@ -173,6 +172,10 @@
173
172
  slashQuery = "";
174
173
  }
175
174
 
175
+ function closeSlashMenuOutside(e: MouseEvent) {
176
+ if (slashMenuEl && !slashMenuEl.contains(e.target as Node)) closeSlashMenu();
177
+ }
178
+
176
179
  function executeSlashCommand(item: SlashItem) {
177
180
  if (!editor) return;
178
181
  const { from } = editor.state.selection;
@@ -196,14 +199,7 @@
196
199
  }
197
200
 
198
201
  function openImageToolbar(img: HTMLImageElement) {
199
- const rect = img.getBoundingClientRect();
200
- const wrapperRect = editorElement?.getBoundingClientRect();
201
- if (!wrapperRect) return;
202
202
  activeImageElement = img;
203
- imageToolbarPos = {
204
- top: rect.top - wrapperRect.top - 40,
205
- left: rect.left - wrapperRect.left + rect.width / 2 - 60,
206
- };
207
203
  showImageToolbar = true;
208
204
  }
209
205
 
@@ -419,8 +415,30 @@
419
415
  {/if}
420
416
 
421
417
  <!-- Image toolbar (full preset only) -->
422
- {#if preset === "full" && showImageToolbar}
423
- <ImageToolbar pos={imageToolbarPos} onAlign={setImageAlignment} onDelete={deleteActiveImage} />
418
+ {#if preset === "full"}
419
+ <ImageToolbar
420
+ anchor={activeImageElement}
421
+ bind:open={showImageToolbar}
422
+ onAlign={setImageAlignment}
423
+ onDelete={deleteActiveImage}
424
+ />
425
+ <UrlDialog
426
+ bind:open={showImageDialog}
427
+ heading="Insert image"
428
+ label="Image link"
429
+ placeholder="https://example.com/photo.png"
430
+ onSubmit={insertImage}
431
+ onClosed={() => editor?.commands.focus()}
432
+ />
433
+ <UrlDialog
434
+ bind:open={showVideoDialog}
435
+ heading="Insert YouTube video"
436
+ label="YouTube link"
437
+ placeholder="https://www.youtube.com/watch?v=..."
438
+ titleLabel="Title (optional)"
439
+ onSubmit={insertVideo}
440
+ onClosed={() => editor?.commands.focus()}
441
+ />
424
442
  {/if}
425
443
 
426
444
  <!-- Status bar -->