@heyramzi/ui 3.1.0 → 3.2.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.
- package/dist/lib/sliding-pill.js +2 -2
- package/dist/record/measure-clamp.js +23 -0
- package/dist/shadcn/button-group/index.js +6 -0
- package/dist/shadcn/checkbox/checkbox-box.js +3 -0
- package/dist/shadcn/input-group/index.js +9 -0
- package/dist/shadcn/item/index.js +13 -0
- package/dist/shadcn/native-select/index.js +6 -0
- package/dist/shadcn/pagination/index.js +12 -0
- package/package.json +1 -1
- package/scripts/sync-shadcn.mjs +0 -5
- package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
- package/src/canvas/pen/PenOverlay.svelte +1 -30
- package/src/canvas/screen/PartView.svelte +13 -8
- package/src/canvas/screen/ScreenBody.svelte +18 -11
- package/src/canvas/ui/BarTooltip.svelte +46 -0
- package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
- package/src/canvas/ui/CanvasToolbar.svelte +170 -156
- package/src/canvas/ui/StylePanel.svelte +179 -102
- package/src/canvas/ui/canvas-ui.css +12 -76
- package/src/charts/dashboard/data-table.svelte +80 -80
- package/src/charts/dashboard/hint.svelte +34 -0
- package/src/charts/dashboard/hint.svelte.d.ts +10 -0
- package/src/charts/dashboard/rating-chip.svelte +4 -3
- package/src/charts/legend.css +0 -8
- package/src/charts/legend.svelte +26 -30
- package/src/components/copy/copy-button.svelte +10 -2
- package/src/components/copy/copy-button.test.ts +7 -0
- package/src/components/data-table/table-skeleton.svelte +15 -7
- package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
- package/src/components/docs/on-this-page.svelte +2 -2
- package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
- package/src/components/list-page/filter-item.svelte +20 -7
- package/src/components/list-page/filter-popover.svelte +41 -16
- package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
- package/src/components/list-page/grouped-list.svelte +41 -23
- package/src/components/picker/picker.svelte +8 -3
- package/src/components/picker/picker.svelte.d.ts +2 -1
- package/src/components/segmented/segmented-filter.svelte +19 -10
- package/src/components/segmented/segmented-track.svelte +11 -3
- package/src/components/table-view/table-view.svelte +22 -6
- package/src/components/tags-input/tags-input.svelte +94 -77
- package/src/components/toolbar/toolbar.svelte +2 -1
- package/src/components/toolbar/toolbar.test.ts +5 -4
- package/src/components/work-parts/group-head.svelte +28 -9
- package/src/components/work-parts/group-head.svelte.d.ts +5 -2
- package/src/editor/editor-toolbar.svelte +223 -193
- package/src/editor/editor.svelte +56 -38
- package/src/editor/image-toolbar.svelte +99 -37
- package/src/editor/image-toolbar.svelte.d.ts +4 -5
- package/src/editor/url-dialog.svelte +96 -0
- package/src/editor/url-dialog.svelte.d.ts +15 -0
- package/src/effects/theme-toggle/ThemeToggle.css +8 -8
- package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
- package/src/inbox/thread-inbox.svelte +9 -1
- package/src/lib/live-region.svelte +12 -4
- package/src/lib/live-region.svelte.d.ts +5 -1
- package/src/lib/sliding-pill.ts +3 -2
- package/src/record/info-list.svelte +33 -59
- package/src/record/measure-clamp.ts +31 -0
- package/src/record/record-shell.svelte +38 -39
- package/src/record/record-timeline.svelte +10 -5
- package/src/record/steps-rail.svelte +50 -36
- package/src/record/where-we-are.svelte +5 -19
- package/src/shadcn/button-group/button-group-separator.svelte +23 -0
- package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
- package/src/shadcn/button-group/button-group-text.svelte +28 -0
- package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
- package/src/shadcn/button-group/button-group.svelte +46 -0
- package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
- package/src/shadcn/button-group/index.ts +15 -0
- package/src/shadcn/checkbox/checkbox-box.ts +4 -0
- package/src/shadcn/checkbox/checkbox.svelte +2 -4
- package/src/shadcn/input-group/index.ts +22 -0
- package/src/shadcn/input-group/input-group-addon.svelte +51 -0
- package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
- package/src/shadcn/input-group/input-group-button.svelte +49 -0
- package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
- package/src/shadcn/input-group/input-group-input.svelte +20 -0
- package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
- package/src/shadcn/input-group/input-group-text.svelte +19 -0
- package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
- package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
- package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
- package/src/shadcn/input-group/input-group.svelte +24 -0
- package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
- package/src/shadcn/item/index.ts +34 -0
- package/src/shadcn/item/item-actions.svelte +20 -0
- package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
- package/src/shadcn/item/item-content.svelte +23 -0
- package/src/shadcn/item/item-content.svelte.d.ts +5 -0
- package/src/shadcn/item/item-description.svelte +23 -0
- package/src/shadcn/item/item-description.svelte.d.ts +5 -0
- package/src/shadcn/item/item-footer.svelte +20 -0
- package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
- package/src/shadcn/item/item-group.svelte +21 -0
- package/src/shadcn/item/item-group.svelte.d.ts +5 -0
- package/src/shadcn/item/item-header.svelte +20 -0
- package/src/shadcn/item/item-header.svelte.d.ts +5 -0
- package/src/shadcn/item/item-media.svelte +42 -0
- package/src/shadcn/item/item-media.svelte.d.ts +17 -0
- package/src/shadcn/item/item-separator.svelte +19 -0
- package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
- package/src/shadcn/item/item-title.svelte +20 -0
- package/src/shadcn/item/item-title.svelte.d.ts +5 -0
- package/src/shadcn/item/item.svelte +61 -0
- package/src/shadcn/item/item.svelte.d.ts +28 -0
- package/src/shadcn/native-select/index.ts +13 -0
- package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
- package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
- package/src/shadcn/native-select/native-select-option.svelte +20 -0
- package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
- package/src/shadcn/native-select/native-select.svelte +45 -0
- package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
- package/src/shadcn/pagination/index.ts +31 -0
- package/src/shadcn/pagination/pagination-content.svelte +20 -0
- package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
- package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
- package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
- package/src/shadcn/pagination/pagination-item.svelte +14 -0
- package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
- package/src/shadcn/pagination/pagination-link.svelte +41 -0
- package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
- package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
- package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
- package/src/shadcn/pagination/pagination-next.svelte +26 -0
- package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
- package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
- package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
- package/src/shadcn/pagination/pagination-previous.svelte +30 -0
- package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
- package/src/shadcn/pagination/pagination.svelte +27 -0
- package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
- package/src/slides/Controls.svelte +39 -36
- package/src/slides/Deck.svelte +11 -8
- package/src/thread/thread.svelte +15 -8
- package/src/work/detail/task-detail.svelte +1 -1
- package/src/work/gantt/gantt-task-row.svelte +3 -6
- package/src/work/inbox/inbox-card.svelte +7 -5
- package/src/work/inbox/inbox-trace.svelte +14 -10
- package/src/work/kanban-board.svelte +40 -25
- package/src/work/kanban-hidden-rail.svelte +67 -65
- package/src/work/task-list.svelte +145 -124
- package/src/work/task-list.svelte.test.ts +5 -2
- 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
|
-
|
|
42
|
-
let
|
|
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
|
-
|
|
77
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
{
|
|
102
|
-
|
|
103
|
-
|
|
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">×</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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
180
|
+
<div class="toolbar-divider"></div>
|
|
205
181
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
205
|
+
<div class="toolbar-divider"></div>
|
|
232
206
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
title="Quote"
|
|
306
|
+
class="text-muted-foreground"
|
|
307
|
+
onclick={() => (showLinkPopover = false)}
|
|
308
|
+
aria-label="Cancel link"
|
|
263
309
|
>
|
|
264
|
-
<
|
|
310
|
+
<span aria-hidden="true">×</span>
|
|
265
311
|
</Button>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 -->
|
package/src/editor/editor.svelte
CHANGED
|
@@ -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
|
|
129
|
+
// Close the slash menu on outside click. The image toolbar's Popover closes itself.
|
|
125
130
|
$effect(() => {
|
|
126
|
-
if (!showSlashMenu
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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"
|
|
423
|
-
<ImageToolbar
|
|
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 -->
|