@dxos/react-ui-editor 0.8.2-main.f11618f → 0.8.2-main.fbd8ed0

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 (125) hide show
  1. package/dist/lib/browser/index.mjs +1662 -1357
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +2175 -1872
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +1662 -1357
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +1 -1
  11. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  12. package/dist/types/src/{stories/InputMode.stories.d.ts → components/EditorToolbar/EditorToolbar.stories.d.ts} +3 -7
  13. package/dist/types/src/components/EditorToolbar/EditorToolbar.stories.d.ts.map +1 -0
  14. package/dist/types/src/components/EditorToolbar/blocks.d.ts +4 -3
  15. package/dist/types/src/components/EditorToolbar/blocks.d.ts.map +1 -1
  16. package/dist/types/src/components/EditorToolbar/comment.d.ts +4 -3
  17. package/dist/types/src/components/EditorToolbar/comment.d.ts.map +1 -1
  18. package/dist/types/src/components/EditorToolbar/formatting.d.ts +4 -3
  19. package/dist/types/src/components/EditorToolbar/formatting.d.ts.map +1 -1
  20. package/dist/types/src/components/EditorToolbar/headings.d.ts +4 -3
  21. package/dist/types/src/components/EditorToolbar/headings.d.ts.map +1 -1
  22. package/dist/types/src/components/EditorToolbar/image.d.ts +16 -0
  23. package/dist/types/src/components/EditorToolbar/image.d.ts.map +1 -0
  24. package/dist/types/src/components/EditorToolbar/lists.d.ts +4 -3
  25. package/dist/types/src/components/EditorToolbar/lists.d.ts.map +1 -1
  26. package/dist/types/src/components/EditorToolbar/search.d.ts +17 -0
  27. package/dist/types/src/components/EditorToolbar/search.d.ts.map +1 -0
  28. package/dist/types/src/components/EditorToolbar/util.d.ts +11 -17
  29. package/dist/types/src/components/EditorToolbar/util.d.ts.map +1 -1
  30. package/dist/types/src/components/EditorToolbar/view-mode.d.ts +4 -3
  31. package/dist/types/src/components/EditorToolbar/view-mode.d.ts.map +1 -1
  32. package/dist/types/src/defaults.d.ts.map +1 -1
  33. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  34. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  35. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  36. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  37. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  38. package/dist/types/src/extensions/automerge/defs.d.ts +1 -1
  39. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  40. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  41. package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
  42. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  43. package/dist/types/src/extensions/automerge/update-codemirror.d.ts +1 -1
  44. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
  45. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  46. package/dist/types/src/extensions/blast.d.ts.map +1 -1
  47. package/dist/types/src/extensions/command/command.d.ts.map +1 -1
  48. package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
  49. package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
  50. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  51. package/dist/types/src/extensions/debug.d.ts.map +1 -1
  52. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  53. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  54. package/dist/types/src/extensions/folding.d.ts.map +1 -1
  55. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  56. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  57. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
  58. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  59. package/dist/types/src/extensions/markdown/decorate.d.ts +1 -0
  60. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  61. package/dist/types/src/extensions/markdown/formatting.d.ts +1 -1
  62. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  63. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  64. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  65. package/dist/types/src/extensions/markdown/index.d.ts +1 -0
  66. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  67. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  68. package/dist/types/src/extensions/markdown/outliner.d.ts +12 -0
  69. package/dist/types/src/extensions/markdown/outliner.d.ts.map +1 -0
  70. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  71. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  72. package/dist/types/src/extensions/modes.d.ts +5 -5
  73. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  74. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  75. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  76. package/dist/types/src/extensions/typewriter.d.ts.map +1 -1
  77. package/dist/types/src/hooks/index.d.ts +0 -1
  78. package/dist/types/src/hooks/index.d.ts.map +1 -1
  79. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  80. package/dist/types/src/stories/TextEditorBasic.stories.d.ts +3 -0
  81. package/dist/types/src/stories/TextEditorBasic.stories.d.ts.map +1 -1
  82. package/dist/types/src/stories/story-utils.d.ts.map +1 -1
  83. package/dist/types/src/styles/theme.d.ts.map +1 -1
  84. package/dist/types/src/util/cursor.d.ts.map +1 -1
  85. package/dist/types/src/util/debug.d.ts.map +1 -1
  86. package/dist/types/src/util/dom.d.ts.map +1 -1
  87. package/dist/types/src/util/facet.d.ts.map +1 -1
  88. package/dist/types/src/util/react.d.ts.map +1 -1
  89. package/dist/types/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +32 -28
  91. package/src/components/EditorToolbar/EditorToolbar.stories.tsx +90 -0
  92. package/src/components/EditorToolbar/EditorToolbar.tsx +31 -32
  93. package/src/components/EditorToolbar/blocks.ts +27 -6
  94. package/src/components/EditorToolbar/comment.ts +11 -4
  95. package/src/components/EditorToolbar/formatting.ts +34 -7
  96. package/src/components/EditorToolbar/headings.ts +9 -8
  97. package/src/components/EditorToolbar/image.ts +16 -0
  98. package/src/components/EditorToolbar/lists.ts +26 -7
  99. package/src/components/EditorToolbar/search.ts +19 -0
  100. package/src/components/EditorToolbar/util.ts +14 -14
  101. package/src/components/EditorToolbar/view-mode.ts +9 -8
  102. package/src/defaults.ts +1 -1
  103. package/src/extensions/automerge/automerge.stories.tsx +9 -7
  104. package/src/extensions/automerge/automerge.test.tsx +4 -4
  105. package/src/extensions/automerge/automerge.ts +2 -2
  106. package/src/extensions/automerge/defs.ts +1 -2
  107. package/src/extensions/automerge/sync.ts +4 -4
  108. package/src/extensions/automerge/update-automerge.ts +1 -1
  109. package/src/extensions/automerge/update-codemirror.ts +3 -4
  110. package/src/extensions/markdown/changes.ts +3 -2
  111. package/src/extensions/markdown/decorate.ts +8 -7
  112. package/src/extensions/markdown/formatting.ts +4 -4
  113. package/src/extensions/markdown/index.ts +1 -0
  114. package/src/extensions/markdown/outliner.ts +235 -0
  115. package/src/extensions/markdown/styles.ts +2 -2
  116. package/src/extensions/modes.ts +5 -6
  117. package/src/hooks/index.ts +0 -1
  118. package/src/stories/TextEditorBasic.stories.tsx +44 -0
  119. package/src/stories/story-utils.tsx +7 -9
  120. package/src/styles/theme.ts +3 -0
  121. package/dist/types/src/hooks/useActionHandler.d.ts +0 -4
  122. package/dist/types/src/hooks/useActionHandler.d.ts.map +0 -1
  123. package/dist/types/src/stories/InputMode.stories.d.ts.map +0 -1
  124. package/src/hooks/useActionHandler.ts +0 -12
  125. package/src/stories/InputMode.stories.tsx +0 -124
