Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions demo/stories/quoteLink/QuoteLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {memo, useCallback} from 'react';

import {transform as quoteLink} from '@diplodoc/quote-link-extension';
import type {PluginWithParams} from 'markdown-it/lib';

import {ActionName as Action} from 'src/bundle/config/action-names';
import {QuoteLink as QuoteLinkExtension} from 'src/extensions/additional/QuoteLink';
import {
MarkdownEditorView,
type RenderPreview,
type ToolbarsPreset,
useMarkdownEditor,
} from 'src/index';
import {ToolbarName as Toolbar} from 'src/modules/toolbars/constants';
import {
quoteLinkItemMarkup,
quoteLinkItemView,
quoteLinkItemWysiwyg,
} from 'src/modules/toolbars/items';
import {defaultPreset} from 'src/modules/toolbars/presets';

import {PlaygroundLayout} from '../../components/PlaygroundLayout';
import {SplitModePreview} from '../../components/SplitModePreview';
import {plugins as defaultPlugins} from '../../defaults/md-plugins';
import {useLogs} from '../../hooks/useLogs';

const plugins: PluginWithParams[] = [...defaultPlugins, quoteLink({bundle: false})];

const toolbarsPreset: ToolbarsPreset = {
items: {
...defaultPreset.items,
[Action.quoteLink]: {
view: quoteLinkItemView,
wysiwyg: quoteLinkItemWysiwyg,
markup: quoteLinkItemMarkup,
},
},
orders: {
[Toolbar.wysiwygMain]: [[Action.quoteLink], ...defaultPreset.orders[Toolbar.wysiwygMain]],
[Toolbar.markupMain]: [[Action.quoteLink], ...defaultPreset.orders[Toolbar.markupMain]],
},
};

export const QuoteLink = memo(() => {
const renderPreview = useCallback<RenderPreview>(
({getValue, md}) => (
<SplitModePreview
getValue={getValue}
allowHTML={md.html}
linkify={md.linkify}
linkifyTlds={md.linkifyTlds}
breaks={md.breaks}
needToSanitizeHtml
plugins={plugins}
/>
),
[],
);

const editor = useMarkdownEditor({
initial: {markup: ''},
markupConfig: {renderPreview},
wysiwygConfig: {
extensions: QuoteLinkExtension,
extensionOptions: {
yfmConfigs: {
attrs: {
allowedAttributes: ['data-quotelink'],
},
},
},
},
});

useLogs(editor.logger);

return (
<PlaygroundLayout
editor={editor}
view={({className}) => (
<MarkdownEditorView
autofocus
stickyToolbar
settingsVisible
editor={editor}
className={className}
toolbarsPreset={toolbarsPreset}
/>
)}
/>
);
});

QuoteLink.displayName = 'GPT';
11 changes: 11 additions & 0 deletions demo/stories/quoteLink/quoteLink.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type {StoryObj} from '@storybook/react';

import {QuoteLink as component} from './QuoteLink';

export const Story: StoryObj<typeof component> = {};
Story.storyName = 'QuoteLink';

