diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/LinkTransformSuggest.ts b/src/extensions/markdown/Link/LinkTransformSuggest/LinkTransformSuggest.ts new file mode 100644 index 00000000..a4f2d725 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/LinkTransformSuggest.ts @@ -0,0 +1,38 @@ +import type {Extension} from '#core'; + +import {LinkSuggestHandler} from './handler'; +import {linkTransformSuggest} from './plugin'; +import type {SuggestItem} from './types'; + +type SuggestStorage = Map; + +export const LinkTransformSuggest: Extension = (builder) => { + builder.context.set('linkTransformSuggestConfig', new Map()); + + builder.addPlugin(() => { + const storage = builder.context.get('linkTransformSuggestConfig'); + if (!storage?.size) return []; + + const items = Array.from(storage.values()).sort( + (a, b) => (a.priority || 0) - (b.priority || 0), + ); + + return linkTransformSuggest({ + createHandler: (view) => + new LinkSuggestHandler(view, { + items, + logger: builder.logger.nested({ + module: 'link-transform-suggest', + }), + }), + }); + }); +}; + +declare global { + namespace WysiwygEditor { + interface Context { + linkTransformSuggestConfig: SuggestStorage; + } + } +} diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/handler.ts b/src/extensions/markdown/Link/LinkTransformSuggest/handler.ts new file mode 100644 index 00000000..2ff7b237 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/handler.ts @@ -0,0 +1,125 @@ +import type {EditorView} from '#pm/view'; +import {getReactRendererFromState} from 'src/extensions/behavior/ReactRenderer'; +import type {Logger2} from 'src/logger'; +import {ArrayCarousel} from 'src/utils/carousel'; + +import {DEFAULT_DECORATION_CLASS_NAME, SuggestAction, type SuggestHandler} from './plugin'; +import {renderPopup} from './react-components'; +import type {SuggestItem} from './types'; + +type SuggestOpenState = { + url: string; + carousel: ArrayCarousel; +}; + +export type LinkSuggestHandlerParams = { + logger: Logger2.ILogger; + items: readonly SuggestItem[]; +}; + +export class LinkSuggestHandler implements SuggestHandler { + readonly #view: EditorView; + readonly #logger: Logger2.ILogger; + readonly #items: readonly SuggestItem[]; + readonly #renderItem; + + #state: SuggestOpenState | null = null; + #anchor: Element | null = null; + + constructor(view: EditorView, {logger, items}: LinkSuggestHandlerParams) { + this.#view = view; + this.#items = items; + this.#logger = logger; + + this.#renderItem = getReactRendererFromState(view.state).createItem( + 'link-transform-suggest', + () => + this.#state + ? renderPopup({ + anchorElement: this.#anchor, + items: this.#state.carousel.array, + currentIndex: this.#state.carousel.currentIndex, + }) + : null, + ); + } + + open(): void { + const url = ''; + + this._initState(url); + if (!this._validateAvailableItems()) { + SuggestAction.closeSuggest(this.#view.state, this.#view.dispatch); + return; + } + + this._findAnchor(); + this.#renderItem.rerender(); + } + + close(): void { + this.#state = null; + this.#anchor = null; + this.#renderItem.rerender(); + } + + update(): void { + const url = ''; + + if (url !== this.#state?.url) { + this._initState(url); + if (!this._validateAvailableItems()) { + SuggestAction.closeSuggest(this.#view.state, this.#view.dispatch); + return; + } + } + + this._findAnchor(); + this.#renderItem.rerender(); + } + + destroy(): void { + this.#state = null; + this.#anchor = null; + this.#renderItem.remove(); + } + + onEscape(): boolean { + throw new Error('Method not implemented.'); + } + + onEnter(): boolean { + throw new Error('Method not implemented.'); + } + + onUp(): boolean { + throw new Error('Method not implemented.'); + } + + onDown(): boolean { + throw new Error('Method not implemented.'); + } + + private _findAnchor() { + const {dom} = this.#view; + this.#anchor = dom.getElementsByClassName(DEFAULT_DECORATION_CLASS_NAME).item(0); + } + + private _initState(url: string) { + this.#state = null; + + const carousel = new ArrayCarousel(this.#items.filter((item) => item.testUrl(url))); + if (carousel.currentIndex === -1) return; + + this.#state = {url, carousel}; + } + + private _validateAvailableItems(): boolean { + const state = this.#state; + if (!state) return false; + + if (state.carousel.array.length === 1 && state.carousel.array[0].id === 'url') return false; + + return true; + } +} diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/index.ts b/src/extensions/markdown/Link/LinkTransformSuggest/index.ts new file mode 100644 index 00000000..3ade1859 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/index.ts @@ -0,0 +1 @@ +export * from './LinkTransformSuggest'; diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/plugin.ts b/src/extensions/markdown/Link/LinkTransformSuggest/plugin.ts new file mode 100644 index 00000000..a0028f49 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/plugin.ts @@ -0,0 +1,166 @@ +import {type Command, Plugin, PluginKey, type Transaction} from 'prosemirror-state'; + +import {Decoration, type DecorationAttrs, DecorationSet, type EditorView} from '#pm/view'; + +export const DEFAULT_DECORATION_CLASS_NAME = 'link-transform-suggest-deco'; + +const key = new PluginKey('link-transform-suggest'); + +const appendTr = { + open(tr: Transaction, data: Omit): Transaction { + const meta: OpenSuggestTrMeta = {...data, action: 'open'}; + return tr.setMeta(key, meta); + }, + close(tr: Transaction): Transaction { + const meta: CloseSuggestTrMeta = {action: 'close'}; + return tr.setMeta(key, meta); + }, +} as const; + +const closeSuggest: Command = (state, dispatch) => { + const meta: CloseSuggestTrMeta = {action: 'close'}; + dispatch?.(state.tr.setMeta(key, meta)); + return true; +}; + +export const SuggestAction = { + appendTr, + closeSuggest, +}; + +type State = + | { + active: false; + } + | { + active: true; + decorations: DecorationSet; + url: string; + range: {from: number; to: number}; + }; + +type OpenSuggestTrMeta = { + action: 'open'; + url: string; + from: number; + to: number; +}; + +type CloseSuggestTrMeta = { + action: 'close'; +}; + +type SuggestTrMeta = OpenSuggestTrMeta | CloseSuggestTrMeta; + +type LinkTransformSuggestOptions = { + createHandler: (view: EditorView) => SuggestHandler; + decorationAttrs?: DecorationAttrs; +}; + +export interface SuggestHandler { + open(): void; + close(): void; + update(): void; + destroy(): void; + + onEscape(): boolean; + onEnter(): boolean; + onUp(): boolean; + onDown(): boolean; +} + +export function linkTransformSuggest({ + createHandler, + decorationAttrs, +}: LinkTransformSuggestOptions) { + let handler: SuggestHandler | null = null; + + return new Plugin({ + key, + state: { + init() { + return {active: false}; + }, + apply(tr, value, oldState, newState) { + const meta: SuggestTrMeta | undefined = tr.getMeta(key); + + if (meta?.action === 'open') { + return { + active: true, + url: meta.url, + range: meta, + decorations: DecorationSet.create(tr.doc, [ + Decoration.inline( + meta.from, + meta.to, + decorationAttrs || { + class: DEFAULT_DECORATION_CLASS_NAME, + }, + ), + ]), + }; + } + + if (meta?.action === 'close') { + return {active: false}; + } + + if (!value.active) return value; + + if (!oldState.selection.eq(newState.selection)) { + return {active: false}; + } + + const decoSet = value.decorations.map(tr.mapping, tr.doc); + const decos = decoSet.find(); + if (!decos.length) return {active: false}; + const {from, to} = decos[0]; + return {...value, decorations: decoSet, range: {from, to}}; + }, + }, + props: { + handleKeyDown(view, event) { + const state = this.getState(view.state)!; + if (!state.active) return false; + + switch (event.key) { + case 'Enter': + case 'Escape': + case 'ArrowUp': + case 'ArrowDown': + default: { + break; + // TODO + } + } + + const meta: CloseSuggestTrMeta = {action: 'close'}; + view.dispatch(view.state.tr.setMeta(key, meta)); + + return false; + }, + decorations(state) { + const pluginState = this.getState(state); + if (pluginState?.active) return pluginState.decorations; + return DecorationSet.empty; + }, + }, + view(view) { + handler = createHandler(view); + return { + update(_0, prevState) { + const curr = key.getState(view.state)!; + const prev = key.getState(prevState)!; + + if (!prev.active && curr.active) handler?.open(); + if (prev.active && !curr.active) handler?.close(); + if (curr.active) handler?.update(); + }, + destroy() { + handler?.destroy(); + handler = null; + }, + }; + }, + }); +} diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/react-components/LinkTransformMenu.tsx b/src/extensions/markdown/Link/LinkTransformSuggest/react-components/LinkTransformMenu.tsx new file mode 100644 index 00000000..9d9c6272 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/react-components/LinkTransformMenu.tsx @@ -0,0 +1,61 @@ +import {HelpMark, Icon, List} from '@gravity-ui/uikit'; + +import {cn} from 'src/classname'; +import {isFunction} from 'src/lodash'; + +import type {SuggestItem} from '../types'; + +const b = cn('command-menu'); +const ITEM_HEIGHT = 28; // px +const VISIBLE_ITEMS_COUNT = 10; +const MAX_LIST_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS_COUNT; // px +function calcListHeight(itemsCount: number): number | undefined { + if (itemsCount <= 0) return undefined; + return Math.min(MAX_LIST_HEIGHT, itemsCount * ITEM_HEIGHT); +} + +export type LinkTransformMenuProps = { + items: readonly SuggestItem[]; + currentIndex: number; +}; + +export const LinkTransformMenu: React.FC = function LinkTransformMenu({ + items, + currentIndex, +}) { + return ( +
+ + virtualized + items={items as SuggestItem[]} + sortable={false} + filterable={false} + itemHeight={ITEM_HEIGHT} + itemsHeight={calcListHeight(items.length)} + renderItem={renderItem} + deactivateOnLeave={false} + activeItemIndex={currentIndex} + // onItemClick={(_item, index) => onItemClick(index)} // TODO + className={b('list')} + itemClassName={b('list-item')} + /> +
+ ); +}; + +function renderItem({id, view: {title, icon, hint}}: SuggestItem): React.ReactNode { + const titleText = isFunction(title) ? title() : title; + const hintText = isFunction(hint) ? hint() : hint; + + return ( +
+ +
+ {titleText} +
+ {hintText && {hintText}} +
+
+
+ ); +} diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/react-components/LinkTransformPopup.tsx b/src/extensions/markdown/Link/LinkTransformSuggest/react-components/LinkTransformPopup.tsx new file mode 100644 index 00000000..85a74061 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/react-components/LinkTransformPopup.tsx @@ -0,0 +1,18 @@ +import {Popup} from '@gravity-ui/uikit'; + +import {LinkTransformMenu, type LinkTransformMenuProps} from './LinkTransformMenu'; + +export type LinkTransformPopupProps = LinkTransformMenuProps & { + anchorElement: Element | null; +}; + +export const LinkTransformPopup: React.FC = function LinkTransformPopup({ + anchorElement, + ...props +}) { + return ( + + + + ); +}; diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/react-components/index.tsx b/src/extensions/markdown/Link/LinkTransformSuggest/react-components/index.tsx new file mode 100644 index 00000000..250a5d4c --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/react-components/index.tsx @@ -0,0 +1,5 @@ +import {LinkTransformPopup, type LinkTransformPopupProps} from './LinkTransformPopup'; + +export function renderPopup(props: LinkTransformPopupProps): React.ReactNode { + return ; +} diff --git a/src/extensions/markdown/Link/LinkTransformSuggest/types.ts b/src/extensions/markdown/Link/LinkTransformSuggest/types.ts new file mode 100644 index 00000000..a1b258a7 --- /dev/null +++ b/src/extensions/markdown/Link/LinkTransformSuggest/types.ts @@ -0,0 +1,11 @@ +export type SuggestItem = { + id: string; + priority?: number; + view: { + icon: any; + title: string | (() => string); + hint?: string | (() => string); + }; + testUrl: (url: string) => boolean; + run: () => void; +}; diff --git a/src/extensions/markdown/Link/paste-plugin.ts b/src/extensions/markdown/Link/paste-plugin.ts index 52d8abac..2324cb19 100644 --- a/src/extensions/markdown/Link/paste-plugin.ts +++ b/src/extensions/markdown/Link/paste-plugin.ts @@ -1,11 +1,13 @@ import {Plugin, TextSelection, type Transaction} from 'prosemirror-state'; -import {type ExtensionDeps, type Parser, getLoggerFromState} from '../../../core'; -import {isNodeSelection, isTextSelection} from '../../../utils/selection'; -import {DataTransferType, isIosSafariShare} from '../../behavior/Clipboard/utils'; +import {type ExtensionDeps, type Parser, getLoggerFromState} from '#core'; +import {DataTransferType, isIosSafariShare} from 'src/extensions/behavior/Clipboard/utils'; +import {isNodeSelection, isTextSelection} from 'src/utils/selection'; + import {imageType} from '../Image'; -import {LinkAttr, linkType} from './index'; +import {LinkAttr, linkType} from './LinkSpecs'; +import {SuggestAction} from './LinkTransformSuggest/plugin'; export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) { return new Plugin({ @@ -30,15 +32,16 @@ export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) { const url = getUrl(e.clipboardData, parser); if (url) { const linkMarkType = linkType(state.schema); - tr = state.tr.replaceSelectionWith( - state.schema.text(url, [ - ...$from - .marks() - .filter((mark) => mark.type !== linkMarkType), - linkMarkType.create({[LinkAttr.Href]: url}), - ]), - false, - ); + const textNode = state.schema.text(url, [ + ...$from.marks().filter((mark) => mark.type !== linkMarkType), + linkMarkType.create({[LinkAttr.Href]: url}), + ]); + tr = state.tr.replaceSelectionWith(textNode, false); + SuggestAction.appendTr.open(tr, { + url, + from: $from.pos, + to: $from.pos + textNode.nodeSize, + }); logger.event({ event: 'paste-url', });