@dxos/react-ui-editor 0.4.8-main.eb5ae94 → 0.4.8-main.fff1521

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 (127) hide show
  1. package/dist/lib/browser/index.mjs +2725 -2711
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/TextEditor.d.ts +18 -34
  5. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +80 -5
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/Toolbar/Toolbar.d.ts +11 -5
  9. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +4 -2
  11. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/automerge.d.ts +1 -1
  13. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.stories.d.ts +2 -1
  15. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  17. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  18. package/dist/types/src/extensions/automerge/defs.d.ts +1 -15
  19. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  21. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/sync.d.ts +2 -1
  23. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -1
  25. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  26. package/dist/types/src/{hooks → extensions/awareness}/awareness-provider.d.ts +1 -1
  27. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -0
  28. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -0
  29. package/dist/types/src/extensions/awareness/index.d.ts +3 -0
  30. package/dist/types/src/extensions/awareness/index.d.ts.map +1 -0
  31. package/dist/types/src/extensions/comments.d.ts +2 -2
  32. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  33. package/dist/types/src/extensions/cursor-line-margin.d.ts +6 -7
  34. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -1
  35. package/dist/types/src/extensions/cursor.d.ts +1 -1
  36. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  37. package/dist/types/src/extensions/doc.d.ts +3 -0
  38. package/dist/types/src/extensions/doc.d.ts.map +1 -0
  39. package/dist/types/src/extensions/factories.d.ts +49 -0
  40. package/dist/types/src/extensions/factories.d.ts.map +1 -0
  41. package/dist/types/src/extensions/index.d.ts +4 -1
  42. package/dist/types/src/extensions/index.d.ts.map +1 -1
  43. package/dist/types/src/extensions/markdown/bundle.d.ts +5 -4
  44. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  45. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/formatting.d.ts +8 -5
  47. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  49. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/table.d.ts +2 -2
  51. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  52. package/dist/types/src/extensions/modes.d.ts +1 -0
  53. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  54. package/dist/types/src/extensions/state.d.ts +20 -0
  55. package/dist/types/src/extensions/state.d.ts.map +1 -0
  56. package/dist/types/src/extensions/types.d.ts +9 -0
  57. package/dist/types/src/extensions/types.d.ts.map +1 -0
  58. package/dist/types/src/hooks/index.d.ts +1 -4
  59. package/dist/types/src/hooks/index.d.ts.map +1 -1
  60. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  61. package/dist/types/src/hooks/useDocAccessor.d.ts +7 -0
  62. package/dist/types/src/hooks/useDocAccessor.d.ts.map +1 -0
  63. package/dist/types/src/hooks/useTextEditor.d.ts +3 -44
  64. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  65. package/dist/types/src/hooks/useTextEditor.stories.d.ts +7 -0
  66. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  67. package/dist/types/src/themes/default.d.ts +0 -3
  68. package/dist/types/src/themes/default.d.ts.map +1 -1
  69. package/dist/types/src/translations.d.ts +1 -0
  70. package/dist/types/src/translations.d.ts.map +1 -1
  71. package/package.json +25 -27
  72. package/src/components/TextEditor/TextEditor.stories.tsx +520 -23
  73. package/src/components/TextEditor/TextEditor.tsx +45 -156
  74. package/src/components/Toolbar/Toolbar.stories.tsx +54 -55
  75. package/src/components/Toolbar/Toolbar.tsx +96 -35
  76. package/src/extensions/automerge/automerge.stories.tsx +16 -17
  77. package/src/extensions/automerge/automerge.ts +2 -1
  78. package/src/extensions/automerge/cursor.ts +1 -1
  79. package/src/extensions/automerge/defs.ts +1 -22
  80. package/src/extensions/automerge/index.ts +0 -1
  81. package/src/extensions/automerge/sync.ts +2 -9
  82. package/src/extensions/automerge/update-automerge.ts +2 -1
  83. package/src/{hooks → extensions/awareness}/awareness-provider.ts +1 -1
  84. package/src/extensions/{awareness.ts → awareness/awareness.ts} +1 -1
  85. package/src/extensions/awareness/index.ts +6 -0
  86. package/src/extensions/command/state.ts +1 -1
  87. package/src/extensions/comments.ts +9 -5
  88. package/src/extensions/cursor-line-margin.ts +17 -18
  89. package/src/extensions/cursor.ts +1 -1
  90. package/src/extensions/doc.ts +14 -0
  91. package/src/extensions/factories.ts +167 -0
  92. package/src/extensions/index.ts +4 -1
  93. package/src/extensions/markdown/bundle.ts +11 -28
  94. package/src/extensions/markdown/decorate.ts +2 -4
  95. package/src/extensions/markdown/formatting.test.ts +13 -13
  96. package/src/extensions/markdown/formatting.ts +106 -93
  97. package/src/extensions/markdown/image.ts +7 -0
  98. package/src/extensions/markdown/table.ts +11 -3
  99. package/src/extensions/modes.ts +3 -1
  100. package/src/extensions/state.ts +90 -0
  101. package/src/extensions/types.ts +16 -0
  102. package/src/hooks/index.ts +1 -5
  103. package/src/hooks/useActionHandler.ts +5 -1
  104. package/src/hooks/useDocAccessor.ts +21 -0
  105. package/src/hooks/useTextEditor.stories.tsx +40 -35
  106. package/src/hooks/useTextEditor.ts +14 -120
  107. package/src/index.ts +1 -1
  108. package/src/themes/default.ts +1 -28
  109. package/src/translations.ts +1 -0
  110. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +0 -103
  111. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +0 -1
  112. package/dist/types/src/extensions/awareness.d.ts.map +0 -1
  113. package/dist/types/src/extensions/outliner.d.ts +0 -4
  114. package/dist/types/src/extensions/outliner.d.ts.map +0 -1
  115. package/dist/types/src/hooks/awareness-provider.d.ts.map +0 -1
  116. package/dist/types/src/hooks/defs.d.ts +0 -20
  117. package/dist/types/src/hooks/defs.d.ts.map +0 -1
  118. package/dist/types/src/hooks/useEditorView.d.ts +0 -20
  119. package/dist/types/src/hooks/useEditorView.d.ts.map +0 -1
  120. package/dist/types/src/hooks/useTextModel.d.ts +0 -33
  121. package/dist/types/src/hooks/useTextModel.d.ts.map +0 -1
  122. package/src/components/TextEditor/MarkdownEditor.stories.tsx +0 -524
  123. package/src/extensions/outliner.ts +0 -12
  124. package/src/hooks/defs.ts +0 -30
  125. package/src/hooks/useEditorView.ts +0 -32
  126. package/src/hooks/useTextModel.ts +0 -121
  127. /package/dist/types/src/extensions/{awareness.d.ts → awareness/awareness.d.ts} +0 -0
