@dxos/react-ui-editor 0.4.4 → 0.4.5-main.17763ce

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 (46) hide show
  1. package/dist/lib/browser/index.mjs +421 -540
  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/MarkdownEditor.stories.d.ts +5 -5
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
  7. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  8. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  9. package/dist/types/src/extensions/cursor-line-margin.d.ts +8 -0
  10. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -0
  11. package/dist/types/src/extensions/index.d.ts +1 -0
  12. package/dist/types/src/extensions/index.d.ts.map +1 -1
  13. package/dist/types/src/extensions/markdown/decorate.d.ts +5 -0
  14. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -0
  15. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  16. package/dist/types/src/extensions/markdown/index.d.ts +1 -4
  17. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  18. package/dist/types/src/extensions/markdown/link.d.ts +1 -11
  19. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  20. package/dist/types/src/styles/layout.d.ts +2 -2
  21. package/dist/types/src/styles/layout.d.ts.map +1 -1
  22. package/package.json +31 -28
  23. package/src/components/TextEditor/MarkdownEditor.stories.tsx +29 -37
  24. package/src/components/TextEditor/hooks.stories.tsx +5 -17
  25. package/src/components/Toolbar/Toolbar.stories.tsx +4 -7
  26. package/src/components/Toolbar/Toolbar.tsx +0 -1
  27. package/src/extensions/cursor-line-margin.ts +92 -0
  28. package/src/extensions/index.ts +1 -0
  29. package/src/extensions/markdown/decorate.ts +301 -0
  30. package/src/extensions/markdown/formatting.test.ts +17 -0
  31. package/src/extensions/markdown/formatting.ts +67 -76
  32. package/src/extensions/markdown/index.ts +1 -4
  33. package/src/extensions/markdown/link.ts +23 -185
  34. package/src/styles/layout.ts +3 -2
  35. package/dist/types/src/extensions/markdown/code.d.ts +0 -2
  36. package/dist/types/src/extensions/markdown/code.d.ts.map +0 -1
  37. package/dist/types/src/extensions/markdown/heading.d.ts +0 -6
  38. package/dist/types/src/extensions/markdown/heading.d.ts.map +0 -1
  39. package/dist/types/src/extensions/markdown/hr.d.ts +0 -2
  40. package/dist/types/src/extensions/markdown/hr.d.ts.map +0 -1
  41. package/dist/types/src/extensions/markdown/tasklist.d.ts +0 -3
  42. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +0 -1
  43. package/src/extensions/markdown/code.ts +0 -162
  44. package/src/extensions/markdown/heading.ts +0 -59
  45. package/src/extensions/markdown/hr.ts +0 -71
  46. package/src/extensions/markdown/tasklist.ts +0 -110
@@ -19,19 +19,15 @@ import { withTheme } from '@dxos/storybook-utils';
19
19
  import { MarkdownEditor, type TextEditorProps } from './TextEditor';