@@ -2,21 +2,23 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import { type EditorView } from '@codemirror/view';
5
6
  import { useMemo } from 'react';
6
7
 
8
+ import { type Action } from '@dxos/app-graph';
7
9
  import { live, type Live } from '@dxos/live-object';
8
- import { type Label, type ThemedClassName } from '@dxos/react-ui';
10
+ import { type ThemedClassName } from '@dxos/react-ui';
9
11
  import {
10
12
  type MenuSeparator,
11
13
  type MenuItemGroup,
12
14
  type ToolbarMenuActionGroupProperties,
13
- type MenuActionProperties,
14
15
  createMenuAction,
15
16
  createMenuItemGroup,
16
17
  type ActionGraphProps,
18
+ type MenuActionProperties,
17
19
  } from '@dxos/react-ui-menu';
18
20
 
19
- import type { EditorAction, EditorActionPayload, EditorViewMode, Formatting } from '../../extensions';
21
+ import type { EditorAction, EditorViewMode, Formatting } from '../../extensions';
20
22
  import { translationKey } from '../../translations';
21
23
 
22
24
  export type EditorToolbarState = Formatting &
@@ -31,16 +33,18 @@ export type EditorToolbarFeatureFlags = Partial<{
31
33
  formatting: boolean;
32
34
  lists: boolean;
33
35
  blocks: boolean;
34
- comment: boolean;
35
36
  search: boolean;
36
- viewMode: boolean;
37
+ // TODO(wittjosiah): Factor out. Depend on plugin-level capabilities.
38
+ comment: boolean;
39
+ image: () => void;
40
+ viewMode: (mode: EditorViewMode) => void;
37
41
  }>;
38
42
 
39
43
  export type EditorToolbarActionGraphProps = {
40
44
  state: Live<EditorToolbarState>;
45
+ getView: () => EditorView;
41
46
  // TODO(wittjosiah): Control positioning.
42
47
  customActions?: () => ActionGraphProps;
43
- onAction: (action: EditorAction) => void;
44
48
  };
45
49
 
46
50
  export type EditorToolbarProps = ThemedClassName<
@@ -49,17 +53,13 @@ export type EditorToolbarProps = ThemedClassName<
49
53
 
50
54
  export type EditorToolbarItem = EditorAction | MenuItemGroup | MenuSeparator;
51
55
 
