@dxos/react-ui-editor 0.4.8-main.05fda5d → 0.4.8-main.06fa7e4

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 (137) hide show
  1. package/dist/lib/browser/index.mjs +1938 -1920
  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 +12 -11
  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.d.ts +1 -1
  34. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  35. package/dist/types/src/extensions/dnd.d.ts +7 -1
  36. package/dist/types/src/extensions/dnd.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 +53 -0
  40. package/dist/types/src/extensions/factories.d.ts.map +1 -0
  41. package/dist/types/src/extensions/index.d.ts +4 -2
  42. package/dist/types/src/extensions/index.d.ts.map +1 -1
  43. package/dist/types/src/extensions/markdown/action.d.ts +9 -0
  44. package/dist/types/src/extensions/markdown/action.d.ts.map +1 -0
  45. package/dist/types/src/extensions/markdown/bundle.d.ts +5 -4
  46. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/formatting.d.ts +8 -5
  49. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  51. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  52. package/dist/types/src/extensions/markdown/index.d.ts +1 -0
  53. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  54. package/dist/types/src/extensions/markdown/table.d.ts +2 -2
  55. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  56. package/dist/types/src/extensions/modes.d.ts +2 -1
  57. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  58. package/dist/types/src/extensions/state.d.ts +20 -0
  59. package/dist/types/src/extensions/state.d.ts.map +1 -0
  60. package/dist/types/src/extensions/types.d.ts +9 -0
  61. package/dist/types/src/extensions/types.d.ts.map +1 -0
  62. package/dist/types/src/hooks/index.d.ts +1 -4
  63. package/dist/types/src/hooks/index.d.ts.map +1 -1
  64. package/dist/types/src/hooks/useActionHandler.d.ts +2 -2
  65. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  66. package/dist/types/src/hooks/useDocAccessor.d.ts +9 -0
  67. package/dist/types/src/hooks/useDocAccessor.d.ts.map +1 -0
  68. package/dist/types/src/hooks/useTextEditor.d.ts +3 -44
  69. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  70. package/dist/types/src/hooks/useTextEditor.stories.d.ts +7 -0
  71. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  72. package/dist/types/src/themes/default.d.ts +0 -3
  73. package/dist/types/src/themes/default.d.ts.map +1 -1
  74. package/dist/types/src/translations.d.ts +1 -0
  75. package/dist/types/src/translations.d.ts.map +1 -1
  76. package/package.json +26 -27
  77. package/src/components/TextEditor/TextEditor.stories.tsx +532 -23
  78. package/src/components/TextEditor/TextEditor.tsx +45 -156
  79. package/src/components/Toolbar/Toolbar.stories.tsx +54 -55
  80. package/src/components/Toolbar/Toolbar.tsx +102 -60
  81. package/src/extensions/automerge/automerge.stories.tsx +16 -17
  82. package/src/extensions/automerge/automerge.ts +6 -5
  83. package/src/extensions/automerge/cursor.ts +1 -1
  84. package/src/extensions/automerge/defs.ts +1 -22
  85. package/src/extensions/automerge/index.ts +0 -1
  86. package/src/extensions/automerge/sync.ts +2 -9
  87. package/src/extensions/automerge/update-automerge.ts +2 -1
  88. package/src/{hooks → extensions/awareness}/awareness-provider.ts +1 -1
  89. package/src/extensions/{awareness.ts → awareness/awareness.ts} +1 -1
  90. package/src/extensions/awareness/index.ts +6 -0
  91. package/src/extensions/command/state.ts +3 -3
  92. package/src/extensions/comments.ts +9 -5
  93. package/src/extensions/cursor.ts +1 -1
  94. package/src/extensions/dnd.ts +31 -9
  95. package/src/extensions/doc.ts +14 -0
  96. package/src/extensions/factories.ts +193 -0
  97. package/src/extensions/index.ts +4 -2
  98. package/src/extensions/markdown/action.ts +112 -0
  99. package/src/extensions/markdown/bundle.ts +17 -37
  100. package/src/extensions/markdown/decorate.ts +2 -4
  101. package/src/extensions/markdown/formatting.test.ts +13 -13
  102. package/src/extensions/markdown/formatting.ts +110 -97
  103. package/src/extensions/markdown/image.ts +7 -0
  104. package/src/extensions/markdown/index.ts +1 -0
  105. package/src/extensions/markdown/table.ts +11 -3
  106. package/src/extensions/modes.ts +11 -2
  107. package/src/extensions/state.ts +90 -0
  108. package/src/extensions/types.ts +16 -0
  109. package/src/hooks/index.ts +1 -5
  110. package/src/hooks/useActionHandler.ts +4 -85
  111. package/src/hooks/useDocAccessor.ts +27 -0
  112. package/src/hooks/useTextEditor.stories.tsx +40 -35
  113. package/src/hooks/useTextEditor.ts +14 -120
  114. package/src/index.ts +1 -1
  115. package/src/themes/default.ts +25 -36
  116. package/src/translations.ts +1 -0
  117. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +0 -103
  118. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +0 -1
  119. package/dist/types/src/extensions/awareness.d.ts.map +0 -1
  120. package/dist/types/src/extensions/cursor-line-margin.d.ts +0 -8
  121. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +0 -1
  122. package/dist/types/src/extensions/outliner.d.ts +0 -4
  123. package/dist/types/src/extensions/outliner.d.ts.map +0 -1
  124. package/dist/types/src/hooks/awareness-provider.d.ts.map +0 -1
  125. package/dist/types/src/hooks/defs.d.ts +0 -20
  126. package/dist/types/src/hooks/defs.d.ts.map +0 -1
  127. package/dist/types/src/hooks/useEditorView.d.ts +0 -20
  128. package/dist/types/src/hooks/useEditorView.d.ts.map +0 -1
  129. package/dist/types/src/hooks/useTextModel.d.ts +0 -33
  130. package/dist/types/src/hooks/useTextModel.d.ts.map +0 -1
  131. package/src/components/TextEditor/MarkdownEditor.stories.tsx +0 -524
  132. package/src/extensions/cursor-line-margin.ts +0 -95
  133. package/src/extensions/outliner.ts +0 -12
  134. package/src/hooks/defs.ts +0 -30
  135. package/src/hooks/useEditorView.ts +0 -32
  136. package/src/hooks/useTextModel.ts +0 -121
  137. /package/dist/types/src/extensions/{awareness.d.ts → awareness/awareness.d.ts} +0 -0
