import { fsm2 } from "@heydovetail/ui-components"; import { Node } from "prosemirror-model"; import { Plugin, Transaction, EditorState } from "prosemirror-state"; import React from "react"; import * as pquery from "../pquery"; import { DocPos, DocPosRange } from "../types"; import { el } from "../util/dom"; import { PluginDescriptor } from "../util/PluginDescriptor"; import { PortalProviderApi } from "../util/PortalProvider"; import { safeUrl } from "../util/safeUrl"; import { LinkEditor } from "./react/LinkEditor"; import { EditorSchema } from "../schema"; export enum LinkPluginStateType { IDLE = "idle", SHOW_DETAIL = "showDetail", SHOW_EDITOR = "showEditor", SHOW_COMPOSER = "showComposer" } export interface Link { readonly text: string; readonly url: string; readonly range: DocPosRange; } export interface LinkDraft { readonly markRange: DocPosRange; // The text and range are not necessarily the same as the mark range for the // compose case. If a selection spans multiple blocks, we *could* // concatenate the text from all the blocks together and display that, but // we wouldn't be able to map edits back onto the document. // // The approach we use instead follows Google's Inbox editor, where the text // is taken from the last block in the selection, and edits only apply to // that block. readonly text: string; readonly textRange: DocPosRange; } export interface IdleState { readonly type: LinkPluginStateType.IDLE; } export interface ShowDetailState { readonly type: LinkPluginStateType.SHOW_DETAIL; readonly link: Link; } export interface ShowEditorState { readonly type: LinkPluginStateType.SHOW_EDITOR; readonly link: Link; } export interface ShowComposerState { readonly type: LinkPluginStateType.SHOW_COMPOSER; readonly draft: LinkDraft; } export type PluginState = IdleState | ShowDetailState | ShowEditorState | ShowComposerState; function pluginStateEq(a: PluginState, b: PluginState) { return JSON.stringify(a) === JSON.stringify(b); } export const transition = fsm2.createTransition()({ idle: { showDetail: (_prev, link: Link) => ({ type: LinkPluginStateType.SHOW_DETAIL, link }), showEditor: (_prev, link: Link) => ({ type: LinkPluginStateType.SHOW_EDITOR, link }), showComposer: (_prev, draft: LinkDraft) => ({ type: LinkPluginStateType.SHOW_COMPOSER, draft }), tr: (_prev, tr: Transaction) => pickIdleOrDetail(tr) }, showDetail: { idle: () => ({ type: LinkPluginStateType.IDLE }), showDetail: (_prev, link: Link) => ({ type: LinkPluginStateType.SHOW_DETAIL, link }), showEditor: (_prev, link: Link) => ({ type: LinkPluginStateType.SHOW_EDITOR, link }), tr: (_prev, tr: Transaction) => pickIdleOrDetail(tr) }, showEditor: { idle: () => ({ type: LinkPluginStateType.IDLE }), showEditor: (_prev, link: Link) => ({ type: LinkPluginStateType.SHOW_EDITOR, link }), tr: (_prev, tr: Transaction) => { const link = getAdjacentLinkFromSelection(tr); return link === null ? { type: LinkPluginStateType.IDLE } : { type: LinkPluginStateType.SHOW_EDITOR, link }; } }, showComposer: { idle: () => ({ type: LinkPluginStateType.IDLE }), showComposer: (_prev, draft: LinkDraft) => ({ type: LinkPluginStateType.SHOW_COMPOSER, draft }), tr: (prev, tr: Transaction) => { const { draft: { markRange: { from: prevMarkRangeFrom, to: prevMarkRangeTo } } } = prev; const { deleted: markRangeFromDeleted, pos: newMarkRangeFrom } = tr.mapping.mapResult(prevMarkRangeFrom, 1); const { deleted: markRangeToDeleted, pos: newMarkRangeTo } = tr.mapping.mapResult(prevMarkRangeTo, -1); if (!markRangeFromDeleted && !markRangeToDeleted) { const draft = tryComposeAttemptOnRange(tr.doc, { from: newMarkRangeFrom as DocPos, to: newMarkRangeTo as DocPos }); if (draft !== null) { return { type: LinkPluginStateType.SHOW_COMPOSER, draft }; } } return { type: LinkPluginStateType.IDLE }; } } }); export function tryComposeAttemptOnRange(doc: Node, range: DocPosRange): LinkDraft | null { const textRange = pquery.contiguousTextRange(doc.resolve(range.from), doc.resolve(range.to)); return textRange === null ? null : { markRange: { from: range.from, to: range.to }, text: doc.textBetween(textRange.from, textRange.to), textRange: { from: textRange.from as DocPos, to: textRange.to as DocPos } }; } export function pickIdleOrDetail({ selection, doc }: Pick, "selection" | "doc">): IdleState | ShowDetailState { const link = getAdjacentLinkFromSelection({ selection, doc }); return link === null ? { type: LinkPluginStateType.IDLE } : { type: LinkPluginStateType.SHOW_DETAIL, link }; } export function getAdjacentLinkFromSelection({ selection, doc }: Pick, "selection" | "doc">): Link | null { const searchResult = pquery.adjacentMarkBounds(selection.$from, doc.type.schema.marks["l"]); if (searchResult !== null) { const { mark, from, to } = searchResult; const text = doc.textBetween(from, to); const url = safeUrl(mark.attrs["u"]); return { range: { from: from as DocPos, to: to as DocPos }, text, url }; } return null; } const { key, getPluginState, getPluginStateOrThrow, setPluginState } = new PluginDescriptor("LinkPlugin"); export { setPluginState, getPluginState, getPluginStateOrThrow }; export class LinkPlugin extends Plugin { constructor(options: { portalProviderApi: PortalProviderApi; initialState?: PluginState }) { const { initialState = { type: LinkPluginStateType.IDLE }, portalProviderApi } = options; super({ key, state: { init() { return initialState; }, apply(tr, cur: PluginState): PluginState { const nextPluginState = getPluginState(tr); if (nextPluginState !== null) { return nextPluginState; } if (tr.docChanged || tr.selectionSet) { return transition(cur).tr(tr); } return cur; } }, view: view => { const container = view.dom.appendChild(el("div")); portalProviderApi.render(, container); return { update: (view, prevState) => { const prevPluginState = getPluginStateOrThrow(prevState); const newPluginState = getPluginStateOrThrow(view.state); if (!pluginStateEq(prevPluginState, newPluginState)) { portalProviderApi.render(, container); } }, destroy: () => { if (container.parentElement !== null) { container.parentElement.removeChild(container); } portalProviderApi.destroy(container); } }; } }); } }