52
- export const createEditorAction = (
53
- payload: EditorActionPayload & Partial<MenuActionProperties>,
54
- icon: string,
55
- label: Label = [`${payload.type} label`, { ns: translationKey }],
56
- id: string = payload.type,
57
- ) => createMenuAction(id, { icon, label, ...payload }) as EditorAction;
56
+ export const createEditorAction = (id: string, invoke: () => void, properties: Partial<MenuActionProperties>) => {
57
+ const { label = [`${id} label`, { ns: translationKey }], ...rest } = properties;
58
+ return createMenuAction(id, invoke, { label, ...rest }) as Action<MenuActionProperties>;
59
+ };
58
60
 
59
61
  export const createEditorActionGroup = (
60
62
  id: string,
61
63
  props: Omit<ToolbarMenuActionGroupProperties, 'icon'>,
62
64
  icon?: string,
63
65
  ) => createMenuItemGroup(id, { icon, iconOnly: true, ...props });
64
-
65
- export const editorToolbarSearch = createEditorAction({ type: 'search' }, 'ph--magnifying-glass--regular');
@@ -6,6 +6,7 @@ import { type NodeArg } from '@dxos/app-graph';
6
6
  import { type ToolbarMenuActionGroupProperties } from '@dxos/react-ui-menu';
7
7
 
8
8
  import { createEditorAction, createEditorActionGroup, type EditorToolbarState } from './util';
9
+ import { type EditorViewMode } from '../../extensions';
9
10
  import { translationKey } from '../../translations';
10
11
 
11
12
  const createViewModeGroupAction = (value: string) =>
@@ -20,24 +21,24 @@ const createViewModeGroupAction = (value: string) =>
20
21
  'ph--eye--regular',
21
22
  );
22
23
 
23
- const createViewModeActions = (value: string) =>
24
+ const createViewModeActions = (value: string, onViewModeChange: (mode: EditorViewMode) => void) =>
24
25
  Object.entries({
25
26
  preview: 'ph--eye--regular',
26
27
  source: 'ph--pencil-simple--regular',
27
28
  readonly: 'ph--pencil-slash--regular',
28
29
  }).map(([viewMode, icon]) => {
29
- return createEditorAction(
30
- { type: 'view-mode', data: viewMode, checked: viewMode === value },
30
+ const checked = viewMode === value;
31
+ return createEditorAction(`view-mode--${viewMode}`, () => onViewModeChange(viewMode as EditorViewMode), {
32
+ label: [`${viewMode} mode label`, { ns: translationKey }],
33
+ checked,
31
34
  icon,
32
- [`${viewMode} mode label`, { ns: translationKey }],
33
- `view-mode--${viewMode}`,
34
- );
35
+ });
35
36
  });
36
37
 
37
- export const createViewMode = (state: EditorToolbarState) => {
38
+ export const createViewMode = (state: EditorToolbarState, onViewModeChange: (mode: EditorViewMode) => void) => {
38
39
  const value = state.viewMode ?? 'source';
39
40
  const viewModeGroupAction = createViewModeGroupAction(value);
40
- const viewModeActions = createViewModeActions(value);
41
+ const viewModeActions = createViewModeActions(value, onViewModeChange);
41
42
  return {
42
43
  nodes: [viewModeGroupAction as NodeArg<any>, ...viewModeActions],
43
44
  edges: [
package/src/defaults.ts CHANGED
@@ -52,6 +52,6 @@ export const stackItemContentEditorClassNames = (role?: string) =>
52
52
 
53
53
  export const stackItemContentToolbarClassNames = (role?: string) =>
54
54
  mx(
55
- 'attention-surface is-full border-be !border-separator relative z-[1]',
55
+ 'relative z-[1] flex is-full bg-toolbarSurface border-be border-separator',
56
56
  role === 'section' && 'sticky block-start-0 -mbe-px min-is-0',
57
57
  );
@@ -5,12 +5,13 @@
5
5
  import '@dxos-theme';
6
6
 
7
7
  import '@preact/signals-react';
8
+
9
+ import { Repo } from '@automerge/automerge-repo';
10
+ import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-network-broadcastchannel';
8
11
  import React, { useEffect, useState } from 'react';
9
12
 
10
- import { Repo } from '@dxos/automerge/automerge-repo';
11
- import { BroadcastChannelNetworkAdapter } from '@dxos/automerge/automerge-repo-network-broadcastchannel';
12
13
  import { Expando } from '@dxos/echo-schema';
13
- import { DocAccessor, Filter, live, createDocAccessor, useQuery, useSpace, type Space } from '@dxos/react-client/echo';
14
+ import { DocAccessor, live, createDocAccessor, useQuery, useSpace, type Space, Query } from '@dxos/react-client/echo';
14
15
  import { useIdentity, type Identity } from '@dxos/react-client/halo';
15
16
  import { ClientRepeater, type ClientRepeatedComponentProps } from '@dxos/react-client/testing';
16
17
  import { useThemeContext } from '@dxos/react-ui';
@@ -71,11 +72,12 @@ const Story = () => {
71
72
  doc.text = initialContent;
72
73
  });
73
74
 
74
- const object2 = repo2.find<TestObject>(object1.url);
75
+ const object2 = await repo2.find<TestObject>(object1.url);
75
76
  await object2.whenReady();
76
77
 
77
- setObject1({ handle: object1, path: ['text'] });
78
- setObject2({ handle: object2, path: ['text'] });
78
+ // TODO(mykola): Fix types.
79
+ setObject1({ handle: object1 as any, path: ['text'] });
80
+ setObject2({ handle: object2 as any, path: ['text'] });
79
81
  });
80
82
  }, []);
