import { fsm2 } from "@heydovetail/ui-components"; import { Plugin, Selection, Transaction } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { BehaviorSubject } from "rxjs"; import { CssClassName, WidgetKey } from "../constants"; import { EditorSchema, NodeTypeName } from "../schema"; import { DocPos } from "../types"; import { el, isClipboardEvent, isFileDragEvent } from "../util/dom"; import { PluginDescriptor } from "../util/PluginDescriptor"; import { PortalProviderApi } from "../util/PortalProvider"; import { AttachmentService } from "./AttachmentService"; import { AttachmentView } from "./AttachmentView"; import * as sagas from "./sagas"; import { closestAttachmentPlacement } from "./util/node"; export const enum FileSource { DRAG_DROP, PASTE, FILE_PICKER } /** * An attachment upload that is pending, i.e. waiting for an `id` from * `AttachmentService`. In practice this happens when interacting with the notes * service and waiting for an ID to be confirmed. */ export interface PendingItem { readonly pos: DocPos; readonly id: string; readonly source: FileSource; } interface PendingState { readonly type: "default"; readonly items: ReadonlyArray; } export const transitionPending = fsm2.createTransition()({ default: { tr: (prev, { tr }: { tr: Transaction }) => { const newItems: Array = []; for (const item of prev.items) { const { deleted, pos } = tr.mapping.mapResult(item.pos); if (!deleted) { newItems.push({ ...item, pos: pos as DocPos }); } } return { type: "default", items: newItems }; }, remove: ({ type, items }, opts: { id: PendingItem["id"] }) => ({ type, items: items.filter(item => item.id !== opts.id) }), add: ({ type, items }, opts: { items: PendingState["items"] }) => ({ type, items: [...items, ...opts.items] }) } }); export type DropPreviewState = | { readonly type: "hidden"; } | { readonly type: "visible"; readonly pos: DocPos; }; export const transitionDropPreview = fsm2.createTransition()({ hidden: { tr: (prev, _: { tr: Transaction }) => prev, visible: (_, { pos }: { pos: DocPos }) => ({ type: "visible", pos }) }, visible: { tr: (prev, { tr }: { tr: Transaction }) => { const { deleted, pos } = tr.mapping.mapResult(prev.pos); return deleted ? { type: "hidden" } : { type: "visible", pos: pos as DocPos }; }, hidden: () => ({ type: "hidden" }), visible: (_, { pos }: { pos: DocPos }) => ({ type: "visible", pos }) } }); export interface PluginState { dropPreview: DropPreviewState; pending: PendingState; presentFilePicker: { accept: string } | null; } const { key, getPluginState, getPluginStateOrThrow, setPluginState } = new PluginDescriptor("AttachmentPlugin"); export { setPluginState, getPluginStateOrThrow }; export class AttachmentPlugin extends Plugin { private readonly selectionSubject = new BehaviorSubject | null>(null); private readonly editableSubject = new BehaviorSubject(false); constructor(portalProviderApi: PortalProviderApi, attachmentService: AttachmentService) { super({ key, state: { init(): PluginState { return { dropPreview: { type: "hidden" }, pending: { type: "default", items: [] }, presentFilePicker: null }; }, apply(tr, cur: PluginState): PluginState { const nextPluginState = getPluginState(tr); return nextPluginState !== null ? nextPluginState : !tr.docChanged ? cur : { pending: transitionPending(cur.pending).tr({ tr }), dropPreview: transitionDropPreview(cur.dropPreview).tr({ tr }), presentFilePicker: cur.presentFilePicker }; } }, view: () => ({ update: view => { // Present the file picker if flag is set, then immediately clear the // flag. const pluginState = getPluginStateOrThrow(view.state); if (pluginState.presentFilePicker !== null) { sagas.pickFile(view, attachmentService, pluginState.presentFilePicker.accept); setPluginState(view, prevState => ({ ...prevState, presentFilePicker: null })); } this.selectionSubject.next(view.state.selection); const editable = view.props.editable != null ? view.props.editable(view.state) : true; if (this.editableSubject.value !== editable) { this.editableSubject.next(editable); } } }), props: { nodeViews: { ["at" as NodeTypeName]: (node, view, getPos) => new AttachmentView( node, view, getPos, portalProviderApi, attachmentService, this.selectionSubject.asObservable(), this.editableSubject.asObservable() ) }, decorations(editorState) { const { dropPreview, pending } = getPluginStateOrThrow(editorState); const decorations: Decoration[] = []; if (dropPreview.type === "visible") { decorations.push( Decoration.widget(dropPreview.pos, el("div", CssClassName.ATTACHMENT_INSERT_PREVIEW), { key: `${WidgetKey.ATTACHMENT_INSERT_CURSOR}` }) ); } for (const { pos, id, source } of pending.items) { // For attachments that were dragged in, continue showing the drag // preview to avoid jank. If we didn't do this, the insert preview // would disappear and reflow the editor, and then shortly after the // attachment would appear. if (source === FileSource.DRAG_DROP) { decorations.push( Decoration.widget(pos, el("div", CssClassName.ATTACHMENT_INSERT_PREVIEW), { key: id }) ); } } return DecorationSet.create(editorState.doc, decorations); }, handleDOMEvents: { drop: (view, event) => { if (isFileDragEvent(event)) { const pluginState = getPluginStateOrThrow(view.state); if (pluginState.dropPreview.type === "visible") { setPluginState(view, { ...pluginState, dropPreview: transitionDropPreview(pluginState.dropPreview).hidden() }); sagas.insertAttachmentsFromFiles( view, pluginState.dropPreview.pos, event.dataTransfer.files, attachmentService, FileSource.DRAG_DROP ); return true; } } return false; }, dragleave: (view, event) => { const pluginState = getPluginStateOrThrow(view.state); const mouseEvent = event as DragEvent; if (pluginState.dropPreview.type === "visible" && !view.dom.contains(mouseEvent.relatedTarget as Node)) { setPluginState(view, { ...pluginState, dropPreview: transitionDropPreview(pluginState.dropPreview).hidden() }); } return false; }, dragover: (view, event) => { const pluginState = getPluginStateOrThrow(view.state); if (isFileDragEvent(event)) { const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); if (pos != null) { const insertPos = closestAttachmentPlacement(view.state.doc.resolve(pos.pos)); const newDragInsertPreview = transitionDropPreview(pluginState.dropPreview).visible({ pos: insertPos.pos as DocPos }); // Performance short circuit. if (JSON.stringify(newDragInsertPreview) !== JSON.stringify(pluginState.dropPreview)) { setPluginState(view, { ...pluginState, dropPreview: newDragInsertPreview }); } } } return false; }, paste: (view, event) => { if (isClipboardEvent(event)) { const { selection } = view.state; if (selection.empty && isFileOnlyClipboardEvent(event)) { sagas.insertAttachmentsFromFiles( view, selection.$anchor.pos as DocPos, event.clipboardData.files, attachmentService, FileSource.PASTE ); event.preventDefault(); return true; } } return false; } } } }); } } /** * Return true if the clipboard event only has file data, and no text / html. * When an event has text / html data we want to use that instead. For example * in Microsoft Word when you copy some content, it will include an image render * of the content, which might be useful for bitmap editing programs, however we * want to ignore that and use the HTML content. * * @param event */ function isFileOnlyClipboardEvent(event: ClipboardEvent): boolean { return ( event.clipboardData.files.length > 0 && (event.clipboardData.types.indexOf("text/plain") === -1 && event.clipboardData.types.indexOf("text/html") === -1) ); }