diff --git a/demo/stories/quoteLink/QuoteLink.tsx b/demo/stories/quoteLink/QuoteLink.tsx new file mode 100644 index 000000000..dec3be7eb --- /dev/null +++ b/demo/stories/quoteLink/QuoteLink.tsx @@ -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( + ({getValue, md}) => ( + + ), + [], + ); + + const editor = useMarkdownEditor({ + initial: {markup: ''}, + markupConfig: {renderPreview}, + wysiwygConfig: { + extensions: QuoteLinkExtension, + extensionOptions: { + yfmConfigs: { + attrs: { + allowedAttributes: ['data-quotelink'], + }, + }, + }, + }, + }); + + useLogs(editor.logger); + + return ( + ( + + )} + /> + ); +}); + +QuoteLink.displayName = 'GPT'; diff --git a/demo/stories/quoteLink/quoteLink.stories.ts b/demo/stories/quoteLink/quoteLink.stories.ts new file mode 100644 index 000000000..dca0230d7 --- /dev/null +++ b/demo/stories/quoteLink/quoteLink.stories.ts @@ -0,0 +1,11 @@ +import type {StoryObj} from '@storybook/react'; + +import {QuoteLink as component} from './QuoteLink'; + +export const Story: StoryObj = {}; +Story.storyName = 'QuoteLink'; + +export default { + title: 'Extensions / YFM / QuoteLink', + component, +}; diff --git a/package-lock.json b/package-lock.json index 1f40efe55..8016e6b0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,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", @@ -129,6 +130,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", @@ -2746,6 +2748,29 @@ } } }, + "node_modules/@diplodoc/quote-link-extension": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@diplodoc/quote-link-extension/-/quote-link-extension-0.0.0.tgz", + "integrity": "sha512-Q212JC3UqqCJHJysj5Ta73K1is1jNo+qAybng04uo3ZLtIdSeHHMkiTSnQ6TUpAqr5zmrwPiB0mz3ZQAh+5bng==", + "dev": true, + "dependencies": { + "@diplodoc/utils": "^2.0.1" + } + }, + "node_modules/@diplodoc/quote-link-extension/node_modules/@diplodoc/utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@diplodoc/utils/-/utils-2.0.1.tgz", + "integrity": "sha512-BmEpoWG2fzaBlbS0l7o/nYc4Ww9QXeQGzHM6fTFk6gPV0PRl55aBxMx+60VZn7rDhiKwAQX97yTElBzoznTt4g==", + "dev": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@diplodoc/tabs-extension": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/@diplodoc/tabs-extension/-/tabs-extension-3.5.1.tgz", diff --git a/package.json b/package.json index 0689acd0c..1d23c3965 100644 --- a/package.json +++ b/package.json @@ -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", @@ -298,6 +299,9 @@ "@diplodoc/mermaid-extension": { "optional": true }, + "@diplodoc/quote-link-extension": { + "optional": true + }, "highlight.js": { "optional": true }, @@ -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", diff --git a/src/bundle/config/action-names.ts b/src/bundle/config/action-names.ts index cb4ab8272..d97a6b7a5 100644 --- a/src/bundle/config/action-names.ts +++ b/src/bundle/config/action-names.ts @@ -41,6 +41,7 @@ const names = [ 'orderedList', 'paragraph', 'quote', + 'quoteLink', 'redo', 'sinkListItem', 'strike', diff --git a/src/bundle/config/icons.ts b/src/bundle/config/icons.ts index 10082111f..6d9d384f3 100644 --- a/src/bundle/config/icons.ts +++ b/src/bundle/config/icons.ts @@ -29,6 +29,7 @@ import { MonoIcon, NoteIcon, QuoteIcon, + QuoteLinkIcon, RedoIcon, SinkIcon, StrikethroughIcon, @@ -72,6 +73,7 @@ type Icon = | 'image' | 'table' | 'quote' + | 'quoteLink' | 'checklist' | 'horizontalRule' | 'file' @@ -125,6 +127,7 @@ export const icons: Icons = { table: {data: TableIcon}, quote: {data: QuoteIcon}, + quoteLink: {data: QuoteLinkIcon}, checklist: {data: CheckListIcon}, html: {data: HtmlBlockIcon}, diff --git a/src/extensions/additional/QuoteLink/PlaceholderWidget/commands.ts b/src/extensions/additional/QuoteLink/PlaceholderWidget/commands.ts new file mode 100644 index 000000000..7e371711b --- /dev/null +++ b/src/extensions/additional/QuoteLink/PlaceholderWidget/commands.ts @@ -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; + }; diff --git a/src/extensions/additional/QuoteLink/PlaceholderWidget/descriptor.tsx b/src/extensions/additional/QuoteLink/PlaceholderWidget/descriptor.tsx new file mode 100644 index 000000000..cba450fec --- /dev/null +++ b/src/extensions/additional/QuoteLink/PlaceholderWidget/descriptor.tsx @@ -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 ; + } + + 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, + ); +}; diff --git a/src/extensions/additional/QuoteLink/QuoteLink.test.ts b/src/extensions/additional/QuoteLink/QuoteLink.test.ts new file mode 100644 index 000000000..9360dafd2 --- /dev/null +++ b/src/extensions/additional/QuoteLink/QuoteLink.test.ts @@ -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`
+ +
`, + doc(quoteLink(p('quote link text'))), + ); + }); +}); diff --git a/src/extensions/additional/QuoteLink/QuoteLinkSpecs/index.ts b/src/extensions/additional/QuoteLink/QuoteLinkSpecs/index.ts new file mode 100644 index 000000000..2128495ae --- /dev/null +++ b/src/extensions/additional/QuoteLink/QuoteLinkSpecs/index.ts @@ -0,0 +1,83 @@ +import {transform as quoteLink} from '@diplodoc/quote-link-extension'; +import type {Node} from 'prosemirror-model'; + +import type {ExtensionAuto} from '#core'; +import {nodeTypeFactory} from 'src/utils'; + +import {moveLinkToQuoteAttributes} from './md/moveLinkToQuoteAttributes'; + +export const quoteLinkNodeName = 'yfm_quote-link'; +export const quoteLinkType = nodeTypeFactory(quoteLinkNodeName); +export const isQuoteLinkNode = (node: Node) => node.type.name === quoteLinkNodeName; + +export enum QuoteLinkAttr { + Cite = 'cite', + DataContent = 'data-content', +} + +export const QuoteLinkSpecs: ExtensionAuto = (builder) => { + builder + .configureMd((md) => + md + .use( + quoteLink({ + bundle: false, + }), + ) + .use(moveLinkToQuoteAttributes), + ) + .addNode(quoteLinkNodeName, () => ({ + spec: { + attrs: { + class: {default: 'yfm-quote-link'}, + [QuoteLinkAttr.Cite]: {default: ''}, + [QuoteLinkAttr.DataContent]: {default: ''}, + }, + content: 'block+', + group: 'block', + defining: true, + parseDOM: [ + { + tag: '.yfm-quote-link', + getAttrs(dom) { + return { + [QuoteLinkAttr.Cite]: (dom as Element).getAttribute( + QuoteLinkAttr.Cite, + ), + [QuoteLinkAttr.DataContent]: (dom as Element).getAttribute( + QuoteLinkAttr.DataContent, + ), + }; + }, + priority: builder.Priority.VeryHigh, + }, + ], + toDOM(node) { + return ['blockquote', node.attrs, 0]; + }, + selectable: true, + }, + fromMd: { + tokenSpec: { + name: quoteLinkNodeName, + type: 'block', + getAttrs: (tok) => ({ + [QuoteLinkAttr.Cite]: tok.attrGet('cite'), + [QuoteLinkAttr.DataContent]: tok.attrGet('data-content') || null, + }), + }, + }, + toMd: (state, node) => { + state.wrapBlock('> ', null, node, () => { + state.write( + `[${node.attrs[QuoteLinkAttr.DataContent]}](${ + node.attrs[QuoteLinkAttr.Cite] + }){data-quotelink=true}`, + ); + state.write('\n'); + state.write('\n'); + state.renderContent(node); + }); + }, + })); +}; diff --git a/src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/moveLinkToQuoteAttributes.ts b/src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/moveLinkToQuoteAttributes.ts new file mode 100644 index 000000000..2e2c30f1d --- /dev/null +++ b/src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/moveLinkToQuoteAttributes.ts @@ -0,0 +1,46 @@ +import type {PluginSimple} from 'markdown-it'; + +import {matchLinkAtInlineStart} from 'src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/utils'; + +import {QuoteLinkAttr} from '..'; + +export const moveLinkToQuoteAttributes: PluginSimple = (md) => { + md.core.ruler.push('move-link-to-quote-attributes', (state) => { + const {tokens} = state; + + let i = 0; + + while (i < tokens.length) { + const token = tokens[i]; + if (token.type === 'yfm_quote-link_open') { + const inlineToken = tokens[i + 2]; + const linkMatch = matchLinkAtInlineStart(inlineToken); + + if ( + linkMatch?.openToken.attrIndex('data-quotelink') !== -1 && + linkMatch?.closeTokenIndex + ) { + token.attrSet(QuoteLinkAttr.Cite, linkMatch?.openToken.attrGet('href') ?? ''); + const content = inlineToken.children + ?.slice(1, linkMatch.closeTokenIndex) + .reduce((result, item) => result + item.content, '') + .trim(); + if (content) { + token.attrSet(QuoteLinkAttr.DataContent, content); + } + + if (linkMatch.closeTokenIndex === (inlineToken.children?.length ?? 0) - 1) { + tokens.splice(i + 1, 3); + } else { + inlineToken.children?.splice(0, linkMatch.closeTokenIndex + 1); + if (inlineToken.children?.every((childToken) => !childToken.content)) { + tokens.splice(i + 1, 3); + } + } + } + } + + i++; + } + }); +}; diff --git a/src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/utils.ts b/src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/utils.ts new file mode 100644 index 000000000..4e614a48d --- /dev/null +++ b/src/extensions/additional/QuoteLink/QuoteLinkSpecs/md/utils.ts @@ -0,0 +1,25 @@ +import type Token from 'markdown-it/lib/token'; + +export function matchLinkAtInlineStart(inlineToken: Token) { + if (inlineToken.type !== 'inline' || !inlineToken.children?.length) { + return null; + } + + const {children: tokens} = inlineToken; + if (tokens[0].type !== 'link_open') { + return null; + } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.type === 'link_close') { + return { + openToken: tokens[0], + closeToken: token, + closeTokenIndex: i, + }; + } + } + + return null; +} diff --git a/src/extensions/additional/QuoteLink/commands.ts b/src/extensions/additional/QuoteLink/commands.ts new file mode 100644 index 000000000..57cf2255a --- /dev/null +++ b/src/extensions/additional/QuoteLink/commands.ts @@ -0,0 +1,48 @@ +import {wrapIn} from 'prosemirror-commands'; +import type {ResolvedPos} from 'prosemirror-model'; +import type {Command} from 'prosemirror-state'; + +import {isTextSelection} from 'src/utils'; + +import '../../../types/spec'; + +import {addQuoteLinkPlaceholder} from './PlaceholderWidget/commands'; +import {isQuoteLinkNode, quoteLinkType} from './QuoteLinkSpecs'; + +export const toggleQuote: Command = (state, dispatch) => { + const {selection} = state; + const qType = quoteLinkType(state.schema); + if (!isTextSelection(selection) || !selection.$cursor) return wrapIn(qType)(state, dispatch); + const {$cursor} = selection; + let {depth} = $cursor; + while (depth > 0) { + const node = $cursor.node(depth); + const nodeSpec = node.type.spec; + if (!nodeSpec.complex || nodeSpec.complex === 'root') { + const targetDepth = depth - 1; + const range = getBlockRange($cursor, depth); + let tr = state.tr; + if (isQuoteLinkNode($cursor.node(targetDepth))) { + tr = tr.lift(range!, targetDepth - 1).scrollIntoView(); + dispatch?.(tr); + } else { + tr = tr.wrap(range!, [{type: qType}]).scrollIntoView(); + dispatch?.(tr); + } + + return true; + } + depth--; + } + + return false; +}; + +function getBlockRange($pos: ResolvedPos, depth?: number) { + const {doc} = $pos; + const $before = doc.resolve($pos.before(depth)); + const $after = doc.resolve($pos.after(depth)); + return $before.blockRange($after); +} + +export const addQuoteLink = addQuoteLinkPlaceholder; diff --git a/src/extensions/additional/QuoteLink/index.scss b/src/extensions/additional/QuoteLink/index.scss new file mode 100644 index 000000000..684202d40 --- /dev/null +++ b/src/extensions/additional/QuoteLink/index.scss @@ -0,0 +1,48 @@ +.yfm-quote-link { + --yfm-quote-link-background: var(--g-color-private-black-50-solid); + --yfm-quote-link-background-hover: var(--g-color-base-simple-hover); + + // TODO: remove this after the plugin package update + * { + position: relative; + z-index: 1; + } +} + +.g-root_theme_dark, +.g-root_theme_dark-hc { + .yfm-quote-link { + --yfm-quote-link-background: var(--g-color-private-white-100-solid); + } +} + +.yfm-cut:not(.yfm-cut-active) .yfm-cut-content:has(> .yfm-quote-link) { + padding: 0; + + > .yfm-quote-link { + padding-top: var(--g-spacing-2); + padding-left: var(--g-spacing-7); + } +} + +.yfm-editor { + .yfm-quote-link { + background-color: var(--yfm-quote-link-background); + + // return the image settings button and clipboard button positions + .g-md-img-settings-button, + .yfm-clipboard-button { + position: absolute; + z-index: 2; + } + + // return the button icon position + .g-button__icon-inner { + position: absolute; + } + + p { + max-width: unset; + } + } +} diff --git a/src/extensions/additional/QuoteLink/index.ts b/src/extensions/additional/QuoteLink/index.ts new file mode 100644 index 000000000..5fe6f3109 --- /dev/null +++ b/src/extensions/additional/QuoteLink/index.ts @@ -0,0 +1,58 @@ +import '@diplodoc/quote-link-extension/runtime'; +import type {NodeType} from 'prosemirror-model'; +import {hasParentNodeOfType} from 'prosemirror-utils'; + +import type {Action, ExtensionAuto} from '#core'; +import {linkType} from 'src/extensions'; +import {isMarkActive, wrappingInputRule} from 'src/utils'; + +import {QuoteLinkSpecs, quoteLinkType} from './QuoteLinkSpecs'; +import {addQuoteLink, toggleQuote} from './commands'; + +import './index.scss'; +import '@diplodoc/quote-link-extension/runtime/styles.css'; + +const quoteLinkAction = 'quoteLink'; + +const addLinkToQuoteLinkAction = 'addLinkToQuoteLink'; + +export const QuoteLink: ExtensionAuto = (builder) => { + builder.use(QuoteLinkSpecs); + + builder.addInputRules(({schema}) => ({ + rules: [quoteLinkInputRule(quoteLinkType(schema))], + })); + + builder.addAction(quoteLinkAction, ({schema}) => { + const quoteLink = quoteLinkType(schema); + + return { + isActive: (state) => hasParentNodeOfType(quoteLink)(state.selection), + isEnable: toggleQuote, + run: toggleQuote, + }; + }); + + builder.addAction(addLinkToQuoteLinkAction, (deps) => ({ + isActive: (state) => Boolean(isMarkActive(state, linkType(state.schema))), + isEnable: addQuoteLink(deps), + run: addQuoteLink(deps), + })); +}; + +/** + * Given a quoteLink node type, returns an input rule that turns `"> "` + * at the start of a textblock into a quoteLink. + */ +function quoteLinkInputRule(nodeType: NodeType) { + return wrappingInputRule(/^\s*>\s$/, nodeType); +} + +declare global { + namespace WysiwygEditor { + interface Actions { + [quoteLinkAction]: Action; + [addLinkToQuoteLinkAction]: Action; + } + } +} diff --git a/src/i18n/menubar/en.json b/src/i18n/menubar/en.json index 7f15a7f5d..12916ac4f 100644 --- a/src/i18n/menubar/en.json +++ b/src/i18n/menubar/en.json @@ -48,6 +48,7 @@ "note": "Note", "olist": "Ordered list", "quote": "Quote", + "quotelink": "Quote link", "redo": "Redo", "strike": "Strikethrough", "table": "Table", diff --git a/src/i18n/menubar/ru.json b/src/i18n/menubar/ru.json index 378bfac08..3cfad3b8c 100644 --- a/src/i18n/menubar/ru.json +++ b/src/i18n/menubar/ru.json @@ -48,6 +48,7 @@ "note": "Примечание", "olist": "Нумерованный список", "quote": "Цитата", + "quotelink": "Цитата-ссылка", "redo": "Повторить", "strike": "Зачеркивание", "table": "Таблица", diff --git a/src/icons/index.ts b/src/icons/index.ts index 61c7cb6e3..24031ea56 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -61,4 +61,5 @@ export { Pencil as DrawIoIcon, FolderCode as HtmlBlockIcon, ArrowChevronRight as FoldingHeadingIcon, + CircleLink as QuoteLinkIcon, } from '@gravity-ui/icons'; diff --git a/src/markup/commands/blocks.ts b/src/markup/commands/blocks.ts index 932c031ab..f3d1378b7 100644 --- a/src/markup/commands/blocks.ts +++ b/src/markup/commands/blocks.ts @@ -3,6 +3,13 @@ import type {StateCommand} from '@codemirror/state'; import {replaceOrInsertAfter, wrapPerLine} from './helpers'; export const wrapToBlockquote = wrapPerLine({beforeText: '> ', skipEmptyLine: false}); + +export const insertBlockquoteLink: StateCommand = ({state, dispatch}) => { + const markup = '> [link](url "title"){data-quotelink=true}\n> \n> '; + const tr = replaceOrInsertAfter(state, markup); + dispatch(state.update(tr)); + return true; +}; export const wrapToCheckbox = wrapPerLine({beforeText: '[ ] '}); export const insertHRule: StateCommand = ({state, dispatch}) => { diff --git a/src/modules/toolbars/items.tsx b/src/modules/toolbars/items.tsx index ec4e81896..872f7c57e 100644 --- a/src/modules/toolbars/items.tsx +++ b/src/modules/toolbars/items.tsx @@ -11,6 +11,7 @@ import {headingType, pType} from '../../extensions/specs'; import {i18n as i18nHint} from '../../i18n/hints'; import {i18n} from '../../i18n/menubar'; import { + insertBlockquoteLink, insertHRule, insertLink, insertMermaidDiagram, @@ -255,6 +256,26 @@ export const quoteItemMarkup: ToolbarItemMarkup = { isEnable: enable, }; +// ---- Quote Link ---- +export const quoteLinkItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'quotelink'), + icon: icons.quoteLink, +}; +export const quoteLinkItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => { + e.actions.quoteLink.run(); + e.actions.addLinkToQuoteLink.run(); + }, + isActive: (e) => e.actions.quoteLink.isActive(), + isEnable: (e) => e.actions.quoteLink.isEnable(), +}; +export const quoteLinkItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertBlockquoteLink(e.cm), + isActive: inactive, + isEnable: enable, +}; + // ---- Cut ---- export const cutItemView: ToolbarItemView = { type: ToolbarDataType.SingleButton,