@@ -0,0 +1,167 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { closeBrackets } from '@codemirror/autocomplete';
6
+ import { history } from '@codemirror/commands';
7
+ import { bracketMatching } from '@codemirror/language';
8
+ import { EditorState, type Extension } from '@codemirror/state';
9
+ import {
10
+ EditorView,
11
+ crosshairCursor,
12
+ drawSelection,
13
+ dropCursor,
14
+ highlightActiveLine,
15
+ lineNumbers,
16
+ placeholder,
17
+ scrollPastEnd,
18
+ } from '@codemirror/view';
19
+ import defaultsDeep from 'lodash.defaultsdeep';
20
+
21
+ import { generateName } from '@dxos/display-name';
22
+ import { type DocAccessor } from '@dxos/echo-schema';
23
+ import { log } from '@dxos/log';
24
+ import { type Space } from '@dxos/react-client/echo';
25
+ import { type Identity } from '@dxos/react-client/halo';
26
+ import { type ThemeMode } from '@dxos/react-ui';
27
+ import { type HuePalette, hueTokens } from '@dxos/react-ui-theme';
28
+ import { hexToHue, isNotFalsy } from '@dxos/util';
29
+
30
+ import { automerge } from './automerge';
31
+ import { awareness, SpaceAwarenessProvider } from './awareness';
32
+ import { type ThemeStyles } from '../styles';
33
+ import { defaultTheme } from '../themes';
34
+
35
+ // TODO(burdon): Move into extensions folder.
36
+
37
+ //
38
+ // Basic
39
+ //
40
+
41
+ /**
42
+ * https://codemirror.net/docs/extensions
43
+ * https://github.com/codemirror/basic-setup
44
+ */
45
+ // TODO(burdon): Reconcile with createMarkdownExtensions.
46
+ export type BasicExtensionsOptions = {
47
+ allowMultipleSelections?: boolean;
48
+ bracketMatching?: boolean;
49
+ closeBrackets?: boolean;
50
+ crosshairCursor?: boolean;
51
+ dropCursor?: boolean;
52
+ drawSelection?: boolean;
53
+ editable?: boolean;
54
+ highlightActiveLine?: boolean;
55
+ history?: boolean;
56
+ lineNumbers?: boolean;
57
+ lineWrapping?: boolean;
58
+ placeholder?: string;
59
+ readonly?: boolean;
60
+ scrollPastEnd?: boolean;
61
+ tabSize?: number;
62
+ };
63
+
64
+ const defaults: BasicExtensionsOptions = {
65
+ bracketMatching: true,
66
+ closeBrackets: true,
67
+ drawSelection: true,
68
+ editable: true,
69
+ history: true,
70
+ lineWrapping: true,
71
+ };
72
+
73
+ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extension => {
74
+ const props: BasicExtensionsOptions = defaultsDeep({}, _props, defaults);
75
+ return [
76
+ // TODO(burdon): Doesn't catch errors in keymap functions.
77
+ EditorView.exceptionSink.of((err) => {
78
+ log.catch(err);
79
+ }),
80
+
81
+ props.allowMultipleSelections && EditorState.allowMultipleSelections.of(true),
82
+ props.bracketMatching && bracketMatching(),
83
+ props.closeBrackets && closeBrackets(),
84
+ props.crosshairCursor && crosshairCursor(),
85
+ props.dropCursor && dropCursor(),
86
+ props.drawSelection && drawSelection(),
87
+ props.highlightActiveLine && highlightActiveLine(),
88
+ props.history && history(),
89
+ props.lineNumbers && lineNumbers(),
90
+ props.lineWrapping && EditorView.lineWrapping,
91
+ props.placeholder && placeholder(props.placeholder),
92
+ props.readonly && [EditorState.readOnly.of(true), EditorView.editable.of(false)],
93
+ props.scrollPastEnd && scrollPastEnd(),
94
+ props.tabSize && EditorState.tabSize.of(props.tabSize),
95
+ ].filter(isNotFalsy);
96
+ };
97
+
98
+ //
99
+ // Theme
100
+ //
101
+
102
+ export type ThemeExtensionsOptions = {
103
+ theme?: ThemeStyles;
104
+ themeMode?: ThemeMode;
105
+ slots?: {
106
+ editor?: {
107
+ className?: string;
108
+ };
109
+ content?: {
110
+ className?: string;
111
+ };
112
+ };
113
+ };
114
+
115
+ const defaultSlots = {
116
+ editor: {
117
+ className: 'w-full bs-full',
118
+ },
119
+ };
120
+
121
+ export const createThemeExtensions = ({ theme, themeMode, slots: _slots }: ThemeExtensionsOptions = {}): Extension => {
122
+ const slots = defaultsDeep({}, _slots, defaultSlots);
123
+ return [
124
+ EditorView.baseTheme(defaultTheme),
125
+ EditorView.darkTheme.of(themeMode === 'dark'),
126
+ theme && EditorView.theme(theme),
127
+ slots.editor?.className && EditorView.editorAttributes.of({ class: slots.editor.className }),
128
+ slots.content?.className && EditorView.contentAttributes.of({ class: slots.content.className }),
129
+ ].filter(isNotFalsy);
130
+ };
131
+
132
+ //
133
+ // Data
134
+ //
135
+
136
+ export type DataExtensionsProps = {
137
+ id: string;
138
+ text: DocAccessor; // TODO(burdon): Rename content.
139
+ space?: Space;
140
+ identity?: Identity | null;
141
+ };
142
+
143
+ // TODO(burdon): Factor out automerge defs and extension (not hook).
144
+ // TODO(burdon): Move out of react-ui-editor (remove echo deps).
145
+ export const createDataExtensions = ({ id, text, space, identity }: DataExtensionsProps): Extension[] => {
146
+ const extensions: Extension[] = [automerge(text)];
147
+
148
+ if (space && identity) {
149
+ const peerId = identity?.identityKey.toHex();
150
+ const { cursorLightValue, cursorDarkValue } =
151
+ hueTokens[(identity?.profile?.data?.hue as HuePalette | undefined) ?? hexToHue(peerId ?? '0')];
152
+ const awarenessProvider = new SpaceAwarenessProvider({
153
+ space,
154
+ channel: `awareness.${id}`,
155
+ peerId: identity.identityKey.toHex(),
156
+ info: {
157
+ displayName: identity.profile?.displayName ?? generateName(identity.identityKey.toHex()),
158
+ color: cursorDarkValue,
159
+ lightColor: cursorLightValue,
160
+ },
161
+ });
162
+
163
+ extensions.push(awareness(awarenessProvider));
164
+ }
165
+
166
+ return extensions;
167
+ };
@@ -11,10 +11,13 @@ export * from './command';
11
11
  export * from './comments';
