Skip to content

Commit 196aa28

Browse files
committed
feat(quoteLink): add quote link additional plugin
1 parent 6ab3b98 commit 196aa28

File tree

21 files changed

+604
-6
lines changed

21 files changed

+604
-6
lines changed

demo/stories/quoteLink/QuoteLink.tsx

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {memo, useCallback} from 'react';
2+
3+
import {transform as quoteLink} from '@diplodoc/quote-link-extension';
4+
import type {PluginWithParams} from 'markdown-it/lib';
5+
6+
import {ActionName as Action} from 'src/bundle/config/action-names';
7+
import {QuoteLink as QuoteLinkExtension} from 'src/extensions/additional/QuoteLink';
8+
import {
9+
MarkdownEditorView,
10+
type RenderPreview,
11+
type ToolbarsPreset,
12+
useMarkdownEditor,
13+
} from 'src/index';
14+
import {ToolbarName as Toolbar} from 'src/modules/toolbars/constants';
15+
import {
16+
quoteLinkItemMarkup,
17+
quoteLinkItemView,
18+
quoteLinkItemWysiwyg,
19+
} from 'src/modules/toolbars/items';
20+
import {defaultPreset} from 'src/modules/toolbars/presets';
21+
22+
import {PlaygroundLayout} from '../../components/PlaygroundLayout';
23+
import {SplitModePreview} from '../../components/SplitModePreview';
24+
import {plugins as defaultPlugins} from '../../defaults/md-plugins';
25+
import {useLogs} from '../../hooks/useLogs';
26+
27+
const plugins: PluginWithParams[] = [...defaultPlugins, quoteLink({bundle: false})];
28+
29+
const toolbarsPreset: ToolbarsPreset = {
30+
items: {
31+
...defaultPreset.items,
32+
[Action.quoteLink]: {
33+
view: quoteLinkItemView,
34+
wysiwyg: quoteLinkItemWysiwyg,
35+
markup: quoteLinkItemMarkup,
36+
},
37+
},
38+
orders: {
39+
[Toolbar.wysiwygMain]: [[Action.quoteLink], ...defaultPreset.orders[Toolbar.wysiwygMain]],
40+
[Toolbar.markupMain]: [[Action.quoteLink], ...defaultPreset.orders[Toolbar.markupMain]],
41+
},
42+
};
43+
44+
export const QuoteLink = memo(() => {
45+
const renderPreview = useCallback<RenderPreview>(
46+
({getValue, md}) => (
47+
<SplitModePreview
48+
getValue={getValue}
49+
allowHTML={md.html}
50+
linkify={md.linkify}
51+
linkifyTlds={md.linkifyTlds}
52+
breaks={md.breaks}
53+
needToSanitizeHtml
54+
plugins={plugins}
55+
/>
56+
),
57+
[],
58+
);
59+
60+
const editor = useMarkdownEditor({
61+
initial: {markup: ''},
62+
markupConfig: {renderPreview},
63+
wysiwygConfig: {
64+
extensions: QuoteLinkExtension,
65+
extensionOptions: {
66+
yfmConfigs: {
67+
attrs: {
68+
allowedAttributes: ['data-quotelink'],
69+
},
70+
},
71+
},
72+
},
73+
});
74+
75+
useLogs(editor.logger);
76+
77+
return (
78+
<PlaygroundLayout
79+
editor={editor}
80+
view={({className}) => (
81+
<MarkdownEditorView
82+
autofocus
83+
stickyToolbar
84+
settingsVisible
85+
editor={editor}
86+
className={className}
87+
toolbarsPreset={toolbarsPreset}
88+
/>
89+
)}
90+
/>
91+
);
92+
});
93+
94+
QuoteLink.displayName = 'GPT';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type {StoryObj} from '@storybook/react';
2+
3+
import {QuoteLink as component} from './QuoteLink';
4+
5+
export const Story: StoryObj<typeof component> = {};
6+
Story.storyName = 'QuoteLink';
7+
8+
export default {
9+
title: 'Experiments / QuoteLink',
10+
component,
11+
};

package-lock.json

