Skip to content

feat(wysiwyg): added suggest to convert pasted link #694

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -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<string, SuggestItem>;

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;
}
}
}
125 changes: 125 additions & 0 deletions src/extensions/markdown/Link/LinkTransformSuggest/handler.ts
Original file line number Diff line number Diff line change
@@ -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<SuggestItem>;
};

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './LinkTransformSuggest';
166 changes: 166 additions & 0 deletions src/extensions/markdown/Link/LinkTransformSuggest/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<State>('link-transform-suggest');

const appendTr = {
open(tr: Transaction, data: Omit<OpenSuggestTrMeta, 'action'>): 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<State>({
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;
},
};
},
});
}
Original file line number Diff line number Diff line change
@@ -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<LinkTransformMenuProps> = function LinkTransformMenu({
items,
currentIndex,
}) {
return (
<div className={b()}>
<List<SuggestItem>
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')}
/>
</div>
);
};

function renderItem({id, view: {title, icon, hint}}: SuggestItem): React.ReactNode {
const titleText = isFunction(title) ? title() : title;
const hintText = isFunction(hint) ? hint() : hint;

return (
<div key={id} className={b('item', {id})}>
<Icon data={icon.data} size={20} className={b('item-icon')} />
<div className={b('item-body')}>
<span className={b('item-title')}>{titleText}</span>
<div className={b('item-extra')}>
{hintText && <HelpMark className={b('item-hint')}>{hintText}</HelpMark>}
</div>
</div>
</div>
);
}
Loading
Loading