12
12
  export * from './cursor';
13
13
  export * from './cursor-line-margin';
14
+ export * from './doc';
14
15
  export * from './dnd';
16
+ export * from './factories';
15
17
  export * from './listener';
16
18
  export * from './markdown';
17
19
  export * from './mention';
18
20
  export * from './modes';
19
- export * from './outliner';
21
+ export * from './state';
22
+ export * from './types';
20
23
  export * from './typewriter';
@@ -2,51 +2,34 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
6
- import { history, historyKeymap, indentWithTab, standardKeymap } from '@codemirror/commands';
5
+ import { closeBracketsKeymap } from '@codemirror/autocomplete';
6
+ import { historyKeymap, indentWithTab, standardKeymap } from '@codemirror/commands';
7
7
  import { markdownLanguage, markdown } from '@codemirror/lang-markdown';
8
- import { bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language';
8
+ import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
9
9
  import { languages } from '@codemirror/language-data';
10
10
  import { searchKeymap } from '@codemirror/search';
11
- import { EditorState, type Extension } from '@codemirror/state';
11
+ import { type Extension } from '@codemirror/state';
12
12
  import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
13
- import { EditorView, drawSelection, dropCursor, keymap, placeholder } from '@codemirror/view';
13
+ import { keymap } from '@codemirror/view';
14
14
 
15
15
  import { type ThemeMode } from '@dxos/react-ui';
16
- import { isNotFalsy } from '@dxos/util';
17
16
 
18
17
  import { markdownHighlightStyle, markdownTagsExtensions } from './highlight';
19
- import { type TextEditorProps } from '../../components';
20
18
 
21
19
  export type MarkdownBundleOptions = {
22
20
  themeMode?: ThemeMode;
23
- } & Pick<TextEditorProps, 'lineWrapping' | 'placeholder'>;
21
+ };
24
22
 
