@fusion.dev/helix-editor 0.220.0 → 0.222.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.
@@ -0,0 +1,123 @@
1
+ export const EDITOR_CSS = `
2
+ :where(.ProseMirror) {
3
+ font-feature-settings: "liga" 0;
4
+ font-variant-ligatures: none;
5
+ overflow-wrap: break-word;
6
+ position: relative;
7
+ white-space: pre-wrap;
8
+ white-space: break-spaces;
9
+ }
10
+
11
+ :where(.ProseMirror pre) {
12
+ white-space: pre-wrap;
13
+ }
14
+
15
+ :where(.ProseMirror li) {
16
+ position: relative;
17
+ }
18
+
19
+ :where(.ProseMirror-hideselection *)::selection {
20
+ background: transparent;
21
+ }
22
+
23
+ :where(.ProseMirror-hideselection) {
24
+ caret-color: transparent;
25
+ }
26
+
27
+ :where(.ProseMirror [draggable][contenteditable="false"]) {
28
+ user-select: text;
29
+ }
30
+
31
+ :where(.ProseMirror-selectednode) {
32
+ outline: var(--helix-size-focus-ring, 2px) solid var(--editor-selected-color, var(--helix-color-focus-ring, Highlight));
33
+ outline-offset: var(--helix-size-focus-offset, 2px);
34
+ }
35
+
36
+ :where(li.ProseMirror-selectednode) {
37
+ outline: none;
38
+ }
39
+
40
+ :where(li.ProseMirror-selectednode)::after {
41
+ border: var(--helix-size-focus-ring, 2px) solid var(--editor-selected-color, var(--helix-color-focus-ring, Highlight));
42
+ content: "";
43
+ inset-block: calc(var(--helix-size-focus-ring, 2px) * -1);
44
+ inset-inline: calc(var(--helix-space-lg, 1.5rem) * -1) calc(var(--helix-size-focus-ring, 2px) * -1);
45
+ pointer-events: none;
46
+ position: absolute;
47
+ }
48
+
49
+ img.ProseMirror-separator {
50
+ border: none !important;
51
+ display: inline !important;
52
+ margin: 0 !important;
53
+ }
54
+
55
+ :where(.ProseMirror-gapcursor) {
56
+ display: none;
57
+ pointer-events: none;
58
+ position: absolute;
59
+ }
60
+
61
+ :where(.ProseMirror-gapcursor)::after {
62
+ animation: helix-editor-blink 1.1s steps(2, start) infinite;
63
+ border-block-start: var(--helix-border-thin, 1px) solid currentColor;
64
+ content: "";
65
+ display: block;
66
+ inline-size: var(--helix-space-lg, 1.5rem);
67
+ inset-block-start: -2px;
68
+ position: absolute;
69
+ }
70
+
71
+ :where(.ProseMirror-focused .ProseMirror-gapcursor) {
72
+ display: block;
73
+ }
74
+
75
+ @keyframes helix-editor-blink {
76
+ to {
77
+ visibility: hidden;
78
+ }
79
+ }
80
+
81
+ @media (prefers-reduced-motion: reduce) {
82
+ :where(.ProseMirror-gapcursor)::after {
83
+ animation: none;
84
+ }
85
+ }
86
+
87
+ :where(.helix-editor-drop-cursor) {
88
+ background-color: var(--helix-color-primary, Highlight);
89
+ }
90
+
91
+ :where(.helix-find-match) {
92
+ background-color: light-dark(
93
+ oklch(from var(--helix-color-warning, oklch(0.52 0.11 75)) 0.93 0.07 h),
94
+ color-mix(in oklab, var(--helix-color-warning, oklch(0.82 0.185 75)) 32%, transparent)
95
+ );
96
+ }
97
+
98
+ :where(.helix-find-current) {
99
+ background-color: oklch(from var(--helix-color-warning, oklch(0.52 0.11 75)) 0.8 0.16 h);
100
+ color: oklch(from var(--helix-color-warning, oklch(0.52 0.11 75)) 0.25 0.04 h);
101
+ }
102
+
103
+ :where(.helix-editor-placeholder)::before {
104
+ block-size: 0;
105
+ color: var(--editor-placeholder-color, var(--helix-color-foreground-subtle, GrayText));
106
+ content: attr(data-placeholder);
107
+ float: inline-start;
108
+ pointer-events: none;
109
+ }
110
+ `;
111
+ let sheet;
112
+ export const adoptEditorSheet = (root) => {
113
+ if (typeof CSSStyleSheet === 'undefined' || !('adoptedStyleSheets' in root)) {
114
+ return;
115
+ }
116
+ if (!sheet) {
117
+ sheet = new CSSStyleSheet();
118
+ sheet.replaceSync(EDITOR_CSS);
119
+ }
120
+ if (!root.adoptedStyleSheets.includes(sheet)) {
121
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
122
+ }
123
+ };
@@ -0,0 +1,53 @@
1
+ import type { MarkType, ResolvedPos, Schema } from 'prosemirror-model';
2
+ import type { Command, EditorState } from 'prosemirror-state';
3
+ /**
4
+ * A tool the Editor frame's toolbar draws, by the name the frame gives it.
5
+ * The frame's own type is the headless tier's, held to this one by a spec.
6
+ */
7
+ export type EditorTool = 'bold' | 'bullet-list' | 'code' | 'code-block' | 'heading-1' | 'heading-2' | 'heading-3' | 'italic' | 'link' | 'ordered-list' | 'paragraph' | 'quote' | 'redo' | 'strike' | 'undo';
8
+ /** What the toolbar shows about the selection, as the frame takes it. */
9
+ export interface EditorFormats {
10
+ applied: readonly EditorTool[];
11
+ block: EditorTool | '';
12
+ link: string;
13
+ unavailable: readonly EditorTool[];
14
+ }
15
+ /** Every tool, in the order the toolbar draws them. */
16
+ export declare const EDITOR_TOOLS: readonly EditorTool[];
17
+ /**
18
+ * The run of a mark around a position: where the link the caret is in starts
19
+ * and ends, across the text nodes that carry the same mark.
20
+ */
21
+ export declare const markRange: ($pos: ResolvedPos, type: MarkType) => {
22
+ from: number;
23
+ mark: ReturnType<MarkType['create']>;
24
+ to: number;
25
+ } | undefined;
26
+ /**
27
+ * Links the selection to an address, or with an empty address takes its link
28
+ * off. Refused for an address `safeHref` refuses, where the schema has no
29
+ * link, and where the text there takes no link, such as in a code block.
30
+ *
31
+ * A caret in a link relinks or unlinks the whole link. A caret anywhere else
32
+ * writes the address as linked text.
33
+ */
34
+ export declare const setLink: (schema: Schema, href: string) => Command;
35
+ /**
36
+ * The command each tool runs, for the tools the schema has the nodes and
37
+ * marks for: a mark toggled, a block type set, a list or a quote put on or
38
+ * taken off, and undo and redo through `prosemirror-history`. `overrides`
39
+ * replaces any of them, such as undo and redo where y-prosemirror keeps the
40
+ * history. A link takes an address, so `setLink` runs it, and it is not here.
41
+ */
42
+ export declare const toolCommands: (schema: Schema, overrides?: Partial<Record<EditorTool, Command>>) => Partial<Record<EditorTool, Command>>;
43
+ /**
44
+ * What the toolbar shows for a state: the tools whose format holds at the
45
+ * selection, its block type, the address of its link, and the tools that
46
+ * cannot apply there.
47
+ *
48
+ * **A tool cannot apply where its command, asked without being run, refuses.**
49
+ * The block type the selection is already in is the exception: setting a
50
+ * paragraph to a paragraph changes nothing and is refused, and the menu still
51
+ * offers it, checked.
52
+ */
53
+ export declare const formatsOf: (state: EditorState, commands: Partial<Record<EditorTool, Command>>) => EditorFormats;
@@ -0,0 +1,242 @@
1
+ import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands';
2
+ import { redo, undo } from 'prosemirror-history';
3
+ import { liftListItem, wrapInList } from 'prosemirror-schema-list';
4
+ import { liftTarget } from 'prosemirror-transform';
5
+ import { safeHref } from '../schema/schema.js';
6
+ export const EDITOR_TOOLS = [
7
+ 'undo',
8
+ 'redo',
9
+ 'paragraph',
10
+ 'heading-1',
11
+ 'heading-2',
12
+ 'heading-3',
13
+ 'code-block',
14
+ 'bold',
15
+ 'italic',
16
+ 'strike',
17
+ 'code',
18
+ 'link',
19
+ 'bullet-list',
20
+ 'ordered-list',
21
+ 'quote',
22
+ ];
23
+ const MARKS = { bold: 'strong', code: 'code', italic: 'em', strike: 'strike' };
24
+ const HEADINGS = { 'heading-1': 1, 'heading-2': 2, 'heading-3': 3 };
25
+ const listAround = ($pos, types) => {
26
+ for (let depth = $pos.depth; depth > 0; depth -= 1) {
27
+ const node = $pos.node(depth);
28
+ if (types.includes(node.type)) {
29
+ return { depth, node };
30
+ }
31
+ }
32
+ return undefined;
33
+ };
34
+ const inside = ($pos, type) => {
35
+ for (let depth = $pos.depth; depth > 0; depth -= 1) {
36
+ if ($pos.node(depth).type === type) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ };
42
+ const toggleList = (listType, item, lists) => (state, dispatch) => {
43
+ const around = listAround(state.selection.$from, lists);
44
+ if (around?.node.type === listType) {
45
+ return liftListItem(item)(state, dispatch);
46
+ }
47
+ if (around) {
48
+ if (dispatch) {
49
+ const pos = state.selection.$from.before(around.depth);
50
+ dispatch(state.tr.setNodeMarkup(pos, listType, { id: around.node.attrs.id }).scrollIntoView());
51
+ }
52
+ return true;
53
+ }
54
+ return wrapInList(listType)(state, dispatch);
55
+ };
56
+ const toggleQuote = (quote) => (state, dispatch) => {
57
+ const { $from, $to } = state.selection;
58
+ if (inside($from, quote)) {
59
+ const range = $from.blockRange($to, (node) => node.type === quote);
60
+ const target = range ? liftTarget(range) : null;
61
+ if (!range || target === null) {
62
+ return false;
63
+ }
64
+ if (dispatch) {
65
+ dispatch(state.tr.lift(range, target).scrollIntoView());
66
+ }
67
+ return true;
68
+ }
69
+ return wrapIn(quote)(state, dispatch);
70
+ };
71
+ export const markRange = ($pos, type) => {
72
+ const parent = $pos.parent;
73
+ const start = $pos.start();
74
+ let index = $pos.index();
75
+ let child = parent.maybeChild(index);
76
+ let mark = child ? type.isInSet(child.marks) : undefined;
77
+ if (!mark && $pos.textOffset === 0 && index > 0) {
78
+ index -= 1;
79
+ child = parent.maybeChild(index);
80
+ mark = child ? type.isInSet(child.marks) : undefined;
81
+ }
82
+ if (!(child && mark)) {
83
+ return undefined;
84
+ }
85
+ let first = index;
86
+ while (first > 0 && mark.isInSet(parent.child(first - 1).marks)) {
87
+ first -= 1;
88
+ }
89
+ let last = index;
90
+ while (last < parent.childCount - 1 && mark.isInSet(parent.child(last + 1).marks)) {
91
+ last += 1;
92
+ }
93
+ let from = start;
94
+ for (let at = 0; at < first; at += 1) {
95
+ from += parent.child(at).nodeSize;
96
+ }
97
+ let to = from;
98
+ for (let at = first; at <= last; at += 1) {
99
+ to += parent.child(at).nodeSize;
100
+ }
101
+ return { from, mark, to };
102
+ };
103
+ export const setLink = (schema, href) => (state, dispatch) => {
104
+ const type = schema.marks.link;
105
+ const { $from, empty, from, to } = state.selection;
106
+ if (!type || !$from.parent.type.allowsMarkType(type)) {
107
+ return false;
108
+ }
109
+ const around = empty ? markRange($from, type) : undefined;
110
+ const [start, end] = around ? [around.from, around.to] : [from, to];
111
+ if (href === '') {
112
+ if (!state.doc.rangeHasMark(start, end, type)) {
113
+ return false;
114
+ }
115
+ if (dispatch) {
116
+ dispatch(state.tr.removeMark(start, end, type).scrollIntoView());
117
+ }
118
+ return true;
119
+ }
120
+ const address = safeHref(href);
121
+ if (address === '') {
122
+ return false;
123
+ }
124
+ if (dispatch) {
125
+ const mark = type.create({ href: address });
126
+ const tr = empty && !around
127
+ ? state.tr.insertText(address, from).addMark(from, from + address.length, mark)
128
+ : state.tr.removeMark(start, end, type).addMark(start, end, mark);
129
+ dispatch(tr.scrollIntoView());
130
+ }
131
+ return true;
132
+ };
133
+ export const toolCommands = (schema, overrides = {}) => {
134
+ const { nodes, marks } = schema;
135
+ const commands = { redo, undo };
136
+ for (const [tool, name] of Object.entries(MARKS)) {
137
+ const mark = marks[name];
138
+ if (mark) {
139
+ commands[tool] = toggleMark(mark);
140
+ }
141
+ }
142
+ if (nodes.paragraph) {
143
+ commands.paragraph = setBlockType(nodes.paragraph);
144
+ }
145
+ const heading = nodes.heading;
146
+ if (heading) {
147
+ for (const [tool, level] of Object.entries(HEADINGS)) {
148
+ commands[tool] = setBlockType(heading, { level });
149
+ }
150
+ }
151
+ if (nodes.code_block) {
152
+ commands['code-block'] = setBlockType(nodes.code_block);
153
+ }
154
+ const item = nodes.list_item;
155
+ const lists = [nodes.bullet_list, nodes.ordered_list].filter((type) => type !== undefined);
156
+ if (item && nodes.bullet_list) {
157
+ commands['bullet-list'] = toggleList(nodes.bullet_list, item, lists);
158
+ }
159
+ if (item && nodes.ordered_list) {
160
+ commands['ordered-list'] = toggleList(nodes.ordered_list, item, lists);
161
+ }
162
+ if (nodes.blockquote) {
163
+ commands.quote = toggleQuote(nodes.blockquote);
164
+ }
165
+ return { ...commands, ...overrides };
166
+ };
167
+ const blockOf = ($pos) => {
168
+ const node = $pos.parent;
169
+ switch (node.type.name) {
170
+ case 'paragraph':
171
+ return 'paragraph';
172
+ case 'code_block':
173
+ return 'code-block';
174
+ case 'heading': {
175
+ const level = node.attrs.level;
176
+ return level === 1 ? 'heading-1' : level === 2 ? 'heading-2' : level === 3 ? 'heading-3' : '';
177
+ }
178
+ default:
179
+ return '';
180
+ }
181
+ };
182
+ const markHolds = (state, type) => {
183
+ const { $from, empty, from, to } = state.selection;
184
+ return empty
185
+ ? type.isInSet(state.storedMarks ?? $from.marks()) !== undefined
186
+ : state.doc.rangeHasMark(from, to, type);
187
+ };
188
+ const linkAt = (state, type) => {
189
+ if (!type) {
190
+ return '';
191
+ }
192
+ const { $from, empty, from, to } = state.selection;
193
+ if (empty) {
194
+ return markRange($from, type)?.mark.attrs.href ?? '';
195
+ }
196
+ let href = '';
197
+ state.doc.nodesBetween(from, to, (node) => {
198
+ const mark = href === '' ? type.isInSet(node.marks) : undefined;
199
+ if (mark) {
200
+ href = mark.attrs.href;
201
+ }
202
+ return href === '';
203
+ });
204
+ return href;
205
+ };
206
+ export const formatsOf = (state, commands) => {
207
+ const { schema } = state;
208
+ const { $from } = state.selection;
209
+ const applied = [];
210
+ for (const [tool, name] of Object.entries(MARKS)) {
211
+ const mark = schema.marks[name];
212
+ if (mark && markHolds(state, mark)) {
213
+ applied.push(tool);
214
+ }
215
+ }
216
+ const lists = [schema.nodes.bullet_list, schema.nodes.ordered_list].filter((type) => type !== undefined);
217
+ const list = listAround($from, lists);
218
+ if (list?.node.type.name === 'bullet_list') {
219
+ applied.push('bullet-list');
220
+ }
221
+ else if (list?.node.type.name === 'ordered_list') {
222
+ applied.push('ordered-list');
223
+ }
224
+ const quote = schema.nodes.blockquote;
225
+ if (quote && inside($from, quote)) {
226
+ applied.push('quote');
227
+ }
228
+ const block = blockOf($from);
229
+ const link = linkAt(state, schema.marks.link);
230
+ const unavailable = EDITOR_TOOLS.filter((tool) => {
231
+ if (tool === block) {
232
+ return false;
233
+ }
234
+ if (tool === 'link') {
235
+ const type = schema.marks.link;
236
+ return !(type && $from.parent.type.allowsMarkType(type));
237
+ }
238
+ const command = commands[tool];
239
+ return !command?.(state);
240
+ });
241
+ return { applied, block, link, unavailable };
242
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-editor",
3
- "description": "Connects the Helix Editor frame to CodeMirror, themed with your tokens: highlighting, line numbers, folding, search, and diagnostics.",
4
- "version": "0.220.0",
3
+ "description": "Connects the Helix Editor frame to ProseMirror: a schema whose blocks keep their ids, the editing plugins, and a document drawn in your tokens.",
4
+ "version": "0.222.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -32,34 +32,36 @@
32
32
  },
33
33
  "bugs": "https://github.com/project-fusion/fusion/issues",
34
34
  "dependencies": {
35
- "@codemirror/commands": "^6.11.1",
36
- "@codemirror/lang-css": "^6.3.1",
37
- "@codemirror/lang-html": "^6.4.12",
38
- "@codemirror/lang-javascript": "^6.2.5",
39
- "@codemirror/lang-json": "^6.0.2",
40
- "@codemirror/lang-markdown": "^6.3.4",
41
- "@codemirror/lang-python": "^6.2.1",
42
- "@codemirror/lang-rust": "^6.0.2",
43
- "@codemirror/language": "^6.13.1",
44
- "@codemirror/legacy-modes": "^6.5.5",
45
- "@codemirror/lint": "^6.9.7",
46
- "@codemirror/search": "^6.5.11",
47
- "@codemirror/state": "^6.7.6",
48
- "@codemirror/view": "^6.43.14",
49
- "@fusion.dev/helix-headless": "0.220.0",
50
- "@fusion.dev/helix-sandbox": "0.220.0"
35
+ "prosemirror-commands": "^1.7.2",
36
+ "prosemirror-dropcursor": "^1.8.4",
37
+ "prosemirror-gapcursor": "^1.4.1",
38
+ "prosemirror-history": "^1.5.1",
39
+ "prosemirror-inputrules": "^1.5.1",
40
+ "prosemirror-keymap": "^1.2.3",
41
+ "prosemirror-schema-list": "^1.5.1"
51
42
  },
52
43
  "devDependencies": {
53
- "@fusion.dev/helix-testing": "0.220.0",
54
- "@types/bun": "^1.4.2"
44
+ "@fusion.dev/helix-headless": "0.222.0",
45
+ "@fusion.dev/helix-testing": "0.222.0",
46
+ "@types/bun": "^1.4.3",
47
+ "prosemirror-model": "^1.25.12",
48
+ "prosemirror-state": "^1.4.4",
49
+ "prosemirror-transform": "^1.12.2",
50
+ "prosemirror-view": "^1.42.6"
55
51
  },
56
52
  "homepage": "https://fusion.dev",
57
53
  "keywords": [
58
54
  "editor",
59
- "codemirror",
60
- "code",
61
- "syntax-highlighting"
55
+ "prosemirror",
56
+ "rich-text",
57
+ "contenteditable"
62
58
  ],
59
+ "peerDependencies": {
60
+ "prosemirror-model": "^1.25.0",
61
+ "prosemirror-state": "^1.4.0",
62
+ "prosemirror-transform": "^1.10.0",
63
+ "prosemirror-view": "^1.40.0"
64
+ },
63
65
  "publishConfig": {
64
66
  "access": "restricted"
65
67
  }
@@ -1,25 +0,0 @@
1
- import { type Extension } from '@codemirror/state';
2
- import type { EditorEngine } from '@fusion.dev/helix-headless/code-editor';
3
- /** The language a name means, or nothing for plain text and a name no grammar answers. */
4
- export declare const languageName: (name: string) => string | undefined;
5
- /** Every name `language` takes, its aliases included. */
6
- export declare const languageNames: () => string[];
7
- /** What `codeMirrorEngine` takes beyond what the editor sets. */
8
- export interface CodeMirrorOptions {
9
- /** More CodeMirror extensions, such as completions or a language server client, added after Helix's own. */
10
- extensions?: Extension[];
11
- }
12
- /**
13
- * CodeMirror 6, drawing a Helix Editor: highlighting in the theme's syntax
14
- * colors, line numbers, folding, bracket matching, search (Command+F, or
15
- * Control+F elsewhere), history, and diagnostics underlined in their tone.
16
- * Tab indents, and Escape then Tab leaves the editor.
17
- */
18
- export declare const codeMirrorEngine: (options?: CodeMirrorOptions) => EditorEngine;
19
- /**
20
- * Connects CodeMirror to an editor, with `codeMirrorEngine(options)`, and
21
- * returns what takes it away again, leaving the editor a plain text field.
22
- */
23
- export declare const connectEditor: (editor: {
24
- engine: EditorEngine | undefined;
25
- }, options?: CodeMirrorOptions) => (() => void);