@glw907/cairn-cms 0.60.0 → 0.60.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.
- package/CHANGELOG.md +13 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +10 -42
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +192 -259
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +665 -1166
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +689 -957
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +140 -248
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +4 -0
- package/dist/components/spellcheck.d.ts +3 -1
- package/dist/components/spellcheck.js +14 -2
- package/dist/delivery/CairnHead.svelte +8 -11
- package/package.json +2 -2
- package/src/lib/components/spellcheck.ts +16 -2
|
@@ -19,142 +19,78 @@ The media-type facet (Images, Documents) is a designed-in seam: it renders only
|
|
|
19
19
|
holds more than one distinct top-level content type, so the structure is present without dead UI
|
|
20
20
|
while a site stores images only.
|
|
21
21
|
-->
|
|
22
|
-
<script module lang="ts">
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
<script module lang="ts"></script>
|
|
23
|
+
|
|
24
|
+
<script lang="ts">import { mediaToken } from "../media/reference.js";
|
|
25
|
+
import { publicPath } from "../media/naming.js";
|
|
26
|
+
let { library, onselect } = $props();
|
|
27
|
+
const uid = $props.id();
|
|
28
|
+
const idBase = `cairn-mp-${uid}`;
|
|
29
|
+
const listboxId = `${idBase}-listbox`;
|
|
30
|
+
let query = $state("");
|
|
31
|
+
let typeFilter = $state("all");
|
|
32
|
+
let activeIndex = $state(-1);
|
|
33
|
+
const entries = $derived(Object.values(library));
|
|
34
|
+
const distinctTypes = $derived.by(() => {
|
|
35
|
+
const seen = [];
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
const top = e.contentType.split("/")[0] ?? "";
|
|
38
|
+
if (top && !seen.includes(top)) seen.push(top);
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let { library, onselect }: Props = $props();
|
|
56
|
-
|
|
57
|
-
// A stable id base so the listbox and each option carry unique ids the combobox can point at.
|
|
58
|
-
// $props.id() is Svelte's deterministic, hydration-stable id source (the same value SSR and the
|
|
59
|
-
// client), unlike a Math.random() base that would mismatch across hydration. It must initialize a
|
|
60
|
-
// top-level variable directly, so the prefix is composed in a second declaration.
|
|
61
|
-
const uid = $props.id();
|
|
62
|
-
const idBase = `cairn-mp-${uid}`;
|
|
63
|
-
const listboxId = `${idBase}-listbox`;
|
|
64
|
-
|
|
65
|
-
let query = $state('');
|
|
66
|
-
// The media-type facet selection: 'all' or a top-level content type ('image', 'application').
|
|
67
|
-
let typeFilter = $state<string>('all');
|
|
68
|
-
// The index of the active option within the filtered list, or -1 for none active yet.
|
|
69
|
-
let activeIndex = $state(-1);
|
|
70
|
-
|
|
71
|
-
const entries = $derived(Object.values(library));
|
|
72
|
-
|
|
73
|
-
// The distinct top-level content types in the library, in first-seen order. The facet is a seam:
|
|
74
|
-
// it renders only when more than one distinct type exists, so a site storing images only sees no
|
|
75
|
-
// dead UI. ('image/webp' and 'image/png' both fold to 'image'.)
|
|
76
|
-
const distinctTypes = $derived.by(() => {
|
|
77
|
-
const seen: string[] = [];
|
|
78
|
-
for (const e of entries) {
|
|
79
|
-
const top = e.contentType.split('/')[0] ?? '';
|
|
80
|
-
if (top && !seen.includes(top)) seen.push(top);
|
|
81
|
-
}
|
|
82
|
-
return seen;
|
|
40
|
+
return seen;
|
|
41
|
+
});
|
|
42
|
+
const showFacet = $derived(distinctTypes.length > 1);
|
|
43
|
+
function typeLabel(top) {
|
|
44
|
+
if (top === "image") return "Images";
|
|
45
|
+
if (top === "application") return "Documents";
|
|
46
|
+
return top.charAt(0).toUpperCase() + top.slice(1);
|
|
47
|
+
}
|
|
48
|
+
const filtered = $derived.by(() => {
|
|
49
|
+
const q = query.trim().toLowerCase();
|
|
50
|
+
return entries.filter((e) => {
|
|
51
|
+
if (typeFilter !== "all" && (e.contentType.split("/")[0] ?? "") !== typeFilter) return false;
|
|
52
|
+
if (!q) return true;
|
|
53
|
+
return e.displayName.toLowerCase().includes(q) || e.alt.toLowerCase().includes(q);
|
|
83
54
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const activeDescendant = $derived(activeEntry ? optionId(activeIndex) : undefined);
|
|
119
|
-
|
|
120
|
-
// The active-row narration text, kept in its own live region so it never clobbers the count.
|
|
121
|
-
const activeNarration = $derived(
|
|
122
|
-
activeEntry
|
|
123
|
-
? `${activeEntry.displayName}${activeEntry.alt.trim() === '' ? ', needs alt text' : ''}, ${activeIndex + 1} of ${filtered.length}`
|
|
124
|
-
: '',
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
/** Build a selection from a library entry: its media: token and its manifest alt. */
|
|
128
|
-
function select(entry: MediaLibraryEntry) {
|
|
129
|
-
onselect({ entry, ref: mediaToken({ slug: entry.slug, hash: entry.hash }), alt: entry.alt });
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function onKeydown(e: KeyboardEvent) {
|
|
133
|
-
if (e.key === 'ArrowDown') {
|
|
134
|
-
e.preventDefault();
|
|
135
|
-
if (filtered.length === 0) return;
|
|
136
|
-
// Clamp at the last option (a deliberate non-wrap, fine per the task).
|
|
137
|
-
activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
|
|
138
|
-
} else if (e.key === 'ArrowUp') {
|
|
139
|
-
e.preventDefault();
|
|
140
|
-
if (filtered.length === 0) return;
|
|
141
|
-
activeIndex = Math.max(activeIndex - 1, 0);
|
|
142
|
-
} else if (e.key === 'Home') {
|
|
143
|
-
if (filtered.length === 0) return;
|
|
144
|
-
e.preventDefault();
|
|
145
|
-
activeIndex = 0;
|
|
146
|
-
} else if (e.key === 'End') {
|
|
147
|
-
if (filtered.length === 0) return;
|
|
55
|
+
});
|
|
56
|
+
$effect(() => {
|
|
57
|
+
if (activeIndex >= filtered.length) activeIndex = filtered.length === 0 ? -1 : filtered.length - 1;
|
|
58
|
+
});
|
|
59
|
+
function optionId(i) {
|
|
60
|
+
return `${idBase}-opt-${i}`;
|
|
61
|
+
}
|
|
62
|
+
const activeEntry = $derived(activeIndex >= 0 ? filtered[activeIndex] ?? null : null);
|
|
63
|
+
const activeDescendant = $derived(activeEntry ? optionId(activeIndex) : void 0);
|
|
64
|
+
const activeNarration = $derived(
|
|
65
|
+
activeEntry ? `${activeEntry.displayName}${activeEntry.alt.trim() === "" ? ", needs alt text" : ""}, ${activeIndex + 1} of ${filtered.length}` : ""
|
|
66
|
+
);
|
|
67
|
+
function select(entry) {
|
|
68
|
+
onselect({ entry, ref: mediaToken({ slug: entry.slug, hash: entry.hash }), alt: entry.alt });
|
|
69
|
+
}
|
|
70
|
+
function onKeydown(e) {
|
|
71
|
+
if (e.key === "ArrowDown") {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
if (filtered.length === 0) return;
|
|
74
|
+
activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
|
|
75
|
+
} else if (e.key === "ArrowUp") {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
if (filtered.length === 0) return;
|
|
78
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
79
|
+
} else if (e.key === "Home") {
|
|
80
|
+
if (filtered.length === 0) return;
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
activeIndex = 0;
|
|
83
|
+
} else if (e.key === "End") {
|
|
84
|
+
if (filtered.length === 0) return;
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
activeIndex = filtered.length - 1;
|
|
87
|
+
} else if (e.key === "Enter") {
|
|
88
|
+
if (activeEntry) {
|
|
148
89
|
e.preventDefault();
|
|
149
|
-
|
|
150
|
-
} else if (e.key === 'Enter') {
|
|
151
|
-
if (activeEntry) {
|
|
152
|
-
e.preventDefault();
|
|
153
|
-
select(activeEntry);
|
|
154
|
-
}
|
|
90
|
+
select(activeEntry);
|
|
155
91
|
}
|
|
156
|
-
// Escape is handled by the host popover (Task 6); let it bubble.
|
|
157
92
|
}
|
|
93
|
+
}
|
|
158
94
|
</script>
|
|
159
95
|
|
|
160
96
|
<div class="flex flex-col gap-3">
|
|
@@ -6,87 +6,55 @@ svelte-sortable-list (mouse, and keyboard with Space to lift, arrows to move, Sp
|
|
|
6
6
|
depth comes from the Indent and Outdent buttons, capped at the menu's maxDepth. The engine
|
|
7
7
|
validates on save.
|
|
8
8
|
-->
|
|
9
|
-
<script lang="ts">
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/** The nav load's data: the menu meta, the current tree, page options, and flags. */
|
|
20
|
-
data: NavLoadData;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
let { data }: Props = $props();
|
|
24
|
-
|
|
25
|
-
// A flat, ordered working model is simpler to reorder than a recursive one: each row carries an
|
|
26
|
-
// explicit depth, and the nested tree is rebuilt from order plus depth only at submit time.
|
|
27
|
-
interface Row {
|
|
28
|
-
id: string;
|
|
29
|
-
depth: number;
|
|
30
|
-
label: string;
|
|
31
|
-
url: string;
|
|
9
|
+
<script lang="ts">import { untrack } from "svelte";
|
|
10
|
+
import CsrfField from "./CsrfField.svelte";
|
|
11
|
+
import { SortableList, sortItems } from "@rodrigodagostino/svelte-sortable-list";
|
|
12
|
+
import "@rodrigodagostino/svelte-sortable-list/styles.css";
|
|
13
|
+
let { data } = $props();
|
|
14
|
+
let nextId = 1;
|
|
15
|
+
function flatten(nodes, depth, out) {
|
|
16
|
+
for (const n of nodes) {
|
|
17
|
+
out.push({ id: `row-${nextId++}`, depth, label: n.label, url: n.url ?? "" });
|
|
18
|
+
if (n.children?.length) flatten(n.children, depth + 1, out);
|
|
32
19
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let rows = $state<Row[]>(untrack(() => flatten(data.tree, 0, [])));
|
|
48
|
-
// depth is 0-based internally; maxDepth in the config is 1-based (1 = flat, 2 = one nesting level)
|
|
49
|
-
const maxDepthIndex = $derived(data.menu.maxDepth - 1);
|
|
50
|
-
|
|
51
|
-
// Rebuild the nested tree from the flat rows by depth, then serialize for the hidden field.
|
|
52
|
-
function toTree(list: Row[]): NavNode[] {
|
|
53
|
-
const root: NavNode[] = [];
|
|
54
|
-
const stack: { depth: number; node: NavNode }[] = [];
|
|
55
|
-
for (const r of list) {
|
|
56
|
-
const node: NavNode = { label: r.label.trim() };
|
|
57
|
-
if (r.url.trim()) node.url = r.url.trim();
|
|
58
|
-
while (stack.length && stack[stack.length - 1].depth >= r.depth) stack.pop();
|
|
59
|
-
if (stack.length) (stack[stack.length - 1].node.children ??= []).push(node);
|
|
60
|
-
else root.push(node);
|
|
61
|
-
stack.push({ depth: r.depth, node });
|
|
62
|
-
}
|
|
63
|
-
return root;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
let rows = $state(untrack(() => flatten(data.tree, 0, [])));
|
|
23
|
+
const maxDepthIndex = $derived(data.menu.maxDepth - 1);
|
|
24
|
+
function toTree(list) {
|
|
25
|
+
const root = [];
|
|
26
|
+
const stack = [];
|
|
27
|
+
for (const r of list) {
|
|
28
|
+
const node = { label: r.label.trim() };
|
|
29
|
+
if (r.url.trim()) node.url = r.url.trim();
|
|
30
|
+
while (stack.length && stack[stack.length - 1].depth >= r.depth) stack.pop();
|
|
31
|
+
if (stack.length) (stack[stack.length - 1].node.children ??= []).push(node);
|
|
32
|
+
else root.push(node);
|
|
33
|
+
stack.push({ depth: r.depth, node });
|
|
64
34
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!isCanceled && typeof targetItemIndex === 'number' && draggedItemIndex !== targetItemIndex) {
|
|
87
|
-
rows = sortItems(rows, draggedItemIndex, targetItemIndex);
|
|
88
|
-
}
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
const treeJson = $derived(JSON.stringify(toTree(rows)));
|
|
38
|
+
function addRow() {
|
|
39
|
+
rows = [...rows, { id: `row-${nextId++}`, depth: 0, label: "New item", url: "" }];
|
|
40
|
+
}
|
|
41
|
+
function removeRow(id) {
|
|
42
|
+
rows = rows.filter((r) => r.id !== id);
|
|
43
|
+
}
|
|
44
|
+
function indent(i) {
|
|
45
|
+
if (i === 0) return;
|
|
46
|
+
const ceiling = Math.min(rows[i - 1].depth + 1, maxDepthIndex);
|
|
47
|
+
if (rows[i].depth < ceiling) rows[i].depth += 1;
|
|
48
|
+
}
|
|
49
|
+
function outdent(i) {
|
|
50
|
+
if (rows[i].depth > 0) rows[i].depth -= 1;
|
|
51
|
+
}
|
|
52
|
+
function handleDragEnd(e) {
|
|
53
|
+
const { draggedItemIndex, targetItemIndex, isCanceled } = e;
|
|
54
|
+
if (!isCanceled && typeof targetItemIndex === "number" && draggedItemIndex !== targetItemIndex) {
|
|
55
|
+
rows = sortItems(rows, draggedItemIndex, targetItemIndex);
|
|
89
56
|
}
|
|
57
|
+
}
|
|
90
58
|
</script>
|
|
91
59
|
|
|
92
60
|
<h1 class="mb-6 text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.menu.label}</h1>
|
|
@@ -5,49 +5,22 @@ moves the entry and rewrites every inbound cairn link in one commit, so no inter
|
|
|
5
5
|
dated post keeps its date; only the slug changes. Built on a native <dialog>, following the
|
|
6
6
|
DeleteDialog a11y conventions.
|
|
7
7
|
-->
|
|
8
|
-
<script lang="ts">
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* to stand down its leave guard while the POST is in flight. */
|
|
25
|
-
onsubmitting?: () => void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let { conceptId, id, label, slug, trigger = true, onsubmitting }: Props = $props();
|
|
29
|
-
|
|
30
|
-
let dialog = $state<HTMLDialogElement | null>(null);
|
|
31
|
-
let slugInput = $state<HTMLInputElement | null>(null);
|
|
32
|
-
// Seeded on open() rather than from the prop at declaration, so the input prefills with the
|
|
33
|
-
// current slug each time the dialog opens without capturing only the initial prop value.
|
|
34
|
-
let nextSlug = $state('');
|
|
35
|
-
|
|
36
|
-
/** Open the dialog with a fresh prefill. Exported so a trigger={false} host can drive it. */
|
|
37
|
-
export function open() {
|
|
38
|
-
nextSlug = slug;
|
|
39
|
-
dialog?.showModal();
|
|
40
|
-
// showModal() lands focus on the first focusable element (the header Close button), so move
|
|
41
|
-
// it to the slug input the dialog exists for, and select the prefill so the author can replace
|
|
42
|
-
// it in one keystroke (WCAG 2.4.3). A microtask defers past the dialog's own focus handling.
|
|
43
|
-
queueMicrotask(() => {
|
|
44
|
-
slugInput?.focus();
|
|
45
|
-
slugInput?.select();
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
function close() {
|
|
49
|
-
dialog?.close();
|
|
50
|
-
}
|
|
8
|
+
<script lang="ts">import CsrfField from "./CsrfField.svelte";
|
|
9
|
+
let { conceptId, id, label, slug, trigger = true, onsubmitting } = $props();
|
|
10
|
+
let dialog = $state(null);
|
|
11
|
+
let slugInput = $state(null);
|
|
12
|
+
let nextSlug = $state("");
|
|
13
|
+
export function open() {
|
|
14
|
+
nextSlug = slug;
|
|
15
|
+
dialog?.showModal();
|
|
16
|
+
queueMicrotask(() => {
|
|
17
|
+
slugInput?.focus();
|
|
18
|
+
slugInput?.select();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function close() {
|
|
22
|
+
dialog?.close();
|
|
23
|
+
}
|
|
51
24
|
</script>
|
|
52
25
|
|
|
53
26
|
{#if trigger}
|
|
@@ -7,19 +7,15 @@ MarkdownHelpDialog recipe; the host (the edit page) drives it through the export
|
|
|
7
7
|
it on Ctrl+/, so the component renders no trigger of its own. Esc dismisses through the dialog's
|
|
8
8
|
native behavior.
|
|
9
9
|
-->
|
|
10
|
-
<script lang="ts">
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
function close() {
|
|
21
|
-
dialog?.close();
|
|
22
|
-
}
|
|
10
|
+
<script lang="ts">import { shortcutsClosingLine } from "./editor-shortcuts.js";
|
|
11
|
+
import ShortcutsGrid from "./ShortcutsGrid.svelte";
|
|
12
|
+
let dialog = $state(null);
|
|
13
|
+
export function open() {
|
|
14
|
+
dialog?.showModal();
|
|
15
|
+
}
|
|
16
|
+
function close() {
|
|
17
|
+
dialog?.close();
|
|
18
|
+
}
|
|
23
19
|
</script>
|
|
24
20
|
|
|
25
21
|
<dialog class="modal" aria-labelledby="cairn-shortcuts-title" bind:this={dialog}>
|
|
@@ -4,8 +4,7 @@ The two-column shortcut grid, the shared body of both discoverability sheets (Sh
|
|
|
4
4
|
the Markdown help dialog). Each row pairs a label with its chord, read from the single
|
|
5
5
|
editor-shortcuts source. The host wraps it with its own heading and any closing line.
|
|
6
6
|
-->
|
|
7
|
-
<script lang="ts">
|
|
8
|
-
import { editorShortcuts } from './editor-shortcuts.js';
|
|
7
|
+
<script lang="ts">import { editorShortcuts } from "./editor-shortcuts.js";
|
|
9
8
|
</script>
|
|
10
9
|
|
|
11
10
|
<div class="grid grid-cols-1 gap-x-8 gap-y-1 text-sm sm:grid-cols-2">
|