20
20
  import {
21
21
  type CommentsOptions,
22
- type LinkOptions,
23
22
  autocomplete,
24
23
  blast,
25
- code,
24
+ decorateMarkdown,
26
25
  comments,
27
26
  defaultOptions,
28
- heading,
29
- hr,
30
27
  image,
31
- link,
28
+ linkTooltip,
32
29
  mention,
33
30
  table,
34
- tasklist,
35
31
  typewriter,
36
32
  useComments,
37
33
  formatting,
@@ -47,9 +43,11 @@ const str = (...lines: string[]) => lines.join('\n');
47
43
 
48
44
  const num = () => faker.number.int({ min: 0, max: 9999 }).toLocaleString();
49
45
 
50
- // prettier-ignore
46
+ const img = '![dxos](https://pbs.twimg.com/profile_banners/1268328127673044992/1684766689/1500x500)';
47
+
51
48
  const text = {
52
49
  tasks: str(
50
+ //
53
51
  '## Tasks',
54
52
  '',
55
53
  '- [x] decorator',
@@ -61,6 +59,7 @@ const text = {
61
59
  ),
62
60
 
63
61
  list: str(
62
+ //
64
63
  '## List',
65
64
  '',
66
65
  '- new york',
@@ -70,12 +69,13 @@ const text = {
70
69
  ),
71
70
 
72
71
  numbered: str(
72
+ //
73
73
  '## Numbered',
74
74
  '',
75
75
  '1. one',
76
76
  '2. two',
77
77
  '3. three',
78
- ''
78
+ '',
79
79
  ),
80
80
 
81
81
  code: str(
@@ -115,7 +115,7 @@ const text = {
115
115
  '',
116
116
  ),
117
117
 
118
- image: str('# Image', '', '![dxos](https://pbs.twimg.com/profile_banners/1268328127673044992/1684766689/1500x500)'),
118
+ image: str('# Image', '', img),
119
119
 
120
120
  headings: str(
121
121
  ...[1, 2, 3, 4, 5, 6].map((level) => ['#'.repeat(level) + ` Heading ${level}`, faker.lorem.sentences(), '']).flat(),
@@ -123,7 +123,7 @@ const text = {
123
123
 
124
124
  paragraphs: str(...faker.helpers.multiple(() => [faker.lorem.paragraph(), ''], { count: 3 }).flat()),
125
125
 
126
- footer: str('', '', '', '', '')
126
+ footer: str('', '', '', '', ''),
127
127
  };
128
128
 
129
129
  const document = str(
@@ -173,7 +173,7 @@ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', '
173
173
  const hover =
174
174
  'rounded-sm text-base text-primary-600 hover:text-primary-500 dark:text-primary-300 hover:dark:text-primary-200';
175
175
 
176
- const onHoverLinkTooltip: LinkOptions['onHover'] = (el, url) => {
176
+ const renderLinkTooltip = (el: Element, url: string) => {
177
177
  const web = new URL(url);
178
178
  createRoot(el).render(
179
179
  <StrictMode>
@@ -206,7 +206,7 @@ const onCommentsHover: CommentsOptions['onHover'] = (el, shortcut) => {
206
206
  );
207
207
  };
208
208
 
209
- const onRenderLink: LinkOptions['onRender'] = (el, url) => {
209
+ const renderLinkButton = (el: Element, url: string) => {
210
210
  createRoot(el).render(
211
211
  <StrictMode>
212
212
  <a href={url} target='_blank' rel='noreferrer' className={hover}>
@@ -257,14 +257,11 @@ const defaults = [
257
257
  autocomplete({
258
258
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
259
259
  }),
260
- code(),
260
+ decorateMarkdown({ renderLinkButton }),
261
261
  formatting(),
262
- heading(),
263
- hr(),
264
262
  image(),
265
- link({ onRender: onRenderLink, onHover: onHoverLinkTooltip }),
263
+ linkTooltip(renderLinkTooltip),
266
264
  table(),
267
- tasklist(),
268
265
  ];
269
266
 
270
267
  export const Default = {
@@ -281,6 +278,11 @@ export const NoExtensions = {
281
278
 
282
279
  const large = faker.helpers.multiple(() => faker.lorem.paragraph({ min: 8, max: 16 }), { count: 20 }).join('\n\n');
283
280
 
281
+ const largeWithImages = faker.helpers
282
+ .multiple(() => [faker.lorem.paragraph({ min: 12, max: 16 }), img], { count: 20 })
283
+ .flatMap((x) => x)
284
+ .join('\n\n');
285
+
284
286
  export const Empty = {
285
287
  render: () => <Story />,
286
288
  };
@@ -289,26 +291,25 @@ export const Scrolling = {
289
291
  render: () => <Story text={str('# Large Document', '', large)} extensions={[]} />,
290
292
  };
291
293
 
292
- export const Links = {
293
- render: () => (
294
- <Story
295
- text={str(text.links, text.footer)}
296
- extensions={[link({ onHover: onHoverLinkTooltip, onRender: onRenderLink })]}
297
- />
298
- ),
294
+ export const ScrollingWithImages = {
295
+ render: () => <Story text={str('# Large Document', '', largeWithImages)} extensions={[image()]} />,
299
296
  };
300
297
 
301
- export const Code = {
302
- render: () => <Story text={str(text.code, text.footer)} extensions={[code()]} />,
298
+ export const Links = {
299
+ render: () => <Story text={str(text.links, text.footer)} extensions={[linkTooltip(renderLinkTooltip)]} />,
303
300
  };
304
301
 
305
302
  export const Image = {
306
303
  render: () => <Story text={str(text.image, text.footer)} extensions={[image()]} />,
307
304
  };
308
305
 
306
+ export const Code = {
307
+ render: () => <Story text={str(text.code, text.footer)} extensions={[decorateMarkdown()]} />,
308
+ };
309
+
309
310
  export const Lists = {
310
311
  render: () => (
311
- <Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[tasklist()]} />
312
+ <Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[decorateMarkdown()]} />
312
313
  ),
313
314
  };
314
315
 
@@ -330,7 +331,7 @@ export const Autocomplete = {
330
331
  <Story
331
332
  text={str('# Autocomplete', '', 'Press Ctrl-Space...', text.footer)}
332
333
  extensions={[
333
- link({ onRender: onRenderLink }),
334
+ decorateMarkdown({ renderLinkButton }),
334
335
  autocomplete({
335
336
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
336
337
  }),
@@ -388,15 +389,6 @@ export const Comments = {
388
389
  },
389
390
  };
390
391
 
391
- export const HorizontalRule = {
392
- render: () => (
393
- <Story
394
- text={str('# Horizontal Rule', '', text.paragraphs, '---', text.paragraphs, '---', text.paragraphs)}
395
- extensions={[hr()]}
396
- />
397
- ),
398
- };
399
-
400
392
  export const Vim = {
401
393
  render: () => (
402
394
  <Story
@@ -14,19 +14,15 @@ import { TextEditor } from './TextEditor';
14
14
  import {
15
15
  type EditorMode,
16
16
  EditorModes,
17
- code,
17
+ decorateMarkdown,
18
18
  createMarkdownExtensions,
19
19
  formatting,
20
- heading,
21
- hr,
22
20
  image,
23
- link,
24
21
  table,
25
- tasklist,
26
22
  useFormattingState,
27
23
  } from '../../extensions';
28
24
  import { createDataExtensions, createThemeExtensions, useActionHandler, useTextEditor } from '../../hooks';
29
- import { editorFillLayoutEditor, editorFillLayoutRoot, editorWithToolbarLayout } from '../../styles';
25
+ import { editorFillLayoutEditor, editorWithToolbarLayout } from '../../styles';
30
26
  import { markdownTheme } from '../../themes';
31
27
  import translations from '../../translations';
32
28
  import { Toolbar } from '../Toolbar';
@@ -56,20 +52,16 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
56
52
  themeMode,
57
53
  theme: markdownTheme,
58
54
  slots: {
59
- editor: { className: editorFillLayoutEditor },
55
+ content: { className: '!p-4' },
60
56
  },
61
57
  }),
62
58
  // TODO(burdon): Move lineWrapping.
63
59
  createMarkdownExtensions({ placeholder, lineWrapping: true }),
64
60
  // TODO(burdon): Move into markdown bundle (with React callbacks).
65
- code(),
61
+ decorateMarkdown(),
66
62
  formatting(),
67
- heading(),
68
- hr(),
69
63
  image(),
70
- link(),
71
64
  table(),
72
- tasklist(),
73
65
  trackFormatting,
74
66
  ],
75
67
  [editorMode, themeMode, placeholder, trackFormatting, readonly],
@@ -87,11 +79,7 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
87
79
  <EditorModeToolbar editorMode={editorMode} setEditorMode={setEditorMode} />
88
80
  </Toolbar.Root>
89
81
  <div role='none' className='overflow-y-auto'>
90
- <div
91
- role='textbox'
92
- className={mx(textBlockWidth, attentionSurface, editorFillLayoutRoot, 'p-4')}
93
- ref={parentRef}
94
- />
82
+ <div role='textbox' className={mx(textBlockWidth, attentionSurface, editorFillLayoutEditor)} ref={parentRef} />
95
83
  </div>
96
84
  </div>
97
85
  );
@@ -14,13 +14,11 @@ import { withTheme } from '@dxos/storybook-utils';
14
14
 
15
15
  import { Toolbar } from './Toolbar';
16
16
  import {
17
- code,
17
+ decorateMarkdown,
18
18
  comments,
19
19
  formatting,
20
- heading,
21
20
  image,
22
21
  table,
23
- tasklist,
24
22
  useComments,
25
23
  useFormattingState,
26
24
  } from '../../extensions';
@@ -39,7 +37,7 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
39
37
  const [formattingState, formattingObserver] = useFormattingState();
40
38
  const extensions = useMemo(
41
39
  () => [
42
- code(),
40
+ decorateMarkdown(),
43
41
  comments({
44
42
  onCreate: ({ cursor }) => {
45
43
  const id = PublicKey.random().toHex();
@@ -48,10 +46,8 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
48
46
  },
49
47
  }),
50
48
  formatting(),
51
- heading(),
52
49
  image(),
53
50
  table(),
54
- tasklist(),
55
51
  formattingObserver,
56
52
  ],
57
53
  [],
@@ -76,8 +72,9 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
76
72
  model={model}
77
73
  extensions={extensions}
78
74
  slots={{
79
- root: { className: mx(textBlockWidth, editorFillLayoutRoot, 'pli-2') },
75
+ root: { className: mx(textBlockWidth, editorFillLayoutRoot) },
80
76
  editor: { className: editorFillLayoutEditor },
77
+ content: { className: '!pli-2' },
81
78
  }}
82
79
  />
83
80
  </div>
@@ -233,7 +233,6 @@ const markdownBlocks: ButtonProps[] = [
233
233
  type: 'codeblock',
234
234
  Icon: CodeBlock,
235
235
  getState: (state) => state.blockType === 'codeblock',
236
- disabled: (state) => !state.blankLine,
237
236
  },
238
237
  {
239
238
  type: 'table',
@@ -0,0 +1,92 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // Based upon @acheronfail’s implementation, fetched 16 Feb 2024
6
+ // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448/5
7
+
8
+ import { type EditorState, type Extension, Facet, Transaction } from '@codemirror/state';
9
+ import { EditorView } from '@codemirror/view';
10
+
11
+ /**
12
+ * Number of lines above/below the cursor to keep visible in the viewport.
13
+ * Defaults to 3.
14
+ */
15
+ export const cursorLineMarginFacet = Facet.define<number, number>({
16
+ combine: (input) => input[0] ?? 3,
17
+ });
18
+
19
+ const annotation = 'cursorLineMargin';
20
+ const lineAtPos = (s: EditorState, pos: number) => s.doc.lineAt(pos).number;
21
+ // seems to the best approximation of CM5's `cursorScrollMargin`
22
+ // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448
23
+ export const cursorLineMargin: Extension = EditorView.updateListener.of(
24
+ ({ transactions, state, selectionSet, startState, view }) => {
25
+ // make sure we don't trigger an infinite loop and ignore our own changes
26
+ if (transactions.length < 1 || transactions.some((tr) => tr.isUserEvent(annotation))) {
27
+ return;
28
+ }
29
+
30
+ const s = state;
31
+ const { main } = s.selection;
32
+ const cursorLineMargin = s.facet(cursorLineMarginFacet);
33
+
34
+ // editor rect
35
+ const rect = view.dom.getBoundingClientRect();
36
+ // top and bottom pixel positions of the visible region of the editor
37
+ const viewportTop = rect.top - view.documentTop;
38
+ const viewportBottom = rect.bottom - view.documentTop;
39
+
40
+ // line with main selection
41
+ const mainLine = lineAtPos(s, main.head);
42
+ // top most visible line
43
+ const _visTopLine = lineAtPos(s, view.lineBlockAtHeight(viewportTop).from);
44
+
45
+ // bottom most visible line
46
+ // NOTE: calculating this is slightly more complex - if the editor is
47
+ // larger than all the lines in it, and the `scrollPastEnd()` extension is
48
+ // enabled, then we need to calculate where the bottom most visible line
49
+ // should be if it extended all the way to the bottom of the editor
50
+ const botLineBlk = view.lineBlockAtHeight(viewportBottom);
51
+ const visBotLineDoc = lineAtPos(s, Math.min(botLineBlk.to, s.doc.length));
52
+ const visBotLine =
53
+ botLineBlk.bottom >= viewportBottom
54
+ ? visBotLineDoc
55
+ : visBotLineDoc + Math.floor((viewportBottom - botLineBlk.bottom) / view.defaultLineHeight);
56
+
57
+ // const needsScrollTop = mainLine <= visTopLine + cursorLineMargin;
58
+ const needsScrollTop = false;
59
+ const needsScrollBot = mainLine >= visBotLine - cursorLineMargin;
60
+
61
+ // the scroll margins are overlapping
62
+ if (needsScrollTop && needsScrollBot) {
63
+ console.warn('is cursorScrollMargin too large for the editor size?');
64
+ return;
65
+ }
66
+
67
+ // no need to scroll, we're in between scroll regions
68
+ if (!needsScrollTop && !needsScrollBot) {
69
+ return;
70
+ }
71
+
72
+ // if we're within the margin, but moving the other way, then don't scroll
73
+ if (selectionSet) {
74
+ const { head } = startState.selection.main;
75
+ const oldMainLine = lineAtPos(startState, head);
76
+ if (needsScrollTop && oldMainLine < mainLine) {
77
+ return;
78
+ }
79
+ if (needsScrollBot && oldMainLine > mainLine) {
80
+ return;
81
+ }
82
+ }
83
+
84
+ view.dispatch({
85
+ annotations: Transaction.userEvent.of(annotation),
86
+ effects: EditorView.scrollIntoView(s.selection.main.head, {
87
+ y: needsScrollTop ? 'start' : 'end',
88
+ yMargin: view.defaultLineHeight * cursorLineMargin,
89
+ }),
90
+ });
91
+ },
92
+ );
@@ -10,6 +10,7 @@ export * from './basic';
10
10
  export * from './blast';
11
11
  export * from './comments';
12
12
  export * from './cursor';
13
+ export * from './cursor-line-margin';
13
14
  export * from './listener';
14
15
  export * from './markdown';
15
16
  export * from './mention';
@@ -0,0 +1,301 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { RangeSetBuilder, type EditorState } from '@codemirror/state';
7
+ import { EditorView, Decoration, type DecorationSet, WidgetType, ViewPlugin, type ViewUpdate } from '@codemirror/view';
8
+
9
+ import { mx } from '@dxos/react-ui-theme';
10
+
11
+ import { getToken } from '../../styles';
12
+
13
+ /**
14
+ * Widget for first/last line of code block (read-only).
15
+ */
16
+ // TODO(burdon): Add copy-to-clipboard button.
17
+ class FencedCodeBoundary extends WidgetType {
18
+ constructor(private readonly _className: string) {
19
+ super();
20
+ }
21
+
22
+ override toDOM() {
23
+ const el = document.createElement('span');
24
+ el.innerHTML = '&nbsp;';
25
+ el.className = mx('cm-code cm-codeblock', this._className);
26
+ return el;
27
+ }
28
+ }
29
+
30
+ class HorizontalRuleWidget extends WidgetType {
31
+ override toDOM() {
32
+ const el = document.createElement('span');
33
+ el.className = 'cm-hr';
34
+ return el;
35
+ }
36
+ }
37
+
38
+ class LinkButton extends WidgetType {
39
+ constructor(
40
+ private readonly url: string,
41
+ private readonly render: (el: Element, url: string) => void,
42
+ ) {
43
+ super();
44
+ }
45
+
46
+ override eq(other: this) {
47
+ return this.url === other.url;
48
+ }
49
+
50
+ override toDOM(view: EditorView) {
51
+ const el = document.createElement('span');
52
+ this.render(el, this.url);
53
+ return el;
54
+ }
55
+ }
56
+
57
+ class CheckboxWidget extends WidgetType {
58
+ constructor(private _checked: boolean) {
59
+ super();
60
+ }
61
+
62
+ override eq(other: this) {
63
+ return this._checked === other._checked;
64
+ }
65
+
66
+ override toDOM(view: EditorView) {
67
+ const input = document.createElement('input');
68
+ input.className = 'cm-task-checkbox';
69
+ input.type = 'checkbox';
70
+ input.checked = this._checked;
71
+ if (view.state.readOnly) {
72
+ input.setAttribute('disabled', 'true');
73
+ } else {
74
+ input.onmousedown = (event: Event) => {
75
+ const pos = view.posAtDOM(input);
76
+ const text = view.state.sliceDoc(pos, pos + 3);
77
+ if (text === (this._checked ? '[x]' : '[ ]')) {
78
+ view.dispatch({
79
+ changes: { from: pos + 1, to: pos + 2, insert: this._checked ? ' ' : 'x' },
80
+ });
81
+ event.preventDefault();
82
+ }
83
+ };
84
+ }
85
+ return input;
86
+ }
87
+
88
+ override ignoreEvent() {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ const hide = Decoration.replace({});
94
+ const fencedCodeTop = Decoration.replace({ widget: new FencedCodeBoundary('cm-codeblock-end cm-codeblock-first') });
95
+ const fencedCodeBottom = Decoration.replace({ widget: new FencedCodeBoundary('cm-codeblock-end cm-codeblock-last') });
96
+ const horizontalRule = Decoration.replace({ widget: new HorizontalRuleWidget() });
97
+ const checkedTask = Decoration.replace({ widget: new CheckboxWidget(true) });
98
+ const uncheckedTask = Decoration.replace({ widget: new CheckboxWidget(false) });
99
+
100
+ const editingRange = (state: EditorState, range: { from: number; to: number }) => {
101
+ const {
102
+ readOnly,
103
+ selection: {
104
+ main: { head },
105
+ },
106
+ } = state;
107
+ return !readOnly && head >= range.from && head <= range.to;
108
+ };
109
+
110
+ const MarksByParent = new Set(['CodeMark', 'EmphasisMark', 'StrikethroughMark', 'SubscriptMark', 'SuperscriptMark']);
111
+
112
+ const buildDecorations = (view: EditorView, options: DecorateOptions): DecorationSet => {
113
+ const builder = new RangeSetBuilder<Decoration>();
114
+ const { state } = view;
115
+
116
+ for (const { from, to } of view.visibleRanges) {
117
+ syntaxTree(state).iterate({
118
+ from,
119
+ to,
120
+ enter: (node) => {
121
+ if (node.name === 'FencedCode') {
122
+ const editing = editingRange(state, node);
123
+ // FencedCode > CodeMark > [CodeInfo] > CodeText > CodeMark
124
+ for (const block of view.viewportLineBlocks) {
125
+ if (block.to < node.from) {
126
+ continue;
127
+ }
128
+ if (block.from > node.to) {
129
+ break;
130
+ }
131
+ const first = block.from <= node.from;
132
+ const last = block.to >= node.to;
133
+ if (!editing && (first || last)) {
134
+ builder.add(block.from, block.to, first ? fencedCodeTop : fencedCodeBottom);
135
+ } else {
136
+ builder.add(
137
+ block.from,
138
+ block.from,
139
+ Decoration.line({
140
+ class: mx('cm-code cm-codeblock-line', first && 'cm-codeblock-first', last && 'cm-codeblock-last'),
141
+ }),
142
+ );
143
+ }
144
+ }
145
+ return false;
146
+ } else if (node.name === 'Link') {
147
+ const marks = node.node.getChildren('LinkMark');
148
+ const urlNode = node.node.getChild('URL');
149
+ const editing = editingRange(state, node);
150
+ if (urlNode && marks.length >= 2) {
151
+ const url = state.sliceDoc(urlNode.from, urlNode.to);
152
+ if (!editing) {
153
+ builder.add(node.from, marks[0].to, hide);
154
+ }
155
+ builder.add(
156
+ marks[0].to,
157
+ marks[1].from,
158
+ Decoration.mark({
159
+ tagName: 'a',
160
+ attributes: {
161
+ class: 'cm-link',
162
+ href: url,
163
+ rel: 'noreferrer',
164
+ target: '_blank',
165
+ },
166
+ }),
167
+ );
168
+ if (!editing) {
169
+ builder.add(
170
+ marks[1].from,
171
+ node.to,
172
+ options.renderLinkButton
173
+ ? Decoration.replace({
174
+ widget: new LinkButton(url, options.renderLinkButton),
175
+ })
176
+ : hide,
177
+ );
178
+ }
179
+ }
180
+ } else if (node.name === 'HeaderMark') {
181
+ const parent = node.node.parent!;
182
+ if (/^ATX/.test(parent.name) && !editingRange(state, state.doc.lineAt(node.from))) {
183
+ const next = state.doc.sliceString(node.to, node.to + 1);
184
+ builder.add(node.from, node.to + (next === ' ' ? 1 : 0), hide);
185
+ }
186
+ } else if (node.name === 'HorizontalRule') {
187
+ if (!editingRange(state, node)) {
188
+ builder.add(node.from, node.to, horizontalRule);
189
+ }
190
+ } else if (node.name === 'TaskMarker') {
191
+ // Check if cursor is inside text.
192
+ if (!editingRange(state, node)) {
193
+ const checked = state.doc.sliceString(node.from + 1, node.to - 1) === 'x';
194
+ builder.add(node.from - 2, node.from - 1, Decoration.mark({ class: 'cm-task' }));
195
+ builder.add(node.from, node.to, checked ? checkedTask : uncheckedTask);
196
+ }
197
+ } else if (MarksByParent.has(node.name)) {
198
+ if (!editingRange(state, node.node.parent!)) {
199
+ builder.add(node.from, node.to, hide);
200
+ }
201
+ }
202
+ },
203
+ });
204
+ }
205
+
206
+ return builder.finish();
207
+ };
208
+
209
+ export interface DecorateOptions {
210
+ renderLinkButton?: (el: Element, url: string) => void;
211
+ }
212
+
213
+ export const decorateMarkdown = (options: DecorateOptions = {}) => {
214
+ return [
215
+ ViewPlugin.fromClass(
216
+ class {
217
+ decorations: DecorationSet;
218
+ constructor(view: EditorView) {
219
+ this.decorations = buildDecorations(view, options);
220
+ }
221
+
222
+ update(update: ViewUpdate) {
223
+ if (update.docChanged || update.selectionSet || update.viewportChanged) {
224
+ this.decorations = buildDecorations(update.view, options);
225
+ }
226
+ }
227
+ },
228
+ {
229
+ decorations: (value) => value.decorations,
230
+ },
231
+ ),
232
+ formattingStyles,
233
+ ];
234
+ };
235
+
236
+ const formattingStyles = EditorView.baseTheme({
237
+ '& .cm-code': {
238
+ fontFamily: getToken('fontFamily.mono', []).join(','),
239
+ },
240
+ '& .cm-codeblock-line': {
241
+ paddingInline: '1rem !important',
242
+ },
243
+
244
+ '& .cm-codeblock-end': {
245
+ display: 'inline-block',
246
+ width: '100%',
247
+ position: 'relative',
248
+ '&::after': {
249
+ position: 'absolute',
250
+ inset: 0,
251
+ content: '""',
252
+ },
253
+ },
254
+ '& .cm-codeblock-first': {
255
+ '&::after': {
256
+ borderTopLeftRadius: '.5rem',
257
+ borderTopRightRadius: '.5rem',
258
+ },
259
+ },
260
+ '& .cm-codeblock-last': {
261
+ '&::after': {
262
+ borderBottomLeftRadius: '.5rem',
263
+ borderBottomRightRadius: '.5rem',
264
+ },
265
+ },
266
+
267
+ '&light .cm-codeblock-line, &light .cm-activeLine.cm-codeblock-line': {
268
+ background: getToken('extend.semanticColors.input.light'),
269
+ mixBlendMode: 'darken',
270
+ },
271
+ '&dark .cm-codeblock-line, &dark .cm-activeLine.cm-codeblock-line': {
272
+ background: getToken('extend.semanticColors.input.dark'),
273
+ mixBlendMode: 'lighten',
274
+ },
275
+ '&light .cm-codeblock-first, &light .cm-codeblock-last': {
276
+ mixBlendMode: 'darken',
277
+ '&::after': {
278
+ background: getToken('extend.semanticColors.input.light'),
279
+ },
280
+ },
281
+ '&dark .cm-codeblock-first, &dark .cm-codeblock-last': {
282
+ mixBlendMode: 'lighten',
283
+ '&::after': {
284
+ background: getToken('extend.semanticColors.input.dark'),
285
+ },
286
+ },
287
+ '& .cm-hr': {
288
+ display: 'inline-block',
289
+ width: '100%',
290
+ height: '0',
291
+ verticalAlign: 'middle',
292
+ borderTop: `1px solid ${getToken('extend.colors.neutral.200')}`,
293
+ },
294
+ '& .cm-task': {
295
+ color: getToken('extend.colors.blue.500'),
296
+ },
297
+ '& .cm-task-checkbox': {
298
+ marginLeft: '4px',
299
+ marginRight: '4px',
300
+ },
301
+ });