81
83
 
@@ -103,7 +105,7 @@ const EchoStory = ({ spaceKey }: ClientRepeatedComponentProps) => {
103
105
  const identity = useIdentity();
104
106
  const space = useSpace(spaceKey);
105
107
  const [source, setSource] = useState<DocAccessor>();
106
- const objects = useQuery<Expando>(space, Filter.from({ type: 'test' }));
108
+ const objects = useQuery(space, Query.type(Expando, { type: 'test' }));
107
109
 
108
110
  useEffect(() => {
109
111
  if (!source && objects.length) {
@@ -2,6 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { type DocHandle, Repo } from '@automerge/automerge-repo';
5
6
  import { EditorState } from '@codemirror/state';
6
7
  import { EditorView } from '@codemirror/view';
7
8
  import { render, screen } from '@testing-library/react';
@@ -12,8 +13,6 @@ import get from 'lodash.get';
12
13
  import React, { type FC, useEffect, useRef, useState } from 'react';
13
14
  import { describe, test } from 'vitest';
14
15
 
15
- import { type DocHandle, Repo } from '@dxos/automerge/automerge-repo';
16
-
17
16
  import { automerge } from './automerge';
18
17
 
19
18
  type TestObject = {
@@ -36,7 +35,8 @@ const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ han
36
35
  const [view, setView] = useState<EditorView>();
37
36
  useEffect(() => {
38
37
  const extensions = [
39
- automerge({ handle, path }),
38
+ // TODO(mykola): Fix types.
39
+ automerge({ handle: handle as any, path }),
40
40
  EditorView.updateListener.of((update) => {
41
41
  if (view.state.doc.toString() === 'hello!') {
42
42
  // Update editor.
@@ -46,7 +46,7 @@ const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ han
46
46
  ];
47
47
 
48
48
  const view = new EditorView({
49
- state: EditorState.create({ doc: get(handle.docSync()!, path), extensions }),
49
+ state: EditorState.create({ doc: get(handle.doc()!, path), extensions }),
50
50
  parent: ref.current!,
51
51
  });
52
52
 
@@ -4,10 +4,10 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
+ import { next as A } from '@automerge/automerge';
7
8
  import { StateField, type Extension } from '@codemirror/state';
8
9
  import { EditorView, ViewPlugin } from '@codemirror/view';
9
10
 
10
- import { next as A } from '@dxos/automerge/automerge';
11
11
  import { type DocAccessor } from '@dxos/react-client/echo';
12
12
 
13
13
  import { cursorConverter } from './cursor';
@@ -19,7 +19,7 @@ export const automerge = (accessor: DocAccessor): Extension => {
19
19
  const syncState = StateField.define<State>({
20
20
  create: () => ({
21
21
  path: accessor.path.slice(),
22
- lastHeads: A.getHeads(accessor.handle.docSync()!),
22
+ lastHeads: A.getHeads(accessor.handle.doc()!),
23
23
  unreconciledTransactions: [],
24
24
  }),
25
25
 
@@ -4,10 +4,9 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
+ import { type Heads, type Prop } from '@automerge/automerge';
7
8
  import { Annotation, StateEffect, type StateField, type EditorState, type Transaction } from '@codemirror/state';
8
9
 
9
- import { type Heads, type Prop } from '@dxos/automerge/automerge';
10
-
11
10
  export type State = {
12
11
  path: Prop[];
13
12
  lastHeads: Heads;
@@ -4,10 +4,10 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
+ import { next as A } from '@automerge/automerge';
7
8
  import { type StateField } from '@codemirror/state';
8
9
  import { type EditorView } from '@codemirror/view';
9
10
 
10
- import { next as A } from '@dxos/automerge/automerge';
11
11
  import { type IDocHandle } from '@dxos/react-client/echo';
12
12
 
13
13
  import { getLastHeads, getPath, isReconcile, reconcileAnnotation, type State, updateHeads } from './defs';
@@ -57,15 +57,15 @@ export class Syncer {
57
57
  onAutomergeChange(view: EditorView) {
58
58
  // Get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
59
59
  const oldHeads = getLastHeads(view.state, this._state);
60
- const newHeads = A.getHeads(this._handle.docSync()!);
61
- const diff = A.equals(oldHeads, newHeads) ? [] : A.diff(this._handle.docSync()!, oldHeads, newHeads);
60
+ const newHeads = A.getHeads(this._handle.doc()!);
61
+ const diff = A.equals(oldHeads, newHeads) ? [] : A.diff(this._handle.doc()!, oldHeads, newHeads);
62
62
 
63
63
  const selection = view.state.selection;
64
64
  const path = getPath(view.state, this._state);
65
65
  updateCodeMirror(view, selection, path, diff);
66
66
 
67
67
  // TODO(burdon): Test conflicts?
68
- // A.getConflicts(this._handle.docSync()!, path[0]);
68
+ // A.getConflicts(this._handle.doc()!, path[0]);
69
69
 
70
70
  view.dispatch({
71
71
  effects: updateHeads(newHeads),
@@ -4,9 +4,9 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
+ import { next as A, type Heads } from '@automerge/automerge';
7
8
  import { type EditorState, type StateField, type Transaction, type Text } from '@codemirror/state';
8
9
 
9
- import { next as A, type Heads } from '@dxos/automerge/automerge';
10
10
  import { type IDocHandle } from '@dxos/react-client/echo';
11
11
 
12
12
  import { type State } from './defs';
@@ -4,9 +4,6 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { ChangeSet, type ChangeSpec, type EditorSelection, type EditorState } from '@codemirror/state';
8
- import { type EditorView } from '@codemirror/view';
9
-
10
7
  import {
11
8
  type DelPatch,
12
9
  type InsertPatch,
@@ -14,7 +11,9 @@ import {
14
11
  type Prop,
15
12
  type PutPatch,
16
13
  type SpliceTextPatch,
17
- } from '@dxos/automerge/automerge';
14
+ } from '@automerge/automerge';
15
+ import { ChangeSet, type ChangeSpec, type EditorSelection, type EditorState } from '@codemirror/state';
16
+ import { type EditorView } from '@codemirror/view';
18
17
 
19
18
  import { reconcileAnnotation } from './defs';
20
19
 
@@ -56,8 +56,9 @@ export const adjustChanges = () => {
56
56
  // Check for URL.
57
57
  const url = getValidUrl(update.view.state.sliceDoc(fromB, toB));
58
58
  if (url) {
59
+ // Check if pasting inside existing link.
59
60
  const node = tree.resolveInner(fromA, -1);
60
- const invalidPositions = new Set(['Link', 'LinkMark', 'Code', 'CodeText', 'FencedCode', 'URL']);
61
+ const invalidPositions = new Set(['Code', 'CodeText', 'FencedCode', 'Link', 'LinkMark', 'URL']);
61
62
  if (!invalidPositions.has(node?.name)) {
62
63
  const replacedText = tr.startState.sliceDoc(fromA, toA);
63
64
  adjustments.push({ from: fromA, to: toB, insert: createLink(url, replacedText) });
@@ -84,7 +85,7 @@ export const adjustChanges = () => {
84
85
  }
85
86
  }
86
87
 
87
- // TODO(burdon): Is this the right way to augment changes?
88
+ // TODO(burdon): Is this the right way to augment changes? Alt: EditorState.transactionFilter
88
89
  if (adjustments.length) {
89
90
  setTimeout(() => {
90
91
  update.view.dispatch(
@@ -129,10 +129,10 @@ class TextWidget extends WidgetType {
129
129
  }
130
130
 
131
131
  const hide = Decoration.replace({});
132
- const blockQuote = Decoration.line({ class: mx('cm-blockquote') });
133
- const fencedCodeLine = Decoration.line({ class: mx('cm-code cm-codeblock-line') });
134
- const fencedCodeLineFirst = Decoration.line({ class: mx('cm-code cm-codeblock-line', 'cm-codeblock-first') });
135
- const fencedCodeLineLast = Decoration.line({ class: mx('cm-code cm-codeblock-line', 'cm-codeblock-last') });
132
+ const blockQuote = Decoration.line({ class: 'cm-blockquote' });
133
+ const fencedCodeLine = Decoration.line({ class: 'cm-code cm-codeblock-line' });
134
+ const fencedCodeLineFirst = Decoration.line({ class: mx('cm-code cm-codeblock-line', 'cm-codeblock-start') });
135
+ const fencedCodeLineLast = Decoration.line({ class: mx('cm-code cm-codeblock-line', 'cm-codeblock-end') });
136
136
  const commentBlockLine = fencedCodeLine;
137
137
  const commentBlockLineFirst = fencedCodeLineFirst;
138
138
  const commentBlockLineLast = fencedCodeLineLast;
@@ -277,7 +277,7 @@ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boo
277
277
  // Set indentation.
278
278
  const list = getCurrentListLevel();
279
279
  const width = list.type === 'OrderedList' ? orderedListIndentationWidth : bulletListIndentationWidth;
280
- const offset = ((list.level ?? 0) + 1) * width;
280
+ const offset = (options?.listPaddingLeft ?? 0) + ((list.level ?? 0) + 1) * width;
281
281
  if (node.from === line.to - 1) {
282
282
  // Abort if only the hyphen is typed.
283
283
  return false;
@@ -285,7 +285,6 @@ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boo
285
285
 
286
286
  // Add line decoration for the continuation indent.
287
287
  // TODO(burdon): Bug if indentation is more than one indentation unit (e.g., 4 spaces) from the previous line.
288
-
289
288
  deco.add(
290
289
  line.from,
291
290
  line.from,
@@ -406,7 +405,7 @@ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boo
406
405
  }
407
406
 
408
407
  const first = block.from <= node.from;
409
- const last = block.to >= node.to && /^(\s>)*```$/.test(state.doc.sliceString(block.from, block.to));
408
+ const last = block.to >= node.to && /```$/.test(state.doc.sliceString(block.from, block.to));
410
409
  deco.add(block.from, block.from, first ? fencedCodeLineFirst : last ? fencedCodeLineLast : fencedCodeLine);
411
410
 
412
411
  const editing = editingRange(state, node, focus);
@@ -521,6 +520,8 @@ export interface DecorateOptions {
521
520
  selectionChangeDelay?: number;
522
521
  numberedHeadings?: { from: number; to?: number };
523
522
  renderLinkButton?: RenderCallback<{ url: string }>;
523
+ // TODO(burdon): Additional padding for each line.
524
+ listPaddingLeft?: number;
524
525
  }
525
526
 
526
527
  export const decorateMarkdown = (options: DecorateOptions = {}) => {
@@ -5,13 +5,13 @@
5
5
  import { snippet } from '@codemirror/autocomplete';
6
6
  import { syntaxTree } from '@codemirror/language';
7
7
  import {
8
- type Extension,
9
- type StateCommand,
10
- type EditorState,
11
8
  type ChangeSpec,
12
- type Text,
13
9
  EditorSelection,
10
+ type Extension,
11
+ type EditorState,
14
12
  type Line,
13
+ type StateCommand,
14
+ type Text,
15
15
  } from '@codemirror/state';
16
16
  import { EditorView, keymap } from '@codemirror/view';
17
17
  import { type SyntaxNodeRef, type SyntaxNode } from '@lezer/common';
@@ -10,4 +10,5 @@ export * from './formatting';
10
10
  export * from './highlight';
11
11
  export * from './image';
12
12
  export * from './link';
13
+ export * from './outliner';
13
14
  export * from './table';
@@ -0,0 +1,235 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import {
7
+ type ChangeSpec,
8
+ type Extension,
9
+ type Line,
10
+ StateField,
11
+ type Transaction,
12
+ EditorState,
13
+ type Range,
14
+ } from '@codemirror/state';
15
+ import { Decoration, type DecorationSet, EditorView } from '@codemirror/view';
16
+
17
+ import { log } from '@dxos/log';
18
+ import { mx } from '@dxos/react-ui-theme';
19
+
20
+ // TODO(burdon): Cut-and-paste.
21
+ // TODO(burdon): Toggle list/task mode (in gutter?)
22
+ // TODO(burdon): Convert to task object and insert link (menu button).
23
+ // TOOD(burdon): Continuation lines and rich formatting?
24
+
25
+ const indentLevel = 2;
26
+ const matchTaskMarker = /^\s*- (\[ \]|\[x\])? /;
27
+
28
+ /**
29
+ * Get the starting position of text on the given line.
30
+ */
31
+ const getLineInfo = (line: Line) => {
32
+ const match = line.text.match(matchTaskMarker);
33
+ const start = line.from + (match?.[0]?.length ?? 0);
34
+ return {
35
+ match, // TODO(burdon): Indicate task or list marker.
36
+ start,
37
+ };
38
+ };
39
+
40
+ /**
41
+ * Outliner extension.
42
+ * - Store outline as a standard markdown document with task and list markers.
43
+ * - Support continuation lines and rich formatting (with Shift+Enter).
44
+ * - Constrain editor to outline structure.
45
+ * - Support smart cut-and-paste.
46
+ * - Support extracted links.
47
+ * - Drag/drop lines or move them via shortcuts.
48
+ */
49
+ export const outliner = (): Extension => [
50
+ EditorState.transactionFilter.of((tr) => {
51
+ // Don't allow cursor before marker.
52
+ if (!tr.docChanged) {
53
+ const pos = tr.selection?.ranges[tr.selection?.mainIndex]?.from;
54
+ if (pos != null) {
55
+ const { match, start } = getLineInfo(tr.startState.doc.lineAt(pos));
56
+ if (match) {
57
+ if (pos < start) {
58
+ return [{ selection: { anchor: start, head: start } }];
59
+ }
60
+ }
61
+ }
62
+
63
+ return tr;
64
+ }
65
+
66
+ const changes: ChangeSpec[] = [];
67
+ tr.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
68
+ // NOTE: Task markers are atomic so will be deleted when backspace is pressed.
69
+ // NOTE: CM inserts 2 or 6 spaces when deleting a list or task marker to create a continuation.
70
+ // - [ ] <- backspace here deletes the task marker.
71
+ // - [ ] <- backspace here inserts 6 spaces (creates continuation).
72
+ // - [ ] <- backspace here deletes the task marker.
73
+
74
+ const line = tr.startState.doc.lineAt(fromA);
75
+ const isTaskMarker = line.text.match(matchTaskMarker);
76
+ if (isTaskMarker) {
77
+ const { start } = getLineInfo(line);
78
+
79
+ // Detect and cancel replacement of task marker with continuation indent.
80
+ const replace = start === toA && toA - fromA === insert.length;
81
+ if (replace) {
82
+ log.info('delete line');
83
+ changes.push({ from: line.from - 1, to: toA });
84
+ return;
85
+ }
86
+
87
+ // Detect deletion of marker.
88
+ if (fromB === toB) {
89
+ if (toA === line.to) {
90
+ const line = tr.state.doc.lineAt(fromA);
91
+ if (line.text.match(/^\s*$/)) {
92
+ if (line.from === 0) {
93
+ // Don't delete first line.
94
+ log.info('skip');
95
+ changes.push({ from: 0, to: 0 });
96
+ return;
97
+ } else {
98
+ // Delete indent and marker.
99
+ log.info('delete line');
100
+ changes.push({ from: line.from - 1, to: toA });
101
+ return;
102
+ }
103
+ }
104
+ }
105
+ return;
106
+ }
107
+
108
+ // Check appropriate indentation relative to previous line.
109
+ if (insert.length === indentLevel) {
110
+ if (line.number === 1) {
111
+ log.info('skip');
112
+ changes.push({ from: 0, to: 0 });
113
+ return;
114
+ } else {
115
+ const getIndent = (text: string) => (text.match(/^\s*/)?.[0]?.length ?? 0) / indentLevel;
116
+ const currentIndent = getIndent(line.text);
117
+ const indentPrevious = getIndent(tr.state.doc.lineAt(fromA - 1).text);
118
+ if (currentIndent > indentPrevious) {
119
+ log.info('skip');
120
+ changes.push({ from: 0, to: 0 });
121
+ return;
122
+ }
123
+ }
124
+ }
125
+
126
+ // TODO(burdon): Detect pressing ENTER on empty line that is indented.
127
+ // Don't allow empty line.
128
+ // if (start === line.to && insert.toString() === '\n') {
129
+ // log.info('skip');
130
+ // changes.push({ from: 0, to: 0 });
131
+ // return;
132
+ // }
133
+
134
+ log.info('change', {
135
+ line: { from: line.from, to: line.to },
136
+ start,
137
+ a: [fromA, toA],
138
+ b: [fromB, toB],
139
+ insert: { text: insert.toString(), length: insert.length },
140
+ });
141
+ }
142
+ });
143
+
144
+ if (changes.length > 0) {
145
+ return [{ changes }];
146
+ }
147
+
148
+ return tr;
149
+ }),
150
+
151
+ StateField.define<DecorationSet>({
152
+ create: (state) => {
153
+ return Decoration.set(buildDecorations(0, state.doc.length, state));
154
+ },
155
+ update: (value: DecorationSet, tr: Transaction) => {
156
+ const from = 0;
157
+ const to = tr.state.doc.length;
158
+ return value.map(tr.changes).update({
159
+ filterFrom: 0,
160
+ filterTo: tr.state.doc.length,
161
+ filter: () => false,
162
+ add: buildDecorations(from, to, tr.state),
163
+ });
164
+ },
165
+ provide: (field) => EditorView.decorations.from(field),
166
+ }),
167
+
168
+ // TODO(burdon): Increase indent padding by configuring decorate extension.
169
+ // TODO(burdon): Hover to select entire group.
170
+ EditorView.theme({
171
+ '.cm-list-item-start': {
172
+ borderTop: '1px solid var(--dx-separator)',
173
+ borderLeft: '1px solid var(--dx-separator)',
174
+ borderRight: '1px solid var(--dx-separator)',
175
+ borderTopLeftRadius: '4px',
176
+ borderTopRightRadius: '4px',
177
+ paddingTop: '4px',
178
+ marginTop: '8px',
179
+ },
180
+ '.cm-list-item-end': {
181
+ borderLeft: '1px solid var(--dx-separator)',
182
+ borderRight: '1px solid var(--dx-separator)',
183
+ borderBottom: '1px solid var(--dx-separator)',
184
+ borderBottomLeftRadius: '4px',
185
+ borderBottomRightRadius: '4px',
186
+ paddingBottom: '4px',
187
+ marginBottom: '8px',
188
+ },
189
+ '.cm-list-item-continuation': {
190
+ borderLeft: '1px solid var(--dx-separator)',
191
+ borderRight: '1px solid var(--dx-separator)',
192
+
193
+ // TODO(burdon): Should match parent indentation.
194
+ paddingLeft: '24px',
195
+ },
196
+
197
+ // TODO(burdon): Set via options to decorate extension.
198
+ '.cm-list-item-continuation.cm-codeblock-start': {
199
+ borderRadius: '0',
200
+ },
201
+ }),
202
+ ];
203
+
204
+ /**
205
+ * Add line decorations.
206
+ */
207
+ const buildDecorations = (from: number, to: number, state: EditorState) => {
208
+ const decorations: Range<Decoration>[] = [];
209
+ syntaxTree(state).iterate({
210
+ enter: (node) => {
211
+ if (node.name === 'ListItem') {
212
+ const sub = node.node.getChild('BulletList');
213
+ const lineStart = state.doc.lineAt(node.from);
214
+ const lineEnd = sub ? state.doc.lineAt(state.doc.lineAt(sub.from).from - 1) : state.doc.lineAt(node.to);
215
+
216
+ decorations.push(
217
+ Decoration.line({
218
+ class: mx('cm-list-item-start', lineStart.number === lineEnd.number && 'cm-list-item-end'),
219
+ }).range(lineStart.from, lineStart.from),
220
+ );
221
+
222
+ for (let i = lineStart.from + 1; i < lineEnd.from; i++) {
223
+ decorations.push(Decoration.line({ class: mx('cm-list-item-continuation') }).range(i, i));
224
+ }
225
+
226
+ // TODO(burdon): Need to sort.
227
+ if (lineStart.number !== lineEnd.number) {
228
+ decorations.push(Decoration.line({ class: mx('cm-list-item-end') }).range(lineEnd.from, lineEnd.from));
229
+ }
230
+ }
231
+ },
232
+ });
233
+
234
+ return decorations;
235
+ };
@@ -59,11 +59,11 @@ export const formattingStyles = EditorView.theme({
59
59
  background: 'var(--dx-cmCodeblock)',
60
60
  paddingInline: '1rem !important',
61
61
  },
62
- '& .cm-codeblock-first': {
62
+ '& .cm-codeblock-start': {
63
63
  borderTopLeftRadius: '.25rem',
64
64
  borderTopRightRadius: '.25rem',
65
65
  },
66
- '& .cm-codeblock-last': {
66
+ '& .cm-codeblock-end': {
67
67
  borderBottomLeftRadius: '.25rem',
68
68
  borderBottomRightRadius: '.25rem',
69
69
  },
@@ -6,18 +6,17 @@ import { type Extension } from '@codemirror/state';
6
6
  import { keymap } from '@codemirror/view';
7
7
  import { vim } from '@replit/codemirror-vim';
8
8
  import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
9
-
10
- import { S } from '@dxos/echo-schema';
9
+ import { Schema } from 'effect';
11
10
 
12
11
  import { singleValueFacet } from '../util';
13
12
 
14
13
  export const EditorViewModes = ['preview', 'readonly', 'source'] as const;
15
- export const EditorViewMode = S.Union(...EditorViewModes.map((mode) => S.Literal(mode)));
16
- export type EditorViewMode = S.Schema.Type<typeof EditorViewMode>;
14
+ export const EditorViewMode = Schema.Union(...EditorViewModes.map((mode) => Schema.Literal(mode)));
15
+ export type EditorViewMode = Schema.Schema.Type<typeof EditorViewMode>;
17
16
 
18
17
  export const EditorInputModes = ['default', 'vim', 'vscode'] as const;
19
- export const EditorInputMode = S.Union(...EditorInputModes.map((mode) => S.Literal(mode)));
20
- export type EditorInputMode = S.Schema.Type<typeof EditorInputMode>;
18
+ export const EditorInputMode = Schema.Union(...EditorInputModes.map((mode) => Schema.Literal(mode)));
19
+ export type EditorInputMode = Schema.Schema.Type<typeof EditorInputMode>;
21
20
 
22
21
  export type EditorInputConfig = {
23
22
  type?: string;