diff options
Diffstat (limited to 'src/client/views/nodes/FormattedTextBox.tsx')
-rw-r--r-- | src/client/views/nodes/FormattedTextBox.tsx | 374 |
1 files changed, 297 insertions, 77 deletions
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 648037e31..98abde89e 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -1,30 +1,41 @@ -import { action, IReactionDisposer, reaction } from "mobx"; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faEdit, faSmile } from '@fortawesome/free-solid-svg-icons'; +import { action, IReactionDisposer, observable, reaction } from "mobx"; +import { observer } from "mobx-react"; import { baseKeymap } from "prosemirror-commands"; -import { history, redo, undo } from "prosemirror-history"; +import { history } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; -import { schema } from "../../util/RichTextSchema"; -import { EditorState, Transaction, } from "prosemirror-state"; +import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { Opt, FieldWaiting } from "../../../fields/Field"; -import "./FormattedTextBox.scss"; -import React = require("react") -import { RichTextField } from "../../../fields/RichTextField"; -import { FieldViewProps, FieldView } from "./FieldView"; -import { Plugin } from 'prosemirror-state' -import { Decoration, DecorationSet } from 'prosemirror-view' -import { TooltipTextMenu } from "../../util/TooltipTextMenu" +import { Doc, Field, Opt, WidthSym, HeightSym } from "../../../new_fields/Doc"; +import { RichTextField } from "../../../new_fields/RichTextField"; +import { createSchema, makeInterface } from "../../../new_fields/Schema"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { DocServer } from "../../DocServer"; +import { DocumentManager } from "../../util/DocumentManager"; +import { DragManager } from "../../util/DragManager"; +import buildKeymap from "../../util/ProsemirrorKeymap"; +import { inpRules } from "../../util/RichTextRules"; +import { ImageResizeView, schema } from "../../util/RichTextSchema"; +import { SelectionManager } from "../../util/SelectionManager"; +import { TooltipLinkingMenu } from "../../util/TooltipLinkingMenu"; +import { TooltipTextMenu } from "../../util/TooltipTextMenu"; +import { undoBatch, UndoManager } from "../../util/UndoManager"; import { ContextMenu } from "../../views/ContextMenu"; -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faEdit } from '@fortawesome/free-solid-svg-icons'; -import { faSmile } from '@fortawesome/free-solid-svg-icons'; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { DocComponent } from "../DocComponent"; +import { InkingControl } from "../InkingControl"; +import { FieldView, FieldViewProps } from "./FieldView"; +import "./FormattedTextBox.scss"; +import React = require("react"); library.add(faEdit); library.add(faSmile); // FormattedTextBox: Displays an editable plain text node that maps to a specified Key of a Document // -// HTML Markup: <FormattedTextBox Doc={Document's ID} FieldKey={Key's name + "Key"} -// +// HTML Markup: <FormattedTextBox Doc={Document's ID} FieldKey={Key's name} +// // In Code, the node's HTML is specified in the document's parameterized structure as: // document.SetField(KeyStore.Layout, "<FormattedTextBox doc={doc} fieldKey={<KEYNAME>Key} />"); // and the node's binding to the specified document KEYNAME as: @@ -33,67 +44,161 @@ library.add(faSmile); // 'fieldKey' property to the Key stored in LayoutKeys // and 'doc' property to the document that is being rendered // -// When rendered() by React, this extracts the TextController from the Document stored at the -// specified Key and assigns it to an HTML input node. When changes are made tot his node, +// When rendered() by React, this extracts the TextController from the Document stored at the +// specified Key and assigns it to an HTML input node. When changes are made to this node, // this will edit the document and assign the new value to that field. //] -export class FormattedTextBox extends React.Component<FieldViewProps> { - public static LayoutString(fieldStr: string = "DataKey") { return FieldView.LayoutString(FormattedTextBox, fieldStr) } +export interface FormattedTextBoxOverlay { + isOverlay?: boolean; +} + +const richTextSchema = createSchema({ + documentText: "string" +}); + +type RichTextDocument = makeInterface<[typeof richTextSchema]>; +const RichTextDocument = makeInterface(richTextSchema); + +@observer +export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTextBoxOverlay), RichTextDocument>(RichTextDocument) { + public static LayoutString(fieldStr: string = "data") { + return FieldView.LayoutString(FormattedTextBox, fieldStr); + } private _ref: React.RefObject<HTMLDivElement>; + private _proseRef: React.RefObject<HTMLDivElement>; private _editorView: Opt<EditorView>; + private _gotDown: boolean = false; + private _dropDisposer?: DragManager.DragDropDisposer; private _reactionDisposer: Opt<IReactionDisposer>; + private _inputReactionDisposer: Opt<IReactionDisposer>; + private _proxyReactionDisposer: Opt<IReactionDisposer>; + public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } + + @observable public static InputBoxOverlay?: FormattedTextBox = undefined; + public static InputBoxOverlayScroll: number = 0; constructor(props: FieldViewProps) { super(props); this._ref = React.createRef(); - this.onChange = this.onChange.bind(this); + this._proseRef = React.createRef(); + if (this.props.isOverlay) { + DragManager.StartDragFunctions.push(() => FormattedTextBox.InputBoxOverlay = undefined); + } } + _applyingChange: boolean = false; + + _lastState: any = undefined; dispatchTransaction = (tx: Transaction) => { if (this._editorView) { - const state = this._editorView.state.apply(tx); + const state = this._lastState = this._editorView.state.apply(tx); this._editorView.updateState(state); - this.props.doc.SetData(this.props.fieldKey, JSON.stringify(state.toJSON()), RichTextField); + this._applyingChange = true; + Doc.SetOnPrototype(this.props.Document, this.props.fieldKey, new RichTextField(JSON.stringify(state.toJSON()))); + Doc.SetOnPrototype(this.props.Document, "documentText", state.doc.textBetween(0, state.doc.content.size, "\n\n")); + this._applyingChange = false; + let title = StrCast(this.props.Document.title); + if (title && title.startsWith("-") && this._editorView) { + let str = this._editorView.state.doc.textContent; + let titlestr = str.substr(0, Math.min(40, str.length)); + let target = this.props.Document.proto ? this.props.Document.proto : this.props.Document; + target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + } + } + } + + @undoBatch + @action + drop = async (e: Event, de: DragManager.DropEvent) => { + if (de.data instanceof DragManager.LinkDragData) { + let sourceDoc = de.data.linkSourceDocument; + let destDoc = this.props.Document; + + Doc.MakeLink(sourceDoc, destDoc); + de.data.droppedDocuments.push(destDoc); + e.stopPropagation(); } } componentDidMount() { - let state: EditorState; + if (this._ref.current) { + this._dropDisposer = DragManager.MakeDropTarget(this._ref.current, { + handlers: { drop: this.drop.bind(this) } + }); + } const config = { schema, - plugins: [ + inpRules, //these currently don't do anything, but could eventually be helpful + plugins: this.props.isOverlay ? [ + this.tooltipTextMenuPlugin(), history(), - keymap({ "Mod-z": undo, "Mod-y": redo }), + keymap(buildKeymap(schema)), keymap(baseKeymap), - this.tooltipMenuPlugin() - ] + // this.tooltipLinkingMenuPlugin(), + new Plugin({ + props: { + attributes: { class: "ProseMirror-example-setup-style" } + } + }) + ] : [ + history(), + keymap(buildKeymap(schema)), + keymap(baseKeymap), + ] }; - let field = this.props.doc.GetT(this.props.fieldKey, RichTextField); - if (field && field != FieldWaiting && field.Data) { - state = EditorState.fromJSON(config, JSON.parse(field.Data)); + if (this.props.isOverlay) { + this._inputReactionDisposer = reaction(() => FormattedTextBox.InputBoxOverlay, + () => { + if (this._editorView) { + this._editorView.destroy(); + } + this.setupEditor(config, this.props.Document);// MainOverlayTextBox.Instance.TextDoc); // bcz: not sure why, but the order of events is such that this.props.Document hasn't updated yet, so without forcing the editor to the MainOverlayTextBox, it will display the previously focused textbox + } + ); } else { - state = EditorState.create(config); + this._proxyReactionDisposer = reaction(() => this.props.isSelected(), + () => { + if (this.props.isSelected()) { + FormattedTextBox.InputBoxOverlay = this; + FormattedTextBox.InputBoxOverlayScroll = this._ref.current!.scrollTop; + } + }); } - if (this._ref.current) { - this._editorView = new EditorView(this._ref.current, { - state, - dispatchTransaction: this.dispatchTransaction + + + this._reactionDisposer = reaction( + () => { + const field = this.props.Document ? Cast(this.props.Document[this.props.fieldKey], RichTextField) : undefined; + return field ? field.Data : undefined; + }, + field => field && this._editorView && !this._applyingChange && + this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field))) + ); + this.setupEditor(config, this.props.Document); + } + + private setupEditor(config: any, doc?: Doc) { + let field = doc ? Cast(doc[this.props.fieldKey], RichTextField) : undefined; + if (this._proseRef.current) { + this._editorView = new EditorView(this._proseRef.current, { + state: field && field.Data ? EditorState.fromJSON(config, JSON.parse(field.Data)) : EditorState.create(config), + dispatchTransaction: this.dispatchTransaction, + nodeViews: { + image(node, view, getPos) { return new ImageResizeView(node, view, getPos); } + } }); + let text = StrCast(this.props.Document.documentText); + if (text.startsWith("@@@")) { + this.props.Document.proto!.documentText = undefined; + this._editorView.dispatch(this._editorView.state.tr.insertText(text.replace("@@@", ""))); + } } - this._reactionDisposer = reaction(() => { - const field = this.props.doc.GetT(this.props.fieldKey, RichTextField); - return field && field != FieldWaiting ? field.Data : undefined; - }, (field) => { - if (field && this._editorView) { - this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field))); - } - }) if (this.props.selectOnLoad) { - this.props.select(); + this.props.select(false); this._editorView!.focus(); } } @@ -105,60 +210,175 @@ export class FormattedTextBox extends React.Component<FieldViewProps> { if (this._reactionDisposer) { this._reactionDisposer(); } + if (this._inputReactionDisposer) { + this._inputReactionDisposer(); + } + if (this._proxyReactionDisposer) { + this._proxyReactionDisposer(); + } + if (this._dropDisposer) { + this._dropDisposer(); + } } - shouldComponentUpdate() { - return false; - } + onPointerDown = (e: React.PointerEvent): void => { + if (e.button === 0 && this.props.isSelected() && !e.altKey && !e.ctrlKey && !e.metaKey) { + e.stopPropagation(); + if (this._toolTipTextMenu && this._toolTipTextMenu.tooltip) { + this._toolTipTextMenu.tooltip.style.opacity = "0"; + } + } + if (e.button === 0 && ((!this.props.isSelected() && !e.ctrlKey) || (this.props.isSelected() && e.ctrlKey)) && !e.metaKey) { + if (e.target && (e.target as any).href) { + let href = (e.target as any).href; + if (href.indexOf(DocServer.prepend("/doc/")) === 0) { + let docid = href.replace(DocServer.prepend("/doc/"), "").split("?")[0]; + DocServer.GetRefField(docid).then(action((f: Opt<Field>) => { + if (f instanceof Doc) { + if (DocumentManager.Instance.getDocumentView(f)) { + DocumentManager.Instance.getDocumentView(f)!.props.focus(f); + } else { + CollectionDockingView.Instance.AddRightSplit(f); + } + } + })); + } + e.stopPropagation(); + e.preventDefault(); + } - @action - onChange(e: React.ChangeEvent<HTMLInputElement>) { - this.props.doc.SetData(this.props.fieldKey, e.target.value, RichTextField); + } + if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { + this._gotDown = true; + e.preventDefault(); + } } - onPointerDown = (e: React.PointerEvent): void => { - if (e.buttons === 1 && this.props.isSelected()) { + onPointerUp = (e: React.PointerEvent): void => { + if (this._toolTipTextMenu && this._toolTipTextMenu.tooltip) { + this._toolTipTextMenu.tooltip.style.opacity = "1"; + } + if (e.buttons === 1 && this.props.isSelected() && !e.altKey) { e.stopPropagation(); } } - //REPLACE THIS WITH CAPABILITIES SPECIFIC TO THIS TYPE OF NODE - textCapability = (e: React.MouseEvent): void => { + @action + onFocused = (e: React.FocusEvent): void => { + if (!this.props.isOverlay) { + FormattedTextBox.InputBoxOverlay = this; + } else { + if (this._ref.current) { + this._ref.current.scrollTop = FormattedTextBox.InputBoxOverlayScroll; + } + } } - @action + //REPLACE THIS WITH CAPABILITIES SPECIFIC TO THIS TYPE OF NODE + freezeNativeDimensions = (e: React.MouseEvent): void => { + if (NumCast(this.props.Document.nativeWidth)) { + this.props.Document.proto!.nativeWidth = undefined; + this.props.Document.proto!.nativeHeight = undefined; + + } else { + this.props.Document.proto!.nativeWidth = this.props.Document[WidthSym](); + this.props.Document.proto!.nativeHeight = this.props.Document[HeightSym](); + } + } specificContextMenu = (e: React.MouseEvent): void => { - ContextMenu.Instance.addItem({ description: "Text Capability", event: this.textCapability, icon: "edit" }); + if (!this._gotDown) { + e.preventDefault(); + return; + } ContextMenu.Instance.addItem({ - description: "Sub", subitems: [ - { description: "Subitem 1", event: this.textCapability, icon: "smile" }, - { description: "Subitem 2", event: this.textCapability, icon: "smile" }, - { description: "Subitem 3", event: this.textCapability, icon: "smile" }, - { description: "Submenu", subitems: [{ description: "Inner Subitem", event: this.textCapability, icon: "smile" }] } - ] + description: NumCast(this.props.Document.nativeWidth) ? "Unfreeze" : "Freeze", + event: this.freezeNativeDimensions, + icon: "edit" }); } onPointerWheel = (e: React.WheelEvent): void => { - e.stopPropagation(); + if (this.props.isSelected()) { + e.stopPropagation(); + } } - tooltipMenuPlugin() { + onClick = (e: React.MouseEvent): void => { + this._proseRef.current!.focus(); + } + onMouseDown = (e: React.MouseEvent): void => { + if (!this.props.isSelected()) { // preventing default allows the onClick to be generated instead of being swallowed by the text box itself + e.preventDefault(); // bcz: this would normally be in OnPointerDown - however, if done there, no mouse move events will be generated which makes transititioning to GoldenLayout's drag interactions impossible + } + } + + tooltipTextMenuPlugin() { + let myprops = this.props; + let self = this; return new Plugin({ view(_editorView) { - return new TooltipTextMenu(_editorView) + return self._toolTipTextMenu = new TooltipTextMenu(_editorView, myprops); } - }) + }); } - onKeyPress(e: React.KeyboardEvent) { + _toolTipTextMenu: TooltipTextMenu | undefined = undefined; + tooltipLinkingMenuPlugin() { + let myprops = this.props; + return new Plugin({ + view(_editorView) { + return new TooltipLinkingMenu(_editorView, myprops); + } + }); + } + onBlur = (e: any) => { + if (this._undoTyping) { + this._undoTyping.end(); + this._undoTyping = undefined; + } + } + public _undoTyping?: UndoManager.Batch; + onKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + SelectionManager.DeselectAll(); + } e.stopPropagation(); + if (e.key === "Tab") e.preventDefault(); + // stop propagation doesn't seem to stop propagation of native keyboard events. + // so we set a flag on the native event that marks that the event's been handled. + (e.nativeEvent as any).DASHFormattedTextBoxHandled = true; + if (StrCast(this.props.Document.title).startsWith("-") && this._editorView) { + let str = this._editorView.state.doc.textContent; + let titlestr = str.substr(0, Math.min(40, str.length)); + let target = this.props.Document.proto ? this.props.Document.proto : this.props.Document; + target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + } + if (!this._undoTyping) { + this._undoTyping = UndoManager.StartBatch("undoTyping"); + } } render() { - return (<div className="formattedTextBox-cont" - onKeyPress={this.onKeyPress} - onPointerDown={this.onPointerDown} - onContextMenu={this.specificContextMenu} - onWheel={this.onPointerWheel} - ref={this._ref} />) - } -}
\ No newline at end of file + let style = this.props.isOverlay ? "scroll" : "hidden"; + let rounded = NumCast(this.props.Document.borderRounding) < 0 ? "-rounded" : ""; + let interactive = InkingControl.Instance.selectedTool ? "" : "interactive"; + return ( + <div className={`formattedTextBox-cont-${style}`} ref={this._ref} + style={{ + pointerEvents: interactive ? "all" : "none", + }} + onKeyDown={this.onKeyPress} + onKeyPress={this.onKeyPress} + onFocus={this.onFocused} + onClick={this.onClick} + onBlur={this.onBlur} + onPointerUp={this.onPointerUp} + onPointerDown={this.onPointerDown} + onMouseDown={this.onMouseDown} + onContextMenu={this.specificContextMenu} + // tfs: do we need this event handler + onWheel={this.onPointerWheel} + > + <div className={`formattedTextBox-inner${rounded}`} style={{ pointerEvents: this.props.Document.isButton && !this.props.isSelected() ? "none" : "all" }} ref={this._proseRef} /> + </div> + ); + } +} |