export default {
title: 'Extensions / YFM / QuoteLink',
component,
};
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@
"@diplodoc/html-extension": "2.7.1",
"@diplodoc/latex-extension": "1.0.3",
"@diplodoc/mermaid-extension": "1.2.1",
"@diplodoc/quote-link-extension": "0.0.0",
"@diplodoc/tabs-extension": "^3.5.1",
"@diplodoc/transform": "^4.43.0",
"@gravity-ui/eslint-config": "3.3.0",
Expand Down Expand Up @@ -298,6 +299,9 @@
"@diplodoc/mermaid-extension": {
"optional": true
},
"@diplodoc/quote-link-extension": {
"optional": true
},
"highlight.js": {
"optional": true
},
Expand All @@ -312,6 +316,7 @@
"@diplodoc/html-extension": "^2.3.2",
"@diplodoc/latex-extension": "^1.0.3",
"@diplodoc/mermaid-extension": "^1.0.0",
"@diplodoc/quote-link-extension": "^0.0.0",
"@diplodoc/tabs-extension": "^3.5.1",
"@diplodoc/transform": "^4.43.0",
"@gravity-ui/uikit": "^7.1.0",
Expand Down
1 change: 1 addition & 0 deletions src/bundle/config/action-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const names = [
'orderedList',
'paragraph',
'quote',
'quoteLink',
'redo',
'sinkListItem',
'strike',
Expand Down
3 changes: 3 additions & 0 deletions src/bundle/config/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
MonoIcon,
NoteIcon,
QuoteIcon,
QuoteLinkIcon,
RedoIcon,
SinkIcon,
StrikethroughIcon,
Expand Down Expand Up @@ -72,6 +73,7 @@ type Icon =
| 'image'
| 'table'
| 'quote'
| 'quoteLink'
| 'checklist'
| 'horizontalRule'
| 'file'
Expand Down Expand Up @@ -125,6 +127,7 @@ export const icons: Icons = {

table: {data: TableIcon},
quote: {data: QuoteIcon},
quoteLink: {data: QuoteLinkIcon},
checklist: {data: CheckListIcon},

html: {data: HtmlBlockIcon},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {Command} from 'prosemirror-state';

import type {ExtensionDeps} from '#core';

import {addPlaceholder} from './descriptor';

export const addQuoteLinkPlaceholder =
(deps: ExtensionDeps): Command =>
(state, dispatch) => {
dispatch?.(addPlaceholder(state.tr, deps).scrollIntoView());
return true;
};
106 changes: 106 additions & 0 deletions src/extensions/additional/QuoteLink/PlaceholderWidget/descriptor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type React from 'react';

import type {Fragment, Node} from 'prosemirror-model';
import type {Transaction} from 'prosemirror-state';
import {TextSelection} from 'prosemirror-state';
import {findParentNodeOfType, findParentNodeOfTypeClosestToPos} from 'prosemirror-utils';
import type {EditorView} from 'prosemirror-view';

import type {ExtensionDeps} from '#core';
import {ReactWidgetDescriptor, normalizeUrlFactory, pType, removeDecoration} from 'src/extensions';
import {QuoteLinkAttr, quoteLinkType} from 'src/extensions/additional/QuoteLink/QuoteLinkSpecs';
import {
LinkPlaceholderWidget,
type LinkPlaceholderWidgetProps,
} from 'src/extensions/markdown/Link/PlaceholderWidget/widget';
import {isTextSelection} from 'src/utils';

export class QuoteLinkWidgetDescriptor extends ReactWidgetDescriptor {
#domElem;
#view?: EditorView;
#getPos?: () => number;
#schema?: ExtensionDeps['schema'];

private normalizeUrl;

constructor(initPos: number, deps: ExtensionDeps) {
super(initPos, 'quoteLink');
this.#domElem = document.createElement('span');
this.#schema = deps.schema;
this.normalizeUrl = normalizeUrlFactory(deps);
}

getDomElem(): HTMLElement {
return this.#domElem;
}

renderReactElement(view: EditorView, getPos: () => number): React.ReactElement {
this.#view = view;
this.#getPos = getPos;
return <LinkPlaceholderWidget onCancel={this.onCancel} onSubmit={this.onSubmit} />;
}

onCancel: LinkPlaceholderWidgetProps['onCancel'] = () => {
if (!this.#view) return;

this.#view.dispatch(removeDecoration(this.#view.state.tr, this.id));
this.#view.focus();
};

onSubmit: LinkPlaceholderWidgetProps['onSubmit'] = (params) => {
const normalizeResult = this.normalizeUrl(params.url);
if (!normalizeResult || !this.#view || !this.#getPos) return;

let tr = this.#view.state.tr;

const {url} = normalizeResult;
const text = params.text.trim() || normalizeResult.text;

const from = this.#getPos();
const isAllSelected =
from === 1 && (!isTextSelection(tr.selection) || !tr.selection.$cursor);

const currentNodeWithPos = isAllSelected
? findParentNodeOfTypeClosestToPos(
this.#view.state.doc.resolve(4),
quoteLinkType(this.#view.state.schema),
)
: findParentNodeOfType(quoteLinkType(this.#view.state.schema))(
this.#view.state.selection,
);

if (currentNodeWithPos) {
let content: Fragment | Node | undefined = currentNodeWithPos.node.content;
let contentSize = currentNodeWithPos.node.nodeSize - 4;

if (currentNodeWithPos.node.nodeSize <= 4 && text) {
content = pType(this.#view.state.schema).create(null, this.#schema?.text(text));
contentSize = text.length;
}

tr = tr.replaceWith(
currentNodeWithPos.pos,
currentNodeWithPos.pos + currentNodeWithPos.node.nodeSize,
quoteLinkType(this.#view.state.schema).create(
{
[QuoteLinkAttr.Cite]: url,
[QuoteLinkAttr.DataContent]: text,
},
content,
),
);

tr.setSelection(TextSelection.create(tr.doc, from + contentSize));
}

this.#view.dispatch(tr);
};
}

export const addPlaceholder = (tr: Transaction, deps: ExtensionDeps) => {
const isAllSelected =
tr.selection.from === 0 && (!isTextSelection(tr.selection) || !tr.selection.$cursor);
return new QuoteLinkWidgetDescriptor(tr.selection.from + (isAllSelected ? 1 : 0), deps).applyTo(
tr,
);
};
61 changes: 61 additions & 0 deletions src/extensions/additional/QuoteLink/QuoteLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {builders} from 'prosemirror-test-builder';
import dd from 'ts-dedent';

import {ExtensionsManager} from '#core';
import {BlockquoteSpecs} from 'src/extensions/markdown/Blockquote/BlockquoteSpecs';
import {YfmConfigsSpecs} from 'src/extensions/yfm/YfmConfigs/YfmConfigsSpecs';

import {parseDOM} from '../../../../tests/parse-dom';
import {createMarkupChecker} from '../../../../tests/sameMarkup';
import {BaseNode, BaseSchemaSpecs} from '../../base/specs';

import {QuoteLinkAttr, QuoteLinkSpecs, quoteLinkNodeName} from './QuoteLinkSpecs';

const {
schema,
markupParser: parser,
serializer,
} = new ExtensionsManager({
extensions: (builder) =>
builder
.use(BaseSchemaSpecs, {})
.use(YfmConfigsSpecs, {attrs: {allowedAttributes: ['data-quotelink', 'data-content']}})
.use(BlockquoteSpecs)
.use(QuoteLinkSpecs),
}).buildDeps();

const {doc, p, quoteLink} = builders<'doc' | 'p' | 'quoteLink'>(schema, {
doc: {nodeType: BaseNode.Doc},
p: {nodeType: BaseNode.Paragraph},
quoteLink: {
nodeType: quoteLinkNodeName,
[QuoteLinkAttr.Cite]: 'https://ya.ru',
[QuoteLinkAttr.DataContent]: 'Quote link',
},
});

const {same} = createMarkupChecker({parser, serializer});

describe('QuoteLink extension', () => {
it('should parse a quote link', () =>
same(
dd`
> [Quote link](https://ya.ru){data-quotelink=true}
>${' '}
> quote link text
`,
doc(quoteLink(p('quote link text'))),
));

it('should parse html - blockquote tag with quote link class', () => {
parseDOM(
schema,
dd`<div>
<blockquote class="yfm-quote-link" cite="https://ya.ru" data-content="Quote link">
<p>quote link text</p>
</blockquote>
</div>`,
doc(quoteLink(p('quote link text'))),
);
});
});
Loading
Loading