@@ -15,7 +15,7 @@ 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, useState } from 'react';
19
19
 
20
20
  // Markdown refs:
21
21
  // https://github.github.com/gfm
@@ -56,7 +56,7 @@ export type Formatting = {
56
56
  listStyle: null | 'ordered' | 'bullet' | 'task';
57
57
  };
58
58
 
59
- export const compareFormatting = (a: Formatting, b: Formatting) =>
59
+ export const formattingEquals = (a: Formatting, b: Formatting) =>
60
60
  a.blockType === b.blockType &&
61
61
  a.strong === b.strong &&
62
62
  a.emphasis === b.emphasis &&
@@ -161,7 +161,7 @@ export const setStyle =
161
161
  ({ state, dispatch }) => {
162
162
  const marker = inlineMarkerText(type);
163
163
  const changes = state.changeByRange((range) => {
164
- // Special case for markers directly around the cursor, which will often not be parsed as valid styling
164
+ // Special case for markers directly around the cursor, which will often not be parsed as valid styling.
165
165
  if (!enable && range.empty) {
166
166
  const after = state.doc.sliceString(range.head, range.head + 6);
167
167
  const found = after.indexOf(marker);
@@ -190,16 +190,16 @@ export const setStyle =
190
190
  let startCovered: boolean | { from: number; to: number } = false;
191
191
  let endCovered: boolean | { from: number; to: number } = false;
192
192
  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.
193
+
194
+ // Iterate the selected range. For each textblock, determine a start and end position,
195
+ // the overlap of the selected range and the block's extent, that should be styled/unstyled.
196
196
  syntaxTree(state).iterate({
197
197
  from,
198
198
  to,
199
199
  enter: (node) => {
200
200
  const { name } = node;
201
201
  if (Object.hasOwn(Textblocks, name) && Textblocks[name] !== 'codeblock') {
202
- // Set up for this textblock
202
+ // Set up for this textblock.
203
203
  blockStart = blockContentStart(node);
204
204
  blockEnd = blockContentEnd(node, state.doc);
205
205
  startCovered = endCovered = false;
@@ -243,8 +243,7 @@ export const setStyle =
243
243
  : true;
244
244
  }
245
245
  }
246
- // Marks of the same type in range, or any mark if we're
247
- // adding code style, need to be removed.
246
+ // Marks of the same type in range, or any mark if we're adding code style, need to be removed.
248
247
  if (markType === type || (type === Inline.Code && enable)) {
249
248
  if (node.from >= from && openEnd <= to) {
250
249
  changes.push({ from: node.from, to: openEnd });
@@ -271,7 +270,7 @@ export const setStyle =
271
270
  },
272
271
  leave: (node) => {
273
272
  if (Object.hasOwn(Textblocks, node.name) && Textblocks[node.name] !== 'codeblock') {
274
- // Finish opening/closing the marks for this textblock
273
+ // Finish opening/closing the marks for this textblock.
275
274
  const rangeStart = Math.max(from, blockStart);
276
275
  const rangeEnd = Math.min(to, blockEnd);
277
276
  if (enable) {
@@ -296,12 +295,14 @@ export const setStyle =
296
295
  }
297
296
  },
298
297
  });
298
+
299
299
  if (blockStart < 0 && range.empty && enable && !/\S/.test(state.doc.lineAt(range.from).text)) {
300
300
  return {
301
301
  changes: { from: range.head, insert: marker + marker },
302
302
  range: EditorSelection.cursor(range.head + marker.length),
303
303
  };
304
304
  }
305
+
305
306
  const changeSet = state.changes(changes.concat(changesAtEnd));
306
307
  return {
307
308
  changes: changeSet,
@@ -313,8 +314,12 @@ export const setStyle =
313
314
  });
314
315
 
315
316
  dispatch(
316
- state.update(changes, { userEvent: enable ? 'format.style.add' : 'format.style.remove', scrollIntoView: true }),
317
+ state.update(changes, {
318
+ userEvent: enable ? 'format.style.add' : 'format.style.remove',
319
+ scrollIntoView: true,
320
+ }),
317
321
  );
322
+
318
323
  return true;
319
324
  };
320
325
 
@@ -465,90 +470,94 @@ export const removeLink: StateCommand = ({ state, dispatch }) => {
465
470
  };
466
471
 
467
472
  // 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);
473
+ export const addLink =
474
+ ({ url, image }: { url?: string; image?: boolean } = {}): StateCommand =>
475
+ ({ state, dispatch }) => {
476
+ const changes = state.changeByRange((range) => {
477
+ let { from, to } = range;
478
+ const cutStyles: SyntaxNode[] = [];
479
+ let okay: boolean | null = null;
480
+ // Check whether this range is in a position where a link makes sense.
481
+ syntaxTree(state).iterate({
482
+ from,
483
+ to,
484
+ enter: (node) => {
485
+ if (Object.hasOwn(Textblocks, node.name)) {
486
+ // If the selection spans multiple textblocks or is in a code block, abort.
487
+ okay =
488
+ Textblocks[node.name] !== 'codeblock' &&
489
+ from >= blockContentStart(node) &&
490
+ to <= blockContentEnd(node, state.doc);
491
+ } else if (Object.hasOwn(InlineMarker, node.name)) {
492
+ // Look for inline styles that partially overlap the range.
493
+ // Expand the range over them if they start directly outside, otherwise mark them for later.
494
+ const sNode = node.node;
495
+ if (node.from < from && node.to <= to) {
496
+ if (sNode.firstChild!.to === from) {
497
+ from = node.from;
498
+ } else {
499
+ cutStyles.push(sNode);
500
+ }
501
+ } else if (node.from >= from && node.to > to) {
502
+ if (sNode.lastChild!.from === to) {
503
+ to = node.to;
504
+ } else {
505
+ cutStyles.push(sNode);
506
+ }
501
507
  }
502
508
  }
503
- }
504
- },
505
- });
509
+ },
510
+ });
506
511
 
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
- }
512
+ if (okay === null) {
513
+ // No textblock found around selection. Check if the rest of the line is empty.
514
+ const line = state.doc.lineAt(from);
515
+ okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
516
+ }
517
+ if (!okay) {
518
+ return { range };
519
+ }
515
520
 
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;
521
+ const changes: ChangeSpec[] = [];
522
+ // Some changes must be moved to end of change array so that they are applied in the right order
523
+ const changesAfter: ChangeSpec[] = [];
524
+ // Clear existing links.
525
+ removeLinkInner(from, to, changesAfter, state);
526
+ let cursorOffset = 1;
527
+ // Close and reopen inline styles that partially overlap the range.
528
+ for (const style of cutStyles) {
529
+ const type = InlineMarker[style.name];
530
+ const mark = inlineMarkerText(type);
531
+ if (style.from < from) {
532
+ // Extends before.
533
+ changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
534
+ changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
535
+ } else {
536
+ changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
537
+ const after = skipSpaces(to, state.doc, 1);
538
+ if (after === to) {
539
+ cursorOffset += mark.length;
540
+ }
541
+ changesAfter.push({ from: after, insert: mark });
535
542
  }
536
- changesAfter.push({ from: after, insert: mark });
537
543
  }
544
+
545
+ // Add the link markup.
546
+ changes.push({ from, insert: image ? '![' : '[' }, { from: to, insert: `](${url ?? ''})` });
547
+ const changeSet = state.changes(changes.concat(changesAfter));
548
+ // Put the cursor between the title or parenthesis.
549
+ return {
550
+ changes: changeSet,
551
+ range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset - (url ? url.length + 2 : 0)),
552
+ };
553
+ });
554
+ if (changes.changes.empty) {
555
+ return false;
538
556
  }
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
557
 
