@k2b/editor 0.0.0 → 0.1.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 (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +32 -1
  3. package/package.json +116 -4
  4. package/src/BubbleMenu.tsx +126 -0
  5. package/src/Editor.tsx +32 -0
  6. package/src/SlashMenu.tsx +13 -0
  7. package/src/Toolbar.tsx +205 -0
  8. package/src/actions.ts +102 -0
  9. package/src/chart-schema.ts +274 -0
  10. package/src/charts.ts +710 -0
  11. package/src/collab/history.ts +39 -0
  12. package/src/collab/index.ts +32 -0
  13. package/src/collab/relative-cursor.ts +18 -0
  14. package/src/collab/remote-cursors.ts +358 -0
  15. package/src/editor-state.ts +199 -0
  16. package/src/formula.ts +1283 -0
  17. package/src/index.ts +16 -0
  18. package/src/markdown/_lib/cursor-zone-field.ts +338 -0
  19. package/src/markdown/_lib/inline-syntax.ts +9 -0
  20. package/src/markdown/_lib/link-opener.ts +50 -0
  21. package/src/markdown/_lib/list-item-line.ts +34 -0
  22. package/src/markdown/_lib/list-structure.ts +227 -0
  23. package/src/markdown/_lib/math-syntax.ts +3 -0
  24. package/src/markdown/_lib/table-cell.ts +74 -0
  25. package/src/markdown/basic.ts +37 -0
  26. package/src/markdown/block-previews.tsx +453 -0
  27. package/src/markdown/chart-block-messages.ts +5 -0
  28. package/src/markdown/chart-copy.ts +20 -0
  29. package/src/markdown/code-fence-snippets.ts +167 -0
  30. package/src/markdown/code-font.ts +54 -0
  31. package/src/markdown/completion-icon.ts +9 -0
  32. package/src/markdown/data-block-template.ts +11 -0
  33. package/src/markdown/data-blocks.ts +214 -0
  34. package/src/markdown/editor-scope.ts +92 -0
  35. package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
  36. package/src/markdown/fade-scroll-margins.ts +25 -0
  37. package/src/markdown/image-syntax.ts +26 -0
  38. package/src/markdown/images.ts +192 -0
  39. package/src/markdown/index.ts +54 -0
  40. package/src/markdown/info-block-snippets.ts +159 -0
  41. package/src/markdown/info-blocks.ts +156 -0
  42. package/src/markdown/katex.ts +303 -0
  43. package/src/markdown/lib/chart-blocks.ts +511 -0
  44. package/src/markdown/lib/heading-anchors.ts +40 -0
  45. package/src/markdown/lib/ligatures.ts +107 -0
  46. package/src/markdown/lib/markdown-context.ts +72 -0
  47. package/src/markdown/lib/markdown-fences.ts +22 -0
  48. package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
  49. package/src/markdown/lib/markdown-image.ts +36 -0
  50. package/src/markdown/lib/named-blocks.ts +403 -0
  51. package/src/markdown/lib/query-blocks.ts +145 -0
  52. package/src/markdown/ligatures.ts +223 -0
  53. package/src/markdown/links.ts +158 -0
  54. package/src/markdown/list-commands.ts +1124 -0
  55. package/src/markdown/lists.ts +290 -0
  56. package/src/markdown/mark.ts +103 -0
  57. package/src/markdown/markdown.ts +78 -0
  58. package/src/markdown/markup.ts +385 -0
  59. package/src/markdown/mermaid-export.ts +78 -0
  60. package/src/markdown/mermaid-preview.tsx +76 -0
  61. package/src/markdown/mermaid-render.ts +22 -0
  62. package/src/markdown/mermaid-viewport.tsx +78 -0
  63. package/src/markdown/mermaid.ts +242 -0
  64. package/src/markdown/messages.ts +17 -0
  65. package/src/markdown/named-blocks.ts +84 -0
  66. package/src/markdown/note-scroller.browser-harness.ts +84 -0
  67. package/src/markdown/note-scroller.ts +57 -0
  68. package/src/markdown/notebook-charts.tsx +142 -0
  69. package/src/markdown/pretty-table-messages.ts +33 -0
  70. package/src/markdown/pretty-table.ts +220 -0
  71. package/src/markdown/search-theme.ts +98 -0
  72. package/src/markdown/shared-tables.ts +286 -0
  73. package/src/markdown/sub-sup.ts +115 -0
  74. package/src/markdown/surface-press.ts +17 -0
  75. package/src/markdown/tab-indent.ts +104 -0
  76. package/src/markdown/table-columns.ts +188 -0
  77. package/src/markdown/table-formulas.ts +178 -0
  78. package/src/markdown/tables.ts +703 -0
  79. package/src/markdown/theme.ts +243 -0
  80. package/src/markdown/urls.ts +29 -0
  81. package/src/messages.ts +45 -0
  82. package/src/slash-menu.ts +220 -0
  83. package/src/styles.css +376 -0
package/README.md CHANGED
@@ -1 +1,32 @@
1
- # @k2b/editor\n\nPlaceholder. Real versions come from https://github.com/k2b-dev/cloud.
1
+ # @k2b/editor
2
+
3
+ Configurable Markdown editor for SolidJS. An editor, a toolbar, a bubble menu,
4
+ and a slash menu share one editor state; applications place them where they
5
+ want and describe their actions as data.
6
+
7
+ ```tsx
8
+ import { Editor, Toolbar, createEditor, markdownActions } from "@k2b/editor";
9
+
10
+ const editor = createEditor();
11
+ const actions = markdownActions(editor, "en");
12
+
13
+ <Toolbar editor={editor} groups={[{ items: [actions.bold, actions.italic] }]} />
14
+ <Editor editor={editor} label="Note" maxLength={100_000} toolbar={false} />
15
+ ```
16
+
17
+ Optional CodeMirror extensions and source parsers are exported from
18
+ `@k2b/editor/markdown`. Hosts supply directive ranges, URL resolution and
19
+ opening callbacks, and Mermaid configuration. Import `@k2b/editor/styles.css`
20
+ in the host's Tailwind stylesheet; the themes use `--editor-measure`
21
+ for the reading column.
22
+
23
+ The package builds on `@k2b/ui`'s text editor and does not depend on Cloud.
24
+ It ships TypeScript and Solid JSX source; bundle it with a Solid compiler, as
25
+ for `@k2b/ui` islands.
26
+
27
+ Documentation: <https://github.com/k2b-dev/cloud/blob/main/docs-site/docs/en/frontend/editor.md>
28
+
29
+ License: AGPL-3.0-or-later.
30
+
31
+ Source parsers also have direct `@k2b/editor/markdown/lib/*` entry points,
32
+ so server and CLI consumers can load them without the editor widgets.
package/package.json CHANGED
@@ -1,8 +1,120 @@
1
1
  {
2
2
  "name": "@k2b/editor",
3
- "version": "0.0.0",
4
- "description": "Placeholder. Releases are published by the k2b-dev/cloud release workflow.",
3
+ "version": "0.1.0",
4
+ "description": "Configurable Markdown editor for SolidJS: editor, toolbar, bubble menu, and slash menu on one shared state.",
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "*.css"
8
+ ],
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./markdown": "./src/markdown/index.ts",
12
+ "./styles.css": "./src/styles.css",
13
+ "./markdown/lib/*": "./src/markdown/lib/*.ts",
14
+ "./markdown/urls": "./src/markdown/urls.ts",
15
+ "./markdown/mermaid-render": "./src/markdown/mermaid-render.ts",
16
+ "./markdown/mermaid-viewport": "./src/markdown/mermaid-viewport.tsx",
17
+ "./markdown/inline-syntax": "./src/markdown/_lib/inline-syntax.ts",
18
+ "./markdown/math-syntax": "./src/markdown/_lib/math-syntax.ts",
19
+ "./markdown/fade-scroll-margins": "./src/markdown/fade-scroll-margins.ts",
20
+ "./collab": "./src/collab/index.ts",
21
+ "./formula": "./src/formula.ts",
22
+ "./charts": "./src/charts.ts",
23
+ "./markdown/pretty-table": "./src/markdown/pretty-table.ts",
24
+ "./markdown/pretty-table-messages": "./src/markdown/pretty-table-messages.ts",
25
+ "./markdown/chart-copy": "./src/markdown/chart-copy.ts",
26
+ "./markdown/notebook-charts": "./src/markdown/notebook-charts.tsx"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
30
+ },
31
+ "peerDependencies": {
32
+ "solid-js": "^1.9.10",
33
+ "y-codemirror.next": "^0.3.6",
34
+ "y-protocols": "^1.0.7",
35
+ "yjs": "^13.6.33",
36
+ "zod": "^4.3.4"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "y-codemirror.next": {
40
+ "optional": true
41
+ },
42
+ "y-protocols": {
43
+ "optional": true
44
+ },
45
+ "yjs": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "dependencies": {
50
+ "@codemirror/autocomplete": "6.20.3",
51
+ "@codemirror/commands": "6.10.4",
52
+ "@codemirror/lang-cpp": "6.0.3",
53
+ "@codemirror/lang-css": "6.3.1",
54
+ "@codemirror/lang-go": "6.0.1",
55
+ "@codemirror/lang-html": "6.4.12",
56
+ "@codemirror/lang-java": "6.0.2",
57
+ "@codemirror/lang-javascript": "6.2.5",
58
+ "@codemirror/lang-json": "6.0.2",
59
+ "@codemirror/lang-markdown": "6.5.2",
60
+ "@codemirror/lang-php": "6.0.2",
61
+ "@codemirror/lang-python": "6.2.1",
62
+ "@codemirror/lang-rust": "6.0.2",
63
+ "@codemirror/lang-sql": "6.10.0",
64
+ "@codemirror/lang-xml": "6.1.0",
65
+ "@codemirror/lang-yaml": "6.1.3",
66
+ "@codemirror/language": "6.12.4",
67
+ "@codemirror/lint": "6.9.7",
68
+ "@codemirror/search": "6.7.2",
69
+ "@codemirror/state": "6.7.1",
70
+ "@codemirror/view": "6.43.7",
71
+ "@k2b/stdlib": "0.30.0",
72
+ "@k2b/ui": "0.37.0",
73
+ "@lezer/common": "1.5.3",
74
+ "@lezer/highlight": "1.2.4",
75
+ "@lezer/markdown": "1.7.2",
76
+ "@uiw/codemirror-themes": "4.25.12",
77
+ "katex": "0.18.9",
78
+ "marked": "18.0.14",
79
+ "mermaid": "12.0.0"
80
+ },
81
+ "devDependencies": {
82
+ "@types/bun": "1.4.2",
83
+ "solid-js": "1.9.17",
84
+ "typescript": "7.0.2",
85
+ "playwright": "1.64.1",
86
+ "bun-plugin-tailwind": "0.1.2",
87
+ "zod": "4.6.5",
88
+ "y-codemirror.next": "0.3.6",
89
+ "y-protocols": "1.0.7",
90
+ "yjs": "13.6.33"
91
+ },
5
92
  "license": "AGPL-3.0-or-later",
6
- "repository": { "type": "git", "url": "git+https://github.com/k2b-dev/cloud.git", "directory": "packages/editor" },
7
- "publishConfig": { "access": "public" }
93
+ "author": "Valentin Kolb",
94
+ "repository": {
95
+ "type": "git",
96
+ "url": "git+https://github.com/k2b-dev/cloud.git",
97
+ "directory": "packages/editor"
98
+ },
99
+ "homepage": "https://github.com/k2b-dev/cloud/tree/main/packages/editor",
100
+ "bugs": "https://github.com/k2b-dev/cloud/issues",
101
+ "keywords": [
102
+ "solid",
103
+ "solidjs",
104
+ "editor",
105
+ "markdown",
106
+ "codemirror",
107
+ "toolbar"
108
+ ],
109
+ "files": [
110
+ "src",
111
+ "!src/**/*.test.ts",
112
+ "!src/**/*.test.tsx",
113
+ "!src/**/*.fixture.ts",
114
+ "README.md",
115
+ "LICENSE"
116
+ ],
117
+ "publishConfig": {
118
+ "access": "public"
119
+ }
8
120
  }