+25
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+5
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@
222222
"@diplodoc/html-extension": "2.7.1",
223223
"@diplodoc/latex-extension": "1.0.3",
224224
"@diplodoc/mermaid-extension": "1.2.1",
225+
"@diplodoc/quote-link-extension": "0.0.0",
225226
"@diplodoc/tabs-extension": "^3.5.1",
226227
"@diplodoc/transform": "^4.43.0",
227228
"@gravity-ui/eslint-config": "3.3.0",
@@ -297,6 +298,9 @@
297298
"@diplodoc/mermaid-extension": {
298299
"optional": true
299300
},
301+
"@diplodoc/quote-link-extension": {
302+
"optional": true
303+
},
300304
"highlight.js": {
301305
"optional": true
302306
},
@@ -311,6 +315,7 @@
311315
"@diplodoc/html-extension": "^2.3.2",
312316
"@diplodoc/latex-extension": "^1.0.3",
313317
"@diplodoc/mermaid-extension": "^1.0.0",
318+
"@diplodoc/quote-link-extension": "^0.0.0",
314319
"@diplodoc/tabs-extension": "^3.5.1",
315320
"@diplodoc/transform": "^4.43.0",
316321
"@gravity-ui/uikit": "^7.1.0",

src/bundle/config/action-names.ts

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const names = [
4141
'orderedList',
4242
'paragraph',
4343
'quote',
44+
'quoteLink',
4445
'redo',
4546
'sinkListItem',
4647
'strike',

src/bundle/config/icons.ts

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
MonoIcon,
3030
NoteIcon,
3131
QuoteIcon,
32+
QuoteLinkIcon,
3233
RedoIcon,
3334
SinkIcon,
3435
StrikethroughIcon,
@@ -72,6 +73,7 @@ type Icon =
7273
| 'image'
7374
| 'table'
7475
| 'quote'
76+
| 'quoteLink'
7577
| 'checklist'
7678
| 'horizontalRule'
7779
| 'file'
@@ -125,6 +127,7 @@ export const icons: Icons = {
125127

126128
table: {data: TableIcon},
127129
quote: {data: QuoteIcon},
130+
quoteLink: {data: QuoteLinkIcon},
128131
checklist: {data: CheckListIcon},
129132

130133
html: {data: HtmlBlockIcon},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type {Command} from 'prosemirror-state';
2+
3+
import type {ExtensionDeps} from '#core';
4+
5+
import {addPlaceholder} from './descriptor';
6+
7+
export const addQuoteLinkPlaceholder =
8+
(deps: ExtensionDeps): Command =>
9+
(state, dispatch) => {
10+
dispatch?.(addPlaceholder(state.tr, deps).scrollIntoView());
11+
return true;
12+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type React from 'react';
2+
3+
import type {Transaction} from 'prosemirror-state';
4+
import {TextSelection} from 'prosemirror-state';
5+
import type {EditorView} from 'prosemirror-view';
6+
7+
import type {ExtensionDeps} from '#core';
8+
import {
9+
LinkAttr,
10+
ReactWidgetDescriptor,
11+
linkType,
12+
normalizeUrlFactory,
13+
pType,
14+
removeDecoration,
15+
} from 'src/extensions';
16+
import {
17+
LinkPlaceholderWidget,
18+
type LinkPlaceholderWidgetProps,
19+
} from 'src/extensions/markdown/Link/PlaceholderWidget/widget';
20+
import {isTextSelection} from 'src/utils';
21+
22+
export class QuoteLinkWidgetDescriptor extends ReactWidgetDescriptor {
23+
#domElem;
24+
#view?: EditorView;
25+
#getPos?: () => number;
26+
#schema?: ExtensionDeps['schema'];
27+
28+
private normalizeUrl;
29+
30+
constructor(initPos: number, deps: ExtensionDeps) {
31+
super(initPos, 'link-empty');
32+
this.#domElem = document.createElement('span');
33+
this.#schema = deps.schema;
34+
this.normalizeUrl = normalizeUrlFactory(deps);
35+
}
36+
37+
getDomElem(): HTMLElement {
38+
return this.#domElem;
39+
}
40+
41+
renderReactElement(view: EditorView, getPos: () => number): React.ReactElement {
42+
this.#view = view;
43+
this.#getPos = getPos;
44+
return <LinkPlaceholderWidget onCancel={this.onCancel} onSubmit={this.onSubmit} />;
45+
}
46+
47+
onCancel: LinkPlaceholderWidgetProps['onCancel'] = () => {
48+
if (!this.#view) return;
49+
50+
this.#view.dispatch(removeDecoration(this.#view.state.tr, this.id));
51+
this.#view.focus();
52+
};
53+
54+
onSubmit: LinkPlaceholderWidgetProps['onSubmit'] = (params) => {
55+
const normalizeResult = this.normalizeUrl(params.url);
56+
if (!normalizeResult || !this.#view || !this.#getPos) return;
57+
58+
let tr = this.#view.state.tr;
59+
60+
const {url} = normalizeResult;
61+
const text = params.text.trim() || normalizeResult.text;
62+
63+
const from = this.#getPos();
64+
const isAllSelected =
65+
from === 1 && (!isTextSelection(tr.selection) || !tr.selection.$cursor);
66+
const to = from + text.length + (isAllSelected ? 1 : 0);
67+
68+
tr = tr.insertText(text, from);
69+
tr = tr.addMark(
70+
from,
71+
to,
72+
linkType(this.#view.state.schema).create({
73+
[LinkAttr.Href]: url,
74+
[LinkAttr.DataQuoteLink]: true,
75+
}),
76+
);
77+
78+
tr = removeDecoration(tr, this.id);
79+
80+
tr = tr.insert(
81+
to,
82+
pType(this.#view.state.schema).create(null, text ? this.#schema?.text(text) : null),
83+
);
84+
tr.setSelection(TextSelection.create(tr.doc, to + 1 + text.length + 1));
85+
86+
this.#view.dispatch(tr);
87+
this.#view.focus();
88+
};
89+
}
90+
91+
export const addPlaceholder = (tr: Transaction, deps: ExtensionDeps) => {
92+
const isAllSelected =
93+
tr.selection.from === 0 && (!isTextSelection(tr.selection) || !tr.selection.$cursor);
94+
return new QuoteLinkWidgetDescriptor(tr.selection.from + (isAllSelected ? 1 : 0), deps).applyTo(
95+
tr,
96+
);
97+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {builders} from 'prosemirror-test-builder';
2+
import dd from 'ts-dedent';
3+
4+
import {ExtensionsManager} from '#core';
5+
import {LinkAttr, linkMarkName} from 'src/extensions';
6+
import {BlockquoteSpecs} from 'src/extensions/markdown/Blockquote/BlockquoteSpecs';
7+
import {LinkSpecs} from 'src/extensions/markdown/Link/LinkSpecs';
8+
import {YfmConfigsSpecs} from 'src/extensions/yfm/YfmConfigs/YfmConfigsSpecs';
9+
10+
import {parseDOM} from '../../../../tests/parse-dom';
11+
import {createMarkupChecker} from '../../../../tests/sameMarkup';
12+
import {BaseNode, BaseSchemaSpecs} from '../../base/specs';
13+
14+
import {QuoteLinkSpecs, quoteLinkNodeName} from './QuoteLinkSpecs';
15+
16+
const {
17+
schema,
18+
markupParser: parser,
19+
serializer,
20+
} = new ExtensionsManager({
21+
extensions: (builder) =>
22+
builder
23+
.use(BaseSchemaSpecs, {})
24+
.use(YfmConfigsSpecs, {attrs: {allowedAttributes: ['data-quotelink']}})
25+
.use(BlockquoteSpecs)
26+
.use(LinkSpecs)
27+
.use(QuoteLinkSpecs),
28+
}).buildDeps();
29+
30+
const {doc, p, q, a} = builders<'doc' | 'p' | 'q' | 'a'>(schema, {
31+
doc: {nodeType: BaseNode.Doc},
32+
p: {nodeType: BaseNode.Paragraph},
33+
q: {nodeType: quoteLinkNodeName},
34+
a: {nodeType: linkMarkName, [LinkAttr.Href]: 'https://ya.ru', [LinkAttr.DataQuoteLink]: 'true'},
35+
});
36+
37+
const {same} = createMarkupChecker({parser, serializer});
38+
39+
describe('QuoteLink extension', () => {
40+
it('should parse a quote link', () =>
41+
same(
42+
dd`
43+
> [Quote link](https://ya.ru){data-quotelink=true}
44+
>
45+
> quote link text
46+
`,
47+
doc(q(p(a('Quote link')), p('quote link text'))),
48+
));
49+
50+
it('should parse html - blockquote tag with quote link class', () => {
51+
parseDOM(
52+
schema,
53+
dd`<div>
54+
<blockquote class="yfm-quote-link">
55+
<p>
56+
<a href="https://ya.ru" data-quotelink="true">Quote link</a>
57+
</p>
58+
<p>quote link text</p>
59+
</blockquote>
60+
</div>`,
61+
doc(q(p(a('Quote link')), p('quote link text'))),
62+
);
63+
});
64+
});

0 commit comments

Comments
 (0)