25
23
  /**
26
- * Markdown bundle.
24
+ * Creates markdown extensions.
25
+ * To be used in conjunction with createBasicExtensions.
26
+ *
27
27
  * Refs:
28
28
  * https://codemirror.net/docs/community
29
29
  * https://codemirror.net/docs/ref/#codemirror.basicSetup
30
30
  */
31
- export const createMarkdownExtensions = ({
32
- themeMode,
33
- placeholder: _placeholder,
34
- lineWrapping = true,
35
- }: MarkdownBundleOptions = {}): Extension[] => {
31
+ export const createMarkdownExtensions = ({ themeMode }: MarkdownBundleOptions = {}): Extension[] => {
36
32
  return [
37
- // TODO(burdon): Reconcile with createBasicExtensions.
38
- lineWrapping && EditorView.lineWrapping,
39
- EditorState.allowMultipleSelections.of(true),
40
- EditorState.tabSize.of(2),
41
-
42
- bracketMatching(),
43
- closeBrackets(),
44
- dropCursor(),
45
- drawSelection(),
46
- history(),
47
- indentOnInput(),
48
- _placeholder && placeholder(_placeholder),
49
-
50
33
  // Main extension.
51
34
  // https://github.com/codemirror/lang-markdown
52
35
  // https://codemirror.net/5/mode/markdown/index.html (demo).
@@ -86,5 +69,5 @@ export const createMarkdownExtensions = ({
86
69
  // https://codemirror.net/docs/ref/#commands.standardKeymap
87
70
  ...standardKeymap,
88
71
  ]),
89
- ].filter(isNotFalsy);
72
+ ];
90
73
  };
