import { fsm2 } from "@heydovetail/ui-components"; import { Plugin, Transaction, TextSelection } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import React from "react"; import { Observable } from "rxjs"; import { asap } from "rxjs/internal/scheduler/asap"; import { observeOn } from "rxjs/operators"; import { style } from "typestyle-react"; import { CssClassName, DEFAULT_HIGHLIGHT_COLOR } from "../constants"; import { getPluginStateOrThrow as getRangePluginStateOrThrow, PluginState as RangePluginState } from "../range/RangePlugin"; import { getPluginStateOrThrow as getEditablePluginStateOrThrow } from "../editable/EditablePlugin"; import { HighlightRange, RangeType } from "../range/types"; import { EditorSchema } from "../schema"; import { el, isKeyboardEvent } from "../util/dom"; import { PluginDescriptor } from "../util/PluginDescriptor"; import { PortalProviderApi } from "../util/PortalProvider"; import { removeTagFromHighlight, upsertTagToHighlight } from "./operations"; import { HighlightWidget } from "./react/HighlightWidget"; export interface TagGroup { id: string; title: string; color: string; tags: Tag[]; } export interface Tag { id: string; title: string; color: string; count: number | null; } export enum WidgetPluginStateType { DISABLED = "disabled", IDLE = "idle", SELECTING = "selecting", TAGGING = "tagging" } export interface DisabledState { readonly type: WidgetPluginStateType.DISABLED; } export interface IdleState { readonly type: WidgetPluginStateType.IDLE; } export interface SelectingState { readonly type: WidgetPluginStateType.SELECTING; readonly selections: Set<"touch" | "mouse" | "keyboard">; } export interface TaggingState { readonly type: WidgetPluginStateType.TAGGING; readonly selection: { from: number; to: number }; } export type WidgetPluginState = DisabledState | IdleState | SelectingState | TaggingState; export const transitionWidget = fsm2.createTransition()({ disabled: { toggleActive: (_prev, selection: TaggingState["selection"] | null) => selection === null ? { type: WidgetPluginStateType.IDLE } : { type: WidgetPluginStateType.TAGGING, selection }, tr: (_prev, _: { tr: Transaction; rangePluginState: RangePluginState }) => _prev }, idle: { updateSelection: (_prev, opts: { trigger: "touch" | "mouse" | "keyboard"; action: "add" | "remove" }) => ({ type: WidgetPluginStateType.SELECTING, selections: opts.action === "add" ? new Set([opts.trigger]) : new Set() }), idle: _prev => _prev, tagging: (_prev, selection: { from: number; to: number }) => ({ type: WidgetPluginStateType.TAGGING, selection }), toggleActive: (_prev, _: TaggingState["selection"] | null) => ({ type: WidgetPluginStateType.DISABLED }), tr: (_prev, { tr, rangePluginState }: { tr: Transaction; rangePluginState: RangePluginState }) => { if (tr.selection.empty && !tr.docChanged) { const cursorPos = tr.selection.from; const range = rangePluginState.sortedRanges.find(r => r.from < cursorPos && r.to > cursorPos); if (range !== undefined) { return { type: WidgetPluginStateType.TAGGING, selection: { from: range.from, to: range.to } }; } } return _prev; } }, selecting: { idle: () => ({ type: WidgetPluginStateType.IDLE }), updateSelection: (_prev, opts: { trigger: "touch" | "mouse" | "keyboard"; action: "add" | "remove" }) => { const selections = new Set(_prev.selections); if (opts.action === "add") { selections.add(opts.trigger); } else { selections.delete(opts.trigger); } return { type: WidgetPluginStateType.SELECTING, selections }; }, tagging: (_prev, selection: { from: number; to: number }) => ({ type: WidgetPluginStateType.TAGGING, selection }), toggleActive: (_prev, _: TaggingState["selection"] | null) => ({ type: WidgetPluginStateType.DISABLED }), tr: (_prev, _: { tr: Transaction; rangePluginState: RangePluginState }) => _prev }, tagging: { idle: () => ({ type: WidgetPluginStateType.IDLE }), updateSelection: (_prev, opts: { trigger: "touch" | "mouse" | "keyboard"; action: "add" | "remove" }) => ({ type: WidgetPluginStateType.SELECTING, selections: opts.action === "add" ? new Set([opts.trigger]) : new Set() }), toggleActive: (_prev, _: TaggingState["selection"] | null) => ({ type: WidgetPluginStateType.DISABLED }), tr: (_prev, { tr, rangePluginState }: { tr: Transaction; rangePluginState: RangePluginState }) => { const { selection: { 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) { if ( !tr.selection.empty && tr.selection instanceof TextSelection && tr.selection.from === newMarkRangeFrom && tr.selection.to === newMarkRangeTo ) { return { type: WidgetPluginStateType.TAGGING, selection: { from: newMarkRangeFrom, to: newMarkRangeTo } }; } } if (tr.selection.empty && !tr.docChanged) { const cursorPos = tr.selection.from; const range = rangePluginState.sortedRanges.find(r => r.from < cursorPos && r.to > cursorPos); if (range !== undefined) { return { type: WidgetPluginStateType.TAGGING, selection: { from: range.from, to: range.to } }; } } return { type: WidgetPluginStateType.IDLE }; } } }); export interface PluginState { widgetState: WidgetPluginState; tagGroups: TagGroup[]; tagLookup: Map; } const { key, getPluginState, getPluginStateOrThrow, setPluginState } = new PluginDescriptor( "HighlightWidgetPlugin" ); export { getPluginStateOrThrow, getPluginState, setPluginState }; const highlightClass = style({ $nest: { ".ProseMirror:not(.ProseMirror-focused) &": { backgroundColor: DEFAULT_HIGHLIGHT_COLOR, padding: "5px 0" } } }); export class HighlightWidgetPlugin extends Plugin { constructor( portalProviderApi: PortalProviderApi, getTagGroups: () => Observable, createTag: (args: { groupId: string | null; title: string }) => Promise ) { super({ key, state: { init: (_, state): PluginState => { const { editable } = getEditablePluginStateOrThrow(state); return { widgetState: { type: editable ? WidgetPluginStateType.IDLE : WidgetPluginStateType.DISABLED } as DisabledState | IdleState, tagGroups: [], tagLookup: new Map() }; }, apply: (tr, curr: PluginState, _oldState, newState): PluginState => { const nextPluginState = getPluginState(tr); const rangePluginState = getRangePluginStateOrThrow(newState); return nextPluginState !== null ? nextPluginState : { ...curr, widgetState: transitionWidget(curr.widgetState).tr({ tr, rangePluginState }) }; } }, view: view => { const container = el("div"); const $tagGroups = getTagGroups() .pipe(observeOn(asap)) .subscribe(tagGroups => { const tagLookup = new Map( tagGroups.reduce((accum, tg) => [...accum, ...tg.tags], []).map(tag => [tag.id, tag] as [string, Tag]) ); setPluginState(view, prevPluginState => ({ ...prevPluginState, tagLookup, tagGroups })); }); if (view.dom.parentElement !== null) { view.dom.parentElement.insertBefore(container, view.dom.parentElement.firstChild); } return { update: view => { const state = view.state; const { widgetState, tagGroups, tagLookup } = getPluginStateOrThrow(state); const rangePluginState = getRangePluginStateOrThrow(state); if (view.dom.parentElement !== null && widgetState.type === WidgetPluginStateType.TAGGING) { // Otherwise, reposition it and update its content const { from, to } = widgetState.selection; // These are in screen coordinates const start = view.coordsAtPos(from); const end = view.coordsAtPos(to); const left = Math.max((start.left + end.left) / 2, start.left + 3); const highlightedRanges = rangePluginState.sortedRanges.filter( (range): range is HighlightRange => from === range.from && to === range.to && range.type === RangeType.HIGHLIGHT ); const currentTags = highlightedRanges .reduce((accum, r) => [...accum, ...r.attrs.tagIds], []) .map(id => tagLookup.get(id)) .filter((tag): tag is Tag => tag !== undefined); portalProviderApi.render( createTag(options)} tagGroups={tagGroups} selection={{ from, to }} onDismiss={() => { setPluginState(view, existing => ({ ...existing, widgetState: transitionWidget(widgetState).idle() })); view.focus(); }} onAddTag={({ id, from, to }) => view.dispatch(upsertTagToHighlight(view.state, { from, to }, id))} onRemoveTag={({ id, from, to }) => view.dispatch(removeTagFromHighlight(view.state, { from, to }, id))} />, container ); } else { portalProviderApi.destroy(container); } }, destroy: () => { $tagGroups.unsubscribe(); if (container.parentElement !== null) { container.parentElement.removeChild(container); } } }; }, props: { attributes(state) { const { widgetState } = getPluginStateOrThrow(state); return widgetState.type !== WidgetPluginStateType.DISABLED ? { class: CssClassName.TAGGING_SELECTION_COLOR } : null; }, decorations(state) { const { widgetState } = getPluginStateOrThrow(state); const rangePluginState = getRangePluginStateOrThrow(state); if (widgetState.type === WidgetPluginStateType.TAGGING) { const range = rangePluginState.sortedRanges.find( r => r.from === widgetState.selection.from && r.to === widgetState.selection.to && r.type === RangeType.HIGHLIGHT ); if (range === undefined) { return DecorationSet.create(state.doc, [ Decoration.inline(widgetState.selection.from, widgetState.selection.to, { class: highlightClass }) ]); } } return null; }, handleDOMEvents: { mousedown: view => { const { widgetState: prevWidgetState } = getPluginStateOrThrow(view.state); if (prevWidgetState.type !== WidgetPluginStateType.DISABLED) { setPluginState(view, existing => ({ ...existing, widgetState: transitionWidget(prevWidgetState).updateSelection({ trigger: "mouse", action: "add" }) })); // Add window event listener for up event const root = view.root as DocumentFragment; root.addEventListener("mouseup", function mouseup() { setPluginState(view, existing => { let updated: WidgetPluginState = transitionWidget(prevWidgetState).updateSelection({ trigger: "mouse", action: "remove" }); if ( updated.selections.size === 0 && !view.state.selection.empty && view.state.selection instanceof TextSelection ) { updated = transitionWidget(updated).tagging(view.state.selection); } else if (updated.selections.size === 0 && view.state.selection.empty) { updated = transitionWidget(updated).idle(); } return { ...existing, widgetState: updated }; }); root.removeEventListener("mouseup", mouseup); }); } return false; }, touchstart: view => { const { widgetState: prevWidgetState } = getPluginStateOrThrow(view.state); if (prevWidgetState.type !== WidgetPluginStateType.DISABLED) { setPluginState(view, existing => ({ ...existing, widgetState: transitionWidget(prevWidgetState).updateSelection({ trigger: "touch", action: "add" }) })); // Add window event listener for end event const root = view.root as DocumentFragment; root.addEventListener("touchend", function touchend() { setPluginState(view, existing => { let updated: WidgetPluginState = transitionWidget(prevWidgetState).updateSelection({ trigger: "touch", action: "remove" }); if ( updated.selections.size === 0 && !view.state.selection.empty && view.state.selection instanceof TextSelection ) { updated = transitionWidget(updated).tagging(view.state.selection); } else if (updated.selections.size === 0 && view.state.selection.empty) { updated = transitionWidget(updated).idle(); } return { ...existing, widgetState: updated }; }); root.removeEventListener("touchend", touchend); }); } return false; }, keydown: (view, event) => { const { widgetState: prevWidgetState } = getPluginStateOrThrow(view.state); if (prevWidgetState.type !== WidgetPluginStateType.DISABLED) { if (isKeyboardEvent(event) && event.key === "Shift") { setPluginState(view, existing => ({ ...existing, widgetState: transitionWidget(prevWidgetState).updateSelection({ trigger: "keyboard", action: "add" }) })); } // Add window event listener for up state const root = view.root as DocumentFragment; root.addEventListener("keyup", function keyup(event) { if (isKeyboardEvent(event) && event.key === "Shift") { setPluginState(view, existing => { let updated: WidgetPluginState = transitionWidget(prevWidgetState).updateSelection({ trigger: "keyboard", action: "remove" }); if ( updated.selections.size === 0 && !view.state.selection.empty && view.state.selection instanceof TextSelection ) { updated = transitionWidget(updated).tagging(view.state.selection); } else if (updated.selections.size === 0 && view.state.selection.empty) { updated = transitionWidget(updated).idle(); } return { ...existing, widgetState: updated }; }); root.removeEventListener("keyup", keyup); } }); } return false; } } } }); } }