549
- dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
550
- return true;
551
- };
558
+ dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
559
+ return true;
560
+ };
552
561
 
553
562
  //
554
563
  // Lists
@@ -987,7 +996,7 @@ export const toggleCodeblock: StateCommand = (target) => {
987
996
 
988
997
  export type FormattingOptions = {};
989
998
 
990
- export const formatting = (options: FormattingOptions = {}): Extension => {
999
+ export const formattingKeymap = (options: FormattingOptions = {}): Extension => {
991
1000
  return [
992
1001
  keymap.of([
993
1002
  {
@@ -1052,7 +1061,7 @@ export const getFormatting = (state: EditorState): Formatting => {
1052
1061
  const inline: (boolean | null)[] = [null, null, null, null];
1053
1062
  let link: boolean = false;
1054
1063
  let blockQuote: boolean | null = null;
1055
- // False indicates mixed list styles
1064
+ // False indicates mixed list styles.
1056
1065
  let listStyle: Formatting['listStyle'] | null | false = null;
1057
1066
 
1058
1067
  // Track block context for list/blockquote handling.
@@ -1211,21 +1220,25 @@ export const getFormatting = (state: EditorState): Formatting => {
1211
1220
  };
1212
1221
 
1213
1222
  /**
1214
- * Hook computes the current formatting state.
1223
+ * Hook provides an extension to compute the current formatting state.
1215
1224
  */
1216
- export const useFormattingState = (): [Formatting | null, Extension] => {
1217
- const [state, setState] = useState<Formatting | null>(null);
1225
+ export const useFormattingState = (): [Formatting | undefined, Extension] => {
1226
+ const [state, setState] = useState<Formatting>();
1227
+
1218
1228
  const observer = useMemo(
1219
1229
  () =>
1220
1230
  EditorView.updateListener.of((update) => {
1221
1231
  if (update.docChanged || update.selectionSet) {
1222
- const newState = getFormatting(update.state);
1223
- if (!state || !compareFormatting(state, newState)) {
1224
- setState(newState);
1225
- }
1232
+ setState((prevState) => {
1233
+ const newState = getFormatting(update.state);
1234
+ if (!prevState || !formattingEquals(prevState, newState)) {
1235
+ return newState;
1236
+ }
1237
+ return prevState;
1238
+ });
1226
1239
  }
1227
1240
  }),
1228
- [],
1241
+ [setState],
1229
1242
  );
1230
1243
 
1231
1244
  return [state, observer];
@@ -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 = {}) => {};
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ export * from './action';
5
6
  export * from './bundle';
6
7
  export * from './decorate';
7
8
  export * from './formatting';
@@ -3,7 +3,14 @@
3
3
  //
4
4
 
5
5
  import { syntaxTree } from '@codemirror/language';
6
- import { type EditorState, type RangeSet, RangeSetBuilder, StateField, type Transaction } from '@codemirror/state';
6
+ import {
7
+ type EditorState,
8
+ type Extension,
9
+ type RangeSet,
10
+ RangeSetBuilder,
11
+ StateField,
12
+ type Transaction,
13
+ } from '@codemirror/state';
7
14
  import { Decoration, EditorView, WidgetType } from '@codemirror/view';
8
15
 
9
16
  // TODO(burdon): Snippet to create basic table.
@@ -18,7 +25,7 @@ export type TableOptions = {};
18
25
  * GFM tables.
19
26
  * https://github.github.com/gfm/#tables-extension
20
27
  */
21
- export const table = (options: TableOptions = {}) => {
28
+ export const table = (options: TableOptions = {}): Extension => {
22
29
  return StateField.define<RangeSet<any>>({
23
30
  create: (state) => update(state, options),
24
31
  update: (_: RangeSet<any>, tr: Transaction) => update(tr.state, options),
@@ -76,7 +83,7 @@ const update = (state: EditorState, options: TableOptions) => {
76
83
 
77
84
  tables.forEach((table) => {
78
85
  const hide = state.readOnly || cursor < table.from || cursor > table.to;
79
- hide &&
86
+ if (hide) {
80
87
  builder.add(
81
88
  table.from,
82
89
  table.to,
@@ -84,6 +91,7 @@ const update = (state: EditorState, options: TableOptions) => {
84
91
  widget: new TableWidget(table),
85
92
  }),
86
93
  );
94
+ }
87
95
 
88
96
  // Add class for styling.
89
97
  builder.add(table.from, table.to, Decoration.mark({ class: 'cm-table' }));
@@ -5,8 +5,11 @@
5
5
  import { type Extension, Facet } from '@codemirror/state';
6
6
  import { keymap } from '@codemirror/view';
7
7
  import { vim } from '@replit/codemirror-vim';
8
+ import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
8
9
 
9
- export type EditorMode = 'default' | 'vim' | undefined;
10
+ export const focusEvent = 'focus.container';
11
+
12
+ export type EditorMode = 'default' | 'vim' | 'vscode' | undefined;
10
13
 
11
14
  export type EditorConfig = {
12
15
  type: string;
@@ -19,7 +22,13 @@ export const editorMode = Facet.define<EditorConfig, EditorConfig>({
19
22
 
20
23
  export const EditorModes: { [mode: string]: Extension } = {
21
24
  default: [],
25
+ vscode: [
26
+ // https://github.com/replit/codemirror-vscode-keymap
27
+ editorMode.of({ type: 'vscode' }),
28
+ keymap.of(vscodeKeymap),
29
+ ],
22
30
  vim: [
31
+ // https://github.com/replit/codemirror-vim
23
32
  vim(),
24
33
  editorMode.of({ type: 'vim', noTabster: true }),
25
34
  keymap.of([
@@ -27,7 +36,7 @@ export const EditorModes: { [mode: string]: Extension } = {
27
36
  key: 'Alt-Escape',
28
37
  run: (view) => {
29
38
  // Focus container for tab navigation.
30
- view.dispatch({ userEvent: 'focus.container' });
39
+ view.dispatch({ userEvent: focusEvent });
31
40
  return true;
32
41
  },
33
42
  },
@@ -0,0 +1,90 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Transaction } from '@codemirror/state';
6
+ import { EditorView, keymap } from '@codemirror/view';
7
+
8
+ import { debounce } from '@dxos/async';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { isNotFalsy } from '@dxos/util';
11
+
12
+ import { documentId } from './doc';
13
+
14
+ const scrollAnnotation = 'dxos.org/cm/scrolling';
15
+
16
+ // NOTE: Serializable.
17
+ export type SelectionState = {
18
+ scrollTo: {
19
+ from: number;
20
+ };
21
+ selection: {
22
+ anchor: number;
23
+ head?: number;
24
+ };
25
+ };
26
+
27
+ export type StateOptions = {
28
+ setState: (id: string, state: SelectionState) => void;
29
+ getState: (id: string) => SelectionState | undefined;
30
+ };
31
+
32
+ const keyPrefix = 'dxos.org/react-ui-editor/state';
33
+ export const localStorageStateStoreAdapter: StateOptions = {
34
+ setState: (id, state) => {
35
+ invariant(id);
36
+ localStorage.setItem(`${keyPrefix}/${id}`, JSON.stringify(state));
37
+ },
38
+ getState: (id) => {
39
+ invariant(id);
40
+ const state = localStorage.getItem(`${keyPrefix}/${id}`);
41
+ return state ? JSON.parse(state) : undefined;
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Track scrolling and selection state to be restored when switching to document.
47
+ */
48
+ export const state = ({ getState, setState }: Partial<StateOptions> = {}): Extension => {
49
+ const setStateDebounced = debounce(setState!, 1_000);
50
+
51
+ return [
52
+ // TODO(burdon): Track scrolling (currently only updates when cursor moves).
53
+ EditorView.updateListener.of(({ view, changes, transactions }) => {
54
+ // TODO(burdon): Don't react to initial scroll.
55
+ const id = view.state.facet(documentId);
56
+ if (!id || transactions.some((tr) => tr.isUserEvent(scrollAnnotation))) {
57
+ return;
58
+ }
59
+
60
+ if (setState) {
61
+ const { top } = view.dom.getBoundingClientRect();
62
+ const pos = view.posAtCoords({ x: 0, y: top });
63
+ if (pos !== null) {
64
+ const { anchor, head } = view.state.selection.main;
65
+ setStateDebounced(id, {
66
+ scrollTo: { from: pos, yMargin: 0 },
67
+ selection: { anchor, head },
68
+ });
69
+ }
70
+ }
71
+ }),
72
+ getState &&
73
+ keymap.of([
74
+ {
75
+ key: 'ctrl-r', // TODO(burdon): Setting to jump back to bookmark.
76
+ run: (view) => {
77
+ const state = getState(view.state.facet(documentId));
78
+ if (state) {
79
+ view.dispatch({
80
+ effects: EditorView.scrollIntoView(state.scrollTo.from, { yMargin: 0 }),
81
+ selection: state.selection,
82
+ annotations: Transaction.userEvent.of(scrollAnnotation),
83
+ });
84
+ }
85
+ return true;
86
+ },
87
+ },
88
+ ]),
89
+ ].filter(isNotFalsy);
90
+ };
@@ -0,0 +1,16 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // Runtime data structure.
6
+ export type Range = {
7
+ from: number;
8
+ to: number;
9
+ };
10
+
11
+ // Persistent data structure.
12
+ // TODO(burdon): Rename annotation?
13
+ export type Comment = {
14
+ id: string;
15
+ cursor?: string;
16
+ };
@@ -2,10 +2,6 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export * from './awareness-provider';
6
- export * from './defs';
7
-
8
5
  export * from './useActionHandler';
9
- export * from './useEditorView';
6
+ export * from './useDocAccessor';
10
7
  export * from './useTextEditor';
11
- export * from './useTextModel';
@@ -2,92 +2,11 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import type { EditorView } from '@codemirror/view';
5
+ import { type EditorView } from '@codemirror/view';
6
6
 
7
- import type { ToolbarProps } from '../components';
8
- import {
9
- Inline,
10
- List,
11
- addBlockquote,
12
- addCodeblock,
13
- addLink,
14
- addList,
15
- createComment,
16
- insertTable,
17
- removeBlockquote,
18
- removeCodeblock,
19
- removeLink,
20
- removeList,
21
- setHeading,
22
- setStyle,
23
- toggleBlockquote,
24
- toggleList,
25
- toggleStyle,
26
- } from '../extensions';
7
+ import { type ToolbarProps } from '../components';
8
+ import { processAction } from '../extensions';
27
9
 
28
10
  export const useActionHandler = (view?: EditorView | null): ToolbarProps['onAction'] => {
29
- return (action) => {
30
- if (!view) {
31
- return;
32
- }
33
-
34
- let inlineType, listType;
35
- switch (action.type) {
36
- case 'heading':
37
- setHeading(parseInt(action.data))(view);
38
- break;
39
-
40
- case 'strong':
41
- case 'emphasis':
42
- case 'strikethrough':
43
- case 'code':
44
- inlineType =
45
- action.type === 'strong'
46
- ? Inline.Strong
47
- : action.type === 'emphasis'
48
- ? Inline.Emphasis
49
- : action.type === 'strikethrough'
50
- ? Inline.Strikethrough
51
- : Inline.Code;
52
- (typeof action.data === 'boolean' ? setStyle(inlineType, action.data) : toggleStyle(inlineType))(view);
53
- break;
54
-
55
- case 'list-ordered':
56
- case 'list-bullet':
57
- case 'list-task':
58
- listType =
59
- action.type === 'list-ordered' ? List.Ordered : action.type === 'list-bullet' ? List.Bullet : List.Task;
60
- (action.data === false
61
- ? removeList(listType)
62
- : action.data === true
63
- ? addList(listType)
64
- : toggleList(listType))(view);
65
- break;
66
-
67
- case 'blockquote':
68
- (action.data === false ? removeBlockquote : action.data === true ? addBlockquote : toggleBlockquote)(view);
69
- break;
70
- case 'codeblock':
71
- (action.data === false ? removeCodeblock : addCodeblock)(view);
72
- break;
73
- case 'table':
74
- insertTable(view);
75
- break;
76
-
77
- case 'link':
78
- (action.data === false ? removeLink : addLink)(view);
79
- break;
80
-
81
- case 'comment':
82
- createComment(view);
83
- break;
84
- }
85
-
86
- // TODO(burdon): Hack otherwise remains on heading selector.
87
- setTimeout(() => {
88
- if (!view.hasFocus) {
89
- view.focus();
90
- }
91
- });
92
- };
11
+ return (action) => view && processAction(view, action);
93
12
  };
@@ -0,0 +1,27 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { useMemo } from 'react';
6
+
7
+ import {
8
+ createDocAccessor,
9
+ type DocAccessor,
10
+ getTextContent,
11
+ type TextObject,
12
+ type EchoReactiveObject,
13
+ } from '@dxos/echo-schema';
14
+
15
+ // TODO(burdon): Factor out.
16
+ export const useDocAccessor = <T = any>(
17
+ text: TextObject | EchoReactiveObject<{ content: string }>,
18
+ ): { id: string; doc: string | undefined; accessor: DocAccessor<T> } => {
19
+ return useMemo(
20
+ () => ({
21
+ id: text.id,
22
+ doc: getTextContent(text),
23
+ accessor: createDocAccessor<T>(text),
24
+ }),
25
+ [text],
26
+ );
27
+ };