@@ -48,7 +48,7 @@ class CheckboxWidget extends WidgetType {
48
48
 
49
49
  override toDOM(view: EditorView) {
50
50
  const input = document.createElement('input');
51
- input.className = 'cm-task-checkbox';
51
+ input.className = 'cm-task-checkbox ch-checkbox ch-focus-ring -mbs-0.5';
52
52
  input.type = 'checkbox';
53
53
  input.checked = this._checked;
54
54
  if (view.state.readOnly) {
@@ -148,9 +148,7 @@ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boo
148
148
  marks[1].from,
149
149
  node.to,
150
150
  options.renderLinkButton
151
- ? Decoration.replace({
152
- widget: new LinkButton(url, options.renderLinkButton),
153
- })
151
+ ? Decoration.replace({ widget: new LinkButton(url, options.renderLinkButton) })
154
152
  : hide,
155
153
  );
156
154
  }
@@ -9,20 +9,20 @@ import { expect } from 'chai';
9
9
  import { describe, test } from '@dxos/test';
10
10
 
11
11
  import {
12
- setHeading,
12
+ addBlockquote,
13
+ addCodeblock,
14
+ addLink,
15
+ addList,
13
16
  addStyle,
17
+ getFormatting,
14
18
  removeStyle,
15
- addLink,
16
19
  removeLink,
17
- addList,
18
20
  removeList,
19
- addBlockquote,
20
21
  removeBlockquote,
21
- addCodeblock,
22
22
  removeCodeblock,
23
+ setHeading,
23
24
  Inline,
24
25
  List,
25
- getFormatting,
26
26
  type Formatting,
27
27
  } from './formatting';
28
28
 
@@ -204,22 +204,22 @@ describe('removeStyle', () => {
204
204
  });
205
205
 
206
206
  describe('addLink', () => {
207
- testCommand('adds a link', '{}', addLink, '[]({})');
207
+ testCommand('adds a link', '{}', addLink(), '[]({})');
208
208
 
209
- testCommand('adds a link around text', 'hello {world}', addLink, 'hello [world]({})');
209
+ testCommand('adds a link around text', 'hello {world}', addLink(), 'hello [world]({})');
210
210
 
211
- testCommand('clears existing links', '[hello {world}](foo)', addLink, 'hello [world]({})');
211
+ testCommand('clears existing links', '[hello {world}](foo)', addLink(), 'hello [world]({})');
212
212
 
213
- testCommand('does nothing across blocks', '{one\n\ntwo}', addLink, null);
213
+ testCommand('does nothing across blocks', '{one\n\ntwo}', addLink(), null);
214
214
 
215
- testCommand('does nothing in code blocks', '```\n{one}\n```', addLink, null);
215
+ testCommand('does nothing in code blocks', '```\n{one}\n```', addLink(), null);
216
216
 
217
- testCommand('patches up overlapping styles before', '*foo {bar* baz}', addLink, '*foo* [*bar* baz]({})');
217
+ testCommand('patches up overlapping styles before', '*foo {bar* baz}', addLink(), '*foo* [*bar* baz]({})');
218
218
 
219
219
  testCommand(
220
220
  'patches up overlapping styles after',
221
221
  'one {two ~~three} four~~',
222
- addLink,
222
+ addLink(),
223
223
  'one [two ~~three~~]({}) ~~four~~',
224
224
  );
225
225
  });
@@ -15,7 +15,9 @@ import {
15
15
  } from '@codemirror/state';
16
16
  import { EditorView, keymap } from '@codemirror/view';
17
17
  import { type SyntaxNodeRef, type SyntaxNode } from '@lezer/common';
18
- import { useState, useMemo } from 'react';
18
+ import { useMemo } from 'react';
19
+
20
+ import { useStateRef } from '@dxos/react-async';
19
21
 
20
22
  // Markdown refs:
21
23
  // https://github.github.com/gfm
@@ -56,7 +58,7 @@ export type Formatting = {
56
58
  listStyle: null | 'ordered' | 'bullet' | 'task';
57
59
  };
58
60
 
59
- export const compareFormatting = (a: Formatting, b: Formatting) =>
61
+ export const formattingEquals = (a: Formatting, b: Formatting) =>
60
62
  a.blockType === b.blockType &&
61
63
  a.strong === b.strong &&
62
64
  a.emphasis === b.emphasis &&
@@ -161,7 +163,7 @@ export const setStyle =
161
163
  ({ state, dispatch }) => {
162
164
  const marker = inlineMarkerText(type);
163
165
  const changes = state.changeByRange((range) => {
164
- // Special case for markers directly around the cursor, which will often not be parsed as valid styling
166
+ // Special case for markers directly around the cursor, which will often not be parsed as valid styling.
165
167
  if (!enable && range.empty) {
166
168
  const after = state.doc.sliceString(range.head, range.head + 6);
167
169
  const found = after.indexOf(marker);
@@ -190,16 +192,16 @@ export const setStyle =
190
192
  let startCovered: boolean | { from: number; to: number } = false;
191
193
  let endCovered: boolean | { from: number; to: number } = false;
192
194
  let { from, to } = range;
193
- // Iterate the selected range. For each textblock, determine a
194
- // start and end position, the overlap of the selected range and
195
- // the block's extent, that should be styled/unstyled.
195
+
196
+ // Iterate the selected range. For each textblock, determine a start and end position,
197
+ // the overlap of the selected range and the block's extent, that should be styled/unstyled.
196
198
  syntaxTree(state).iterate({
197
199
  from,
198
200
  to,
199
201
  enter: (node) => {
200
202
  const { name } = node;
201
203
  if (Object.hasOwn(Textblocks, name) && Textblocks[name] !== 'codeblock') {
202
- // Set up for this textblock
204
+ // Set up for this textblock.
203
205
  blockStart = blockContentStart(node);
204
206
  blockEnd = blockContentEnd(node, state.doc);
205
207
  startCovered = endCovered = false;
@@ -243,8 +245,7 @@ export const setStyle =
243
245
  : true;
244
246
  }
245
247
  }
246
- // Marks of the same type in range, or any mark if we're
247
- // adding code style, need to be removed.
248
+ // Marks of the same type in range, or any mark if we're adding code style, need to be removed.
248
249
  if (markType === type || (type === Inline.Code && enable)) {
249
250
  if (node.from >= from && openEnd <= to) {
250
251
  changes.push({ from: node.from, to: openEnd });
@@ -271,7 +272,7 @@ export const setStyle =
271
272
  },
272
273
  leave: (node) => {
273
274
  if (Object.hasOwn(Textblocks, node.name) && Textblocks[node.name] !== 'codeblock') {
274
- // Finish opening/closing the marks for this textblock
275
+ // Finish opening/closing the marks for this textblock.
275
276
  const rangeStart = Math.max(from, blockStart);
276
277
  const rangeEnd = Math.min(to, blockEnd);
277
278
  if (enable) {
@@ -296,12 +297,14 @@ export const setStyle =
296
297
  }
297
298
  },
298
299
  });
300
+
299
301
  if (blockStart < 0 && range.empty && enable && !/\S/.test(state.doc.lineAt(range.from).text)) {
300
302
  return {
301
303
  changes: { from: range.head, insert: marker + marker },
302
304
  range: EditorSelection.cursor(range.head + marker.length),
303
305
  };
304
306
  }
307
+
305
308
  const changeSet = state.changes(changes.concat(changesAtEnd));
306
309
  return {
307
310
  changes: changeSet,
@@ -313,8 +316,12 @@ export const setStyle =
313
316
  });
314
317
 
315
318
  dispatch(
316
- state.update(changes, { userEvent: enable ? 'format.style.add' : 'format.style.remove', scrollIntoView: true }),
319
+ state.update(changes, {
320
+ userEvent: enable ? 'format.style.add' : 'format.style.remove',
321
+ scrollIntoView: true,
322
+ }),
317
323
  );
324
+
318
325
  return true;
319
326
  };
320
327
 
@@ -465,90 +472,94 @@ export const removeLink: StateCommand = ({ state, dispatch }) => {
465
472
  };
466
473
 
467
474
  // Add link markup around the selection
468
- export const addLink: StateCommand = ({ state, dispatch }) => {
469
- const changes = state.changeByRange((range) => {
470
- let { from, to } = range;
471
- const cutStyles: SyntaxNode[] = [];
472
- let okay: boolean | null = null;
473
- // Check whether this range is in a position where a link makes sense
474
- syntaxTree(state).iterate({
475
- from,
476
- to,
477
- enter: (node) => {
478
- if (Object.hasOwn(Textblocks, node.name)) {
479
- // If the selection spans multiple textblocks or is in a
480
- // code block, abort
481
- okay =
482
- Textblocks[node.name] !== 'codeblock' &&
483
- from >= blockContentStart(node) &&
484
- to <= blockContentEnd(node, state.doc);
485
- } else if (Object.hasOwn(InlineMarker, node.name)) {
486
- // Look for inline styles that partially overlap the range.
487
- // Expand the range over them if they start directly
488
- // outside, otherwise mark them for later
489
- const sNode = node.node;
490
- if (node.from < from && node.to <= to) {
491
- if (sNode.firstChild!.to === from) {
492
- from = node.from;
493
- } else {
494
- cutStyles.push(sNode);
495
- }
496
- } else if (node.from >= from && node.to > to) {
497
- if (sNode.lastChild!.from === to) {
498
- to = node.to;
499
- } else {
500
- cutStyles.push(sNode);
475
+ export const addLink =
476
+ ({ url, image }: { url?: string; image?: boolean } = {}): StateCommand =>
477
+ ({ state, dispatch }) => {
478
+ const changes = state.changeByRange((range) => {
479
+ let { from, to } = range;
480
+ const cutStyles: SyntaxNode[] = [];
481
+ let okay: boolean | null = null;
482
+ // Check whether this range is in a position where a link makes sense.
483
+ syntaxTree(state).iterate({
484
+ from,
485
+ to,
486
+ enter: (node) => {
487
+ if (Object.hasOwn(Textblocks, node.name)) {
488
+ // If the selection spans multiple textblocks or is in a code block, abort.
489
+ okay =
490
+ Textblocks[node.name] !== 'codeblock' &&
491
+ from >= blockContentStart(node) &&
492
+ to <= blockContentEnd(node, state.doc);
493
+ } else if (Object.hasOwn(InlineMarker, node.name)) {
494
+ // Look for inline styles that partially overlap the range.
495
+ // Expand the range over them if they start directly outside, otherwise mark them for later.
496
+ const sNode = node.node;
497
+ if (node.from < from && node.to <= to) {
498
+ if (sNode.firstChild!.to === from) {
499
+ from = node.from;
500
+ } else {
501
+ cutStyles.push(sNode);
502
+ }
503
+ } else if (node.from >= from && node.to > to) {
504
+ if (sNode.lastChild!.from === to) {
505
+ to = node.to;
506
+ } else {
507
+ cutStyles.push(sNode);
508
+ }
501
509
  }
502
510
  }
503
- }
504
- },
505
- });
511
+ },
512
+ });
506
513
 
507
- if (okay === null) {
508
- // No textblock found around selection. Check if the rest of the line is empty.
509
- const line = state.doc.lineAt(from);
510
- okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
511
- }
512
- if (!okay) {
513
- return { range };
514
- }
514
+ if (okay === null) {
515
+ // No textblock found around selection. Check if the rest of the line is empty.
516
+ const line = state.doc.lineAt(from);
517
+ okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
518
+ }
519
+ if (!okay) {
520
+ return { range };
521
+ }
515
522
 
516
- const changes: ChangeSpec[] = [];
517
- // Some changes must be moved to end of change array so that they are applied in the right order
518
- const changesAfter: ChangeSpec[] = [];
519
- // Clear existing links.
520
- removeLinkInner(from, to, changesAfter, state);
521
- let cursorOffset = 1;
522
- // Close and reopen inline styles that partially overlap the range.
523
- for (const style of cutStyles) {
524
- const type = InlineMarker[style.name];
525
- const mark = inlineMarkerText(type);
526
- if (style.from < from) {
527
- // Extends before.
528
- changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
529
- changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
530
- } else {
531
- changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
532
- const after = skipSpaces(to, state.doc, 1);
533
- if (after === to) {
534
- cursorOffset += mark.length;
523
+ const changes: ChangeSpec[] = [];
524
+ // Some changes must be moved to end of change array so that they are applied in the right order
525
+ const changesAfter: ChangeSpec[] = [];
526
+ // Clear existing links.
527
+ removeLinkInner(from, to, changesAfter, state);
528
+ let cursorOffset = 1;
529
+ // Close and reopen inline styles that partially overlap the range.
530
+ for (const style of cutStyles) {
531
+ const type = InlineMarker[style.name];
532
+ const mark = inlineMarkerText(type);
533
+ if (style.from < from) {
534
+ // Extends before.
535
+ changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
536
+ changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
537
+ } else {
538
+ changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
539
+ const after = skipSpaces(to, state.doc, 1);
540
+ if (after === to) {
541
+ cursorOffset += mark.length;
542
+ }
543
+ changesAfter.push({ from: after, insert: mark });
535
544
  }
536
- changesAfter.push({ from: after, insert: mark });
537
545
  }
546
+
547
+ // Add the link markup.
548
+ changes.push({ from, insert: image ? '![' : '[' }, { from: to, insert: `](${url ?? ''})` });
549
+ const changeSet = state.changes(changes.concat(changesAfter));
550
+ // Put the cursor between the title or parenthesis.
551
+ return {
552
+ changes: changeSet,
553
+ range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset - (url ? url.length + 2 : 0)),
554
+ };
555
+ });
556
+ if (changes.changes.empty) {
557
+ return false;
538
558
  }
539
- // Add the link markup.
540
- changes.push({ from, insert: '[' }, { from: to, insert: ']()' });
541
- const changeSet = state.changes(changes.concat(changesAfter));
542
- // Put the cursor between the parenthesis.
543
- return { changes: changeSet, range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset) };
544
- });
545
- if (changes.changes.empty) {
546
- return false;
547
- }
548
559
 
549
- dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
550
- return true;
551
- };
560
+ dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
561
+ return true;
562
+ };
552
563
 
553
564
  //
554
565
  // Lists
@@ -987,7 +998,7 @@ export const toggleCodeblock: StateCommand = (target) => {
987
998
 
988
999
  export type FormattingOptions = {};
989
1000
 
990
- export const formatting = (options: FormattingOptions = {}): Extension => {
1001
+ export const formattingKeymap = (options: FormattingOptions = {}): Extension => {
991
1002
  return [
992
1003
  keymap.of([
993
1004
  {
@@ -1052,7 +1063,7 @@ export const getFormatting = (state: EditorState): Formatting => {
1052
1063
  const inline: (boolean | null)[] = [null, null, null, null];
1053
1064
  let link: boolean = false;
1054
1065
  let blockQuote: boolean | null = null;
1055
- // False indicates mixed list styles
1066
+ // False indicates mixed list styles.
1056
1067
  let listStyle: Formatting['listStyle'] | null | false = null;
1057
1068
 
1058
1069
  // Track block context for list/blockquote handling.
@@ -1211,16 +1222,18 @@ export const getFormatting = (state: EditorState): Formatting => {
1211
1222
  };
1212
1223
 
1213
1224
  /**
1214
- * Hook computes the current formatting state.
1225
+ * Hook provides an extension to compute the current formatting state.
1215
1226
  */
1216
- export const useFormattingState = (): [Formatting | null, Extension] => {
1217
- const [state, setState] = useState<Formatting | null>(null);
1227
+ export const useFormattingState = (): [Formatting | undefined, Extension] => {
1228
+ const [state, setState, stateRef] = useStateRef<Formatting>();
1218
1229
  const observer = useMemo(
1219
1230
  () =>
1220
1231
  EditorView.updateListener.of((update) => {
1221
1232
  if (update.docChanged || update.selectionSet) {
1222
1233
  const newState = getFormatting(update.state);
1223
- if (!state || !compareFormatting(state, newState)) {
1234
+ if (!stateRef.current || !formattingEquals(stateRef.current, newState)) {
1235
+ // TODO(burdon): Error on first click on toolbar.
1236
+ // Warning: React has detected a change in the order of Hooks called by ForwardRef. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks
1224
1237
  setState(newState);
1225
1238
  }
1226
1239
  }
@@ -61,6 +61,7 @@ const buildDecorations = (from: number, to: number, state: EditorState) => {
61
61
  if (urlNode) {
62
62
  const hide = state.readOnly || cursor < node.from || cursor > node.to;
63
63
  const url = state.sliceDoc(urlNode.from, urlNode.to);
64
+ // TODO(burdon): Doesn't load if scrolling with mouse.
64
65
  preloadImage(url);
65
66
  decorations.push(
66
67
  Decoration.replace({
@@ -96,3 +97,9 @@ class ImageWidget extends WidgetType {
96
97
  return img;
97
98
  }
98
99
  }
100
+
101
+ export type ImageUploadOptions = {
102
+ onSelect: () => { url: string };
103
+ };
104
+
105
+ export const imageUpload = (options: ImageOptions = {}) => {};