@@ -0,0 +1,126 @@
1
+ import { Prec } from "@codemirror/state";
2
+ import { EditorView, keymap } from "@codemirror/view";
3
+ import { SelectionToolbar, Toolbar, useLocale } from "@k2b/ui";
4
+ import { createSignal, For, type JSX, Show } from "solid-js";
5
+ import type { EditorAction, EditorHandle } from "./editor-state";
6
+ import { editorMessages } from "./messages";
7
+ import { ToolbarButton } from "./Toolbar";
8
+
9
+ export type BubbleMenuProps = {
10
+ editor: EditorHandle;
11
+ /** Groups of actions, separated by a line. */
12
+ groups: EditorAction[][];
13
+ /** Accessible name; defaults to "Selection". */
14
+ label?: string;
15
+ /** Extra controls after the actions, such as "Comment". */
16
+ children?: JSX.Element;
17
+ /** The area the menu stays in, such as the editor's scroll area under a toolbar. */
18
+ boundary?: () => HTMLElement | null | undefined;
19
+ /** Show the menu also in a read-only editor, for hosts whose children work there. Defaults to `false`. */
20
+ readOnly?: boolean;
21
+ class?: string;
22
+ };
23
+
24
+ /** The first line of a selection in viewport coordinates; the menu centers over it. */
25
+ const selectionBox = (view: EditorView, from: number, to: number) => {
26
+ const start = view.coordsAtPos(from, 1);
27
+ const end = view.coordsAtPos(to, -1);
28
+ if (!start || !end) return null;
29
+ const sameLine = Math.abs(start.top - end.top) < 4;
30
+ const right = sameLine ? end.right : view.contentDOM.getBoundingClientRect().right;
31
+ return new DOMRect(start.left, start.top, Math.max(1, right - start.left), (sameLine ? end.bottom : start.bottom) - start.top);
32
+ };
33
+
34
+ /**
35
+ * A compact menu that floats at selected text. It opens when a selection settles, not while a pointer still drags
36
+ * it, and closes on Escape, a collapsed selection, or when the editor loses focus. On touch it sits below the
37
+ * selection, where the system's own selection menu does not cover it.
38
+ */
39
+ export function BubbleMenu(props: BubbleMenuProps) {
40
+ const locale = useLocale();
41
+ const t = () => editorMessages.resolve([locale()]).t;
42
+ const [dragging, setDragging] = createSignal(false);
43
+ const [dismissed, setDismissed] = createSignal<unknown>();
44
+ const [geometry, setGeometry] = createSignal(0);
45
+ // Focus that moves from the text into the menu, such as Tab to a button or a host input, keeps it open.
46
+ const [menuFocused, setMenuFocused] = createSignal(false);
47
+ const dismiss = () => {
48
+ setDismissed(props.editor.state()?.selection);
49
+ props.editor.view()?.focus();
50
+ };
51
+
52
+ props.editor.use([
53
+ EditorView.domEventHandlers({
54
+ pointerdown: () => {
55
+ setDragging(true);
56
+ const release = () => {
57
+ setDragging(false);
58
+ document.removeEventListener("pointerup", release, true);
59
+ document.removeEventListener("pointercancel", release, true);
60
+ };
61
+ document.addEventListener("pointerup", release, true);
62
+ document.addEventListener("pointercancel", release, true);
63
+ return false;
64
+ },
65
+ }),
66
+ EditorView.updateListener.of((update) => {
67
+ if (update.geometryChanged || update.viewportChanged) setGeometry((value) => value + 1);
68
+ }),
69
+ // Escape in the text closes an open menu first; with no menu, the key keeps its other meanings.
70
+ Prec.high(
71
+ keymap.of([
72
+ {
73
+ key: "Escape",
74
+ run: () => {
75
+ if (!anchor()) return false;
76
+ dismiss();
77
+ return true;
78
+ },
79
+ },
80
+ ]),
81
+ ),
82
+ ]);
83
+
84
+ const anchor = () => {
85
+ geometry();
86
+ const view = props.editor.view();
87
+ const state = props.editor.state();
88
+ if (!view || !state || dragging() || !(props.editor.focused() || menuFocused())) return null;
89
+ if (state.readOnly && !props.readOnly) return null;
90
+ const range = state.selection.main;
91
+ if (range.empty || dismissed() === state.selection) return null;
92
+ return selectionBox(view, range.from, range.to);
93
+ };
94
+ const coarse = () => typeof window !== "undefined" && window.matchMedia("(pointer: coarse)").matches;
95
+
96
+ return (
97
+ <SelectionToolbar
98
+ label={props.label ?? t().selection}
99
+ anchor={anchor}
100
+ placement={coarse() ? "below" : "above"}
101
+ boundary={props.boundary}
102
+ class={props.class}
103
+ onEscape={dismiss}
104
+ >
105
+ <div
106
+ style={{ display: "contents" }}
107
+ onFocusIn={() => setMenuFocused(true)}
108
+ onFocusOut={(event) => {
109
+ if (!(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) setMenuFocused(false);
110
+ }}
111
+ >
112
+ <For each={props.groups}>
113
+ {(group, index) => (
114
+ <>
115
+ <Show when={index() > 0}>
116
+ <Toolbar.Separator />
117
+ </Show>
118
+ <For each={group}>{(action) => <ToolbarButton editor={props.editor} action={action} size="sm" />}</For>
119
+ </>
120
+ )}
121
+ </For>
122
+ {props.children}
123
+ </div>
124
+ </SelectionToolbar>
125
+ );
126
+ }
package/src/Editor.tsx ADDED
@@ -0,0 +1,32 @@
1
+ import type { EditorView } from "@codemirror/view";
2
+ import { TextEditor, type TextEditorProps } from "@k2b/ui/text-editor";
3
+ import { onCleanup, splitProps } from "solid-js";
4
+ import type { EditorHandle } from "./editor-state";
5
+
6
+ export type EditorProps = TextEditorProps & {
7
+ /** Shared state from `createEditor()`; toolbars and menus placed elsewhere read and change the same editor. */
8
+ editor: EditorHandle;
9
+ };
10
+
11
+ /**
12
+ * The editing surface. It mounts `@k2b/ui`'s `TextEditor` with the handle's extensions, so every place added through
13
+ * `editor.use` reaches the view. All `TextEditor` props pass through unchanged.
14
+ */
15
+ export function Editor(props: EditorProps) {
16
+ const [local, rest] = splitProps(props, ["editor", "extensions", "onReady"]);
17
+ let mounted: EditorView | undefined;
18
+ onCleanup(() => {
19
+ if (mounted) local.editor.detach(mounted);
20
+ });
21
+ return (
22
+ <TextEditor
23
+ {...rest}
24
+ extensions={[local.editor.extension(), local.extensions ?? []]}
25
+ onReady={(controller, controls) => {
26
+ mounted = controller.view;
27
+ local.editor.attach(controller.view, { controller, controls });
28
+ local.onReady?.(controller, controls);
29
+ }}
30
+ />
31
+ );
32
+ }
@@ -0,0 +1,13 @@
1
+ import type { EditorHandle } from "./editor-state";
2
+ import { type SlashMenuOptions, slashMenu } from "./slash-menu";
3
+
4
+ export type SlashMenuProps = SlashMenuOptions & { editor: EditorHandle };
5
+
6
+ /**
7
+ * The slash menu as a place of the shared editor. It renders at the caret, so it has no markup of its own here; the
8
+ * actions and sources it reads are tracked, and a new list reconfigures only this menu.
9
+ */
10
+ export function SlashMenu(props: SlashMenuProps) {
11
+ props.editor.use(() => slashMenu({ actions: props.actions, sources: props.sources }));
12
+ return null;
13
+ }
@@ -0,0 +1,205 @@
1
+ import { EditorView } from "@codemirror/view";
2
+ import { Dropdown, type DropdownPosition, IconButton, Tooltip, Toolbar as UiToolbar, useLocale } from "@k2b/ui";
3
+ import { createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
4
+ import { ariaShortcut, shortcutLabel } from "./actions";
5
+ import type { EditorAction, EditorHandle } from "./editor-state";
6
+ import { editorMessages } from "./messages";
7
+
8
+ /** A menu of actions behind one icon trigger, such as "More". */
9
+ export type ToolbarMenu = {
10
+ kind: "menu";
11
+ id: string;
12
+ icon: string;
13
+ label: string;
14
+ sections: { label?: string; actions: EditorAction[] }[];
15
+ position?: DropdownPosition;
16
+ width?: string;
17
+ };
18
+ /** Host-rendered control that needs more than an action, such as a style picker; it reads the same handle. */
19
+ export type ToolbarCustom = { kind: "custom"; id: string; render: () => JSX.Element };
20
+ export type ToolbarItem = EditorAction | ToolbarMenu | ToolbarCustom;
21
+ export type ToolbarGroup = {
22
+ label?: string;
23
+ class?: string;
24
+ /** Class of the separator after this group; the last group has none. */
25
+ separatorClass?: string;
26
+ items: ToolbarItem[];
27
+ };
28
+
29
+ export type ToolbarProps = {
30
+ editor: EditorHandle;
31
+ groups: ToolbarGroup[];
32
+ /** Accessible name of the action row; defaults to "Formatting". */
33
+ label?: string;
34
+ /** Controls before and after the action row, such as navigation or a sync status. */
35
+ start?: JSX.Element;
36
+ end?: JSX.Element;
37
+ class?: string;
38
+ /** Class of the scrolling action row inside. */
39
+ toolsClass?: string;
40
+ /**
41
+ * On touch screens with an open keyboard, lift the toolbar to the keyboard's top edge while the editor has focus,
42
+ * and give up the same height in the page above it, so the caret line stays visible.
43
+ */
44
+ liftAboveKeyboard?: boolean;
45
+ };
46
+
47
+ const isMenu = (item: ToolbarItem): item is ToolbarMenu => "kind" in item && item.kind === "menu";
48
+ const isCustom = (item: ToolbarItem): item is ToolbarCustom => "kind" in item && item.kind === "custom";
49
+
50
+ /** One icon button for an action, with its shortcut in the tooltip and the pressed and disabled state of the editor. */
51
+ export function ToolbarButton(props: { editor: EditorHandle; action: EditorAction; size?: "xs" | "sm" }) {
52
+ const locale = useLocale();
53
+ const t = () => editorMessages.resolve([locale()]).t;
54
+ const enabled = () => props.editor.isEnabled(props.action);
55
+ return (
56
+ <Tooltip.Anchor
57
+ content={
58
+ props.action.shortcut
59
+ ? t().withShortcut({ label: props.action.label, keys: shortcutLabel(props.action.shortcut, locale()) })
60
+ : props.action.label
61
+ }
62
+ >
63
+ <IconButton
64
+ label={props.action.label}
65
+ tooltip={false}
66
+ size={props.size ?? "xs"}
67
+ aria-pressed={props.action.isActive ? (props.editor.isActive(props.action) ? "true" : "false") : undefined}
68
+ aria-disabled={enabled() ? undefined : "true"}
69
+ aria-keyshortcuts={props.action.shortcut ? ariaShortcut(props.action.shortcut) : undefined}
70
+ onClick={() => void props.editor.run(props.action)}
71
+ class="text-dimmed"
72
+ >
73
+ <i class={`ti ${props.action.icon} text-sm`} aria-hidden="true" />
74
+ </IconButton>
75
+ </Tooltip.Anchor>
76
+ );
77
+ }
78
+
79
+ function ToolbarMenuButton(props: { editor: EditorHandle; menu: ToolbarMenu; coarse: boolean }) {
80
+ const locale = useLocale();
81
+ // On touch screens a controlled menu opens on a tap without taking focus, so the note keeps focus and the keyboard.
82
+ const [open, setOpen] = createSignal(false);
83
+ return (
84
+ <Dropdown.Root
85
+ open={props.coarse ? open() : undefined}
86
+ onOpenChange={setOpen}
87
+ position={props.menu.position ?? "top-right"}
88
+ width={props.menu.width}
89
+ items={props.menu.sections.map((section) => ({
90
+ sectionLabel: section.label,
91
+ items: section.actions.map((action) => ({
92
+ icon: `ti ${action.icon}`,
93
+ label: action.label,
94
+ description: action.shortcut ? shortcutLabel(action.shortcut, locale()) : undefined,
95
+ disabled: !props.editor.isEnabled(action),
96
+ action: () => void props.editor.run(action),
97
+ })),
98
+ }))}
99
+ >
100
+ <Dropdown.Trigger iconOnly label={props.menu.label} size="xs" class="text-dimmed" tooltip={props.menu.label}>
101
+ <i class={`ti ${props.menu.icon} text-sm`} aria-hidden="true" />
102
+ </Dropdown.Trigger>
103
+ </Dropdown.Root>
104
+ );
105
+ }
106
+
107
+ /**
108
+ * A formatting toolbar for the shared editor, placed wherever the host wants it. Pressing a control keeps focus and
109
+ * selection in the editor; every action stays apart from the surrounding typing in the undo history.
110
+ */
111
+ export function Toolbar(props: ToolbarProps) {
112
+ const locale = useLocale();
113
+ const t = () => editorMessages.resolve([locale()]).t;
114
+ const [coarse, setCoarse] = createSignal(false);
115
+ const [lift, setLift] = createSignal(0);
116
+ let root!: HTMLDivElement;
117
+
118
+ onMount(() => {
119
+ const pointer = window.matchMedia("(pointer: coarse)");
120
+ setCoarse(pointer.matches);
121
+ const viewport = window.visualViewport;
122
+ let disposed = false;
123
+ // A phone keyboard covers the bottom of the page and shrinks only the visual viewport. Fine pointers never lift.
124
+ // Coarse pointers lift only after losing at least 15% of the page height, so the shortcuts bar of a hardware
125
+ // keyboard does not lift it. While zoomed, the toolbar stays in flow.
126
+ const measure = () => {
127
+ if (disposed || !viewport || !props.liftAboveKeyboard) return;
128
+ const view = props.editor.view();
129
+ const focused =
130
+ !!view && (view.hasFocus || view.contentDOM.contains(document.activeElement) || root.contains(document.activeElement));
131
+ const inFlowBottom = root.getBoundingClientRect().bottom + lift();
132
+ const pageHeight = document.documentElement.clientHeight;
133
+ const keyboardOpen = viewport.scale === 1 && pageHeight - viewport.height > pageHeight * 0.15;
134
+ const next = pointer.matches && focused && keyboardOpen ? Math.max(0, inFlowBottom - viewport.offsetTop - viewport.height) : 0;
135
+ const previous = lift();
136
+ if (Math.abs(next - previous) < 0.5) return;
137
+ setLift(next);
138
+ // A shorter page may hide the caret line; CodeMirror brings it back after it measured the new height.
139
+ if (view && next > previous) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head) });
140
+ };
141
+ const pointerChanged = () => {
142
+ setCoarse(pointer.matches);
143
+ measure();
144
+ };
145
+ // Picking an item returns focus to the trigger and then the editor within one task. Measuring between drops the lift.
146
+ const afterFocus = () => queueMicrotask(measure);
147
+ pointer.addEventListener("change", pointerChanged);
148
+ viewport?.addEventListener("resize", measure);
149
+ viewport?.addEventListener("scroll", measure);
150
+ window.addEventListener("resize", measure);
151
+ window.addEventListener("scroll", measure);
152
+ document.addEventListener("focusin", afterFocus);
153
+ document.addEventListener("focusout", afterFocus);
154
+ onCleanup(() => {
155
+ disposed = true;
156
+ pointer.removeEventListener("change", pointerChanged);
157
+ viewport?.removeEventListener("resize", measure);
158
+ viewport?.removeEventListener("scroll", measure);
159
+ window.removeEventListener("resize", measure);
160
+ window.removeEventListener("scroll", measure);
161
+ document.removeEventListener("focusin", afterFocus);
162
+ document.removeEventListener("focusout", afterFocus);
163
+ });
164
+ // A toolbar mounted with the keyboard already open lifts at once.
165
+ measure();
166
+ });
167
+
168
+ return (
169
+ // Pointer presses on any control, menu items included, keep focus and selection in the editor. A native listener,
170
+ // because the menus are popovers inside this element and must not depend on delegation reaching them.
171
+ <div
172
+ ref={root}
173
+ class={props.class ?? "k2b-editor-toolbar flex min-w-0 items-center gap-2 text-dimmed"}
174
+ style={{ "margin-bottom": lift() ? `${lift()}px` : undefined }}
175
+ on:mousedown={(event) => event.preventDefault()}
176
+ >
177
+ {props.start}
178
+ <UiToolbar label={props.label ?? t().formatting} class={props.toolsClass ?? "no-scrollbar min-w-0 flex-1 overflow-x-auto"}>
179
+ <For each={props.groups}>
180
+ {(group, index) => (
181
+ <>
182
+ <UiToolbar.Group label={group.label} class={group.class ?? "shrink-0"}>
183
+ <For each={group.items}>
184
+ {(item) =>
185
+ isCustom(item) ? (
186
+ item.render()
187
+ ) : isMenu(item) ? (
188
+ <ToolbarMenuButton editor={props.editor} menu={item} coarse={coarse()} />
189
+ ) : (
190
+ <ToolbarButton editor={props.editor} action={item} />
191
+ )
192
+ }
193
+ </For>
194
+ </UiToolbar.Group>
195
+ <Show when={index() < props.groups.length - 1}>
196
+ <UiToolbar.Separator class={group.separatorClass} />
197
+ </Show>
198
+ </>
199
+ )}
200
+ </For>
201
+ </UiToolbar>
202
+ {props.end}
203
+ </div>
204
+ );
205
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import type { EditorState } from "@codemirror/state";
3
+ import type { EditorView } from "@codemirror/view";
4
+ import { formatShortcut, keyboardPlatform } from "@k2b/ui";
5
+ import { type InlineFormat, textEditorCommands } from "@k2b/ui/text-editor";
6
+ import type { EditorAction, EditorHandle } from "./editor-state";
7
+ import { editorMessages } from "./messages";
8
+
9
+ /** Apple keyboards name the platform modifier Command; everyone else uses Control. */
10
+ export const isApple = () => keyboardPlatform() === "apple";
11
+
12
+ /** A CodeMirror key spec such as `Mod-Shift-z`, written with the shared key names (`⇧⌘Z`, `Ctrl+Shift+Z`). */
13
+ export const shortcutLabel = (keys: string, locale?: string) => {
14
+ const minus = keys === "-" || keys.endsWith("--");
15
+ const parts = (minus ? keys.slice(0, -1) : keys).split("-");
16
+ if (minus) parts.pop();
17
+ const key = minus ? "-" : parts.pop();
18
+ return formatShortcut([...parts, key ?? ""].join("+"), { locale }).label;
19
+ };
20
+
21
+ /** The same keys in `aria-keyshortcuts` syntax, such as `Meta+Shift+2`. */
22
+ export const ariaShortcut = (keys: string) =>
23
+ keys
24
+ .split("-")
25
+ .map((part) =>
26
+ part === "Mod" ? (isApple() ? "Meta" : "Control") : part === "Ctrl" ? "Control" : part.length === 1 ? part.toUpperCase() : part,
27
+ )
28
+ .join("+");
29
+
30
+ /** Whether the selection head sits inside a syntax node with one of these names. */
31
+ const inside = (state: EditorState, names: string[]) => {
32
+ for (
33
+ let node: ReturnType<typeof syntaxTree>["topNode"] | null = syntaxTree(state).resolveInner(state.selection.main.head, -1);
34
+ node;
35
+ node = node.parent
36
+ )
37
+ if (names.includes(node.name)) return true;
38
+ return false;
39
+ };
40
+ const lineStarts = (pattern: RegExp) => (state: EditorState) => pattern.test(state.doc.lineAt(state.selection.main.head).text);
41
+
42
+ /**
43
+ * Markdown actions on `@k2b/ui`'s text editor commands, labelled in the locale. Hosts pick and order them per place
44
+ * and add their own; ids stay stable across locales. In a focused table cell, inline formats apply to the cell and
45
+ * block formats are off, because they would split the table row.
46
+ */
47
+ export const markdownActions = (editor: EditorHandle, locale?: string) => {
48
+ const { t } = editorMessages.resolve(locale ? [locale] : []);
49
+ const inCell = () => !!editor.controls()?.tableActive();
50
+ const action = (
51
+ id: string,
52
+ icon: string,
53
+ label: string,
54
+ run: (view: EditorView) => boolean,
55
+ extra: Partial<EditorAction> & { cell?: InlineFormat } = {},
56
+ ): EditorAction => {
57
+ const { cell, ...rest } = extra;
58
+ return {
59
+ id,
60
+ icon,
61
+ label,
62
+ run: (view) => {
63
+ if (!cell || !editor.controls()?.formatCell(cell)) run(view);
64
+ },
65
+ isEnabled: cell ? undefined : () => !inCell(),
66
+ ...rest,
67
+ };
68
+ };
69
+ return {
70
+ bold: action("bold", "ti-bold", t.bold, textEditorCommands.bold, {
71
+ cell: "bold",
72
+ shortcut: "Mod-b",
73
+ isActive: (s) => inside(s, ["StrongEmphasis"]),
74
+ }),
75
+ italic: action("italic", "ti-italic", t.italic, textEditorCommands.italic, {
76
+ cell: "italic",
77
+ shortcut: "Mod-i",
78
+ isActive: (s) => inside(s, ["Emphasis"]),
79
+ }),
80
+ strikethrough: action("strikethrough", "ti-strikethrough", t.strikethrough, textEditorCommands.strike, {
81
+ cell: "strike",
82
+ shortcut: "Mod-Shift-x",
83
+ isActive: (s) => inside(s, ["Strikethrough"]),
84
+ }),
85
+ code: action("code", "ti-code", t.code, textEditorCommands.code, { cell: "code", isActive: (s) => inside(s, ["InlineCode"]) }),
86
+ link: action("link", "ti-link", t.link, textEditorCommands.link, {
87
+ cell: "link",
88
+ shortcut: "Mod-k",
89
+ isActive: (s) => inside(s, ["Link"]),
90
+ }),
91
+ heading1: action("heading1", "ti-h-1", t.heading1, textEditorCommands.heading1, { isActive: lineStarts(/^# /) }),
92
+ heading2: action("heading2", "ti-h-2", t.heading2, textEditorCommands.heading2, { isActive: lineStarts(/^## /) }),
93
+ heading3: action("heading3", "ti-h-3", t.heading3, textEditorCommands.heading3, { isActive: lineStarts(/^### /) }),
94
+ bulletList: action("bulletList", "ti-list", t.bulletList, textEditorCommands.bulletList, { isActive: lineStarts(/^\s*[-*] /) }),
95
+ numberedList: action("numberedList", "ti-list-numbers", t.numberedList, textEditorCommands.orderedList, {
96
+ isActive: lineStarts(/^\s*\d+\. /),
97
+ }),
98
+ quote: action("quote", "ti-quote", t.quote, textEditorCommands.quote, { isActive: lineStarts(/^> ?/) }),
99
+ codeBlock: action("codeBlock", "ti-code-plus", t.codeBlock, textEditorCommands.codeBlock),
100
+ table: action("table", "ti-table", t.table, textEditorCommands.table),
101
+ };
102
+ };