diff options
Diffstat (limited to 'src/client/views/nodes')
| -rw-r--r-- | src/client/views/nodes/DocumentView.tsx | 8 | ||||
| -rw-r--r-- | src/client/views/nodes/FontIconBox.tsx | 9 | ||||
| -rw-r--r-- | src/client/views/nodes/KeyValueBox.tsx | 4 | ||||
| -rw-r--r-- | src/client/views/nodes/PDFBox.tsx | 37 | ||||
| -rw-r--r-- | src/client/views/nodes/VideoBox.tsx | 28 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/DashFieldView.tsx | 4 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.tsx | 121 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx | 4 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts | 24 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/RichTextMenu.scss | 6 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/RichTextMenu.tsx | 224 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/RichTextRules.ts | 9 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/marks_rts.ts | 21 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/nodes_rts.ts | 2 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/prosemirrorPatches.js | 55 | 
15 files changed, 380 insertions, 176 deletions
| diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 3a3bef2e0..09eeaee36 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -689,7 +689,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu      @undoBatch      @action -    setAcl = (acl: "readOnly" | "addOnly" | "ownerOnly") => { +    setAcl = (acl: "readOnly" | "addOnly" | "ownerOnly" | "write") => {          this.dataDoc.ACL = this.props.Document.ACL = acl;          DocListCast(this.dataDoc[Doc.LayoutFieldKey(this.dataDoc)]).map(d => {              if (d.author === Doc.CurrentUserEmail) d.ACL = acl; @@ -699,7 +699,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu      }      @undoBatch      @action -    testAcl = (acl: "readOnly" | "addOnly" | "ownerOnly") => { +    testAcl = (acl: "readOnly" | "addOnly" | "ownerOnly" | "write") => {          this.dataDoc.author = this.props.Document.author = "ADMIN";          this.dataDoc.ACL = this.props.Document.ACL = acl;          DocListCast(this.dataDoc[Doc.LayoutFieldKey(this.dataDoc)]).map(d => { @@ -811,6 +811,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu          aclItems.push({ description: "Make Add Only", event: () => this.setAcl("addOnly"), icon: "concierge-bell" });          aclItems.push({ description: "Make Read Only", event: () => this.setAcl("readOnly"), icon: "concierge-bell" });          aclItems.push({ description: "Make Private", event: () => this.setAcl("ownerOnly"), icon: "concierge-bell" }); +        aclItems.push({ description: "Make Editable", event: () => this.setAcl("write"), icon: "concierge-bell" });          aclItems.push({ description: "Test Private", event: () => this.testAcl("ownerOnly"), icon: "concierge-bell" });          aclItems.push({ description: "Test Readonly", event: () => this.testAcl("readOnly"), icon: "concierge-bell" });          !existingAcls && cm.addItem({ description: "Privacy...", subitems: aclItems, icon: "question" }); @@ -1168,8 +1169,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu      }      render() { -        if (this.props.Document[AclSym] && this.props.Document[AclSym] === AclPrivate) return (null);          if (!(this.props.Document instanceof Doc)) return (null); +        if (this.props.Document[AclSym] && this.props.Document[AclSym] === AclPrivate) return (null); +        if (this.props.Document.hidden) return (null);          const backgroundColor = Doc.UserDoc().renderStyle === "comic" ? undefined : this.props.forcedBackgroundColor?.(this.Document) || StrCast(this.layoutDoc._backgroundColor) || StrCast(this.layoutDoc.backgroundColor) || StrCast(this.Document.backgroundColor) || this.props.backgroundColor?.(this.Document);          const opacity = Cast(this.layoutDoc._opacity, "number", Cast(this.layoutDoc.opacity, "number", Cast(this.Document.opacity, "number", null)));          const finalOpacity = this.props.opacity ? this.props.opacity() : opacity; diff --git a/src/client/views/nodes/FontIconBox.tsx b/src/client/views/nodes/FontIconBox.tsx index cf0b16c7c..5e8dd2497 100644 --- a/src/client/views/nodes/FontIconBox.tsx +++ b/src/client/views/nodes/FontIconBox.tsx @@ -5,7 +5,7 @@ import { createSchema, makeInterface } from '../../../fields/Schema';  import { DocComponent } from '../DocComponent';  import './FontIconBox.scss';  import { FieldView, FieldViewProps } from './FieldView'; -import { StrCast, Cast } from '../../../fields/Types'; +import { StrCast, Cast, NumCast } from '../../../fields/Types';  import { Utils } from "../../../Utils";  import { runInAction, observable, reaction, IReactionDisposer } from 'mobx';  import { Doc } from '../../../fields/Doc'; @@ -59,13 +59,14 @@ export class FontIconBox extends DocComponent<FieldViewProps, FontIconDocument>(      render() {          const referenceDoc = (this.layoutDoc.dragFactory instanceof Doc ? this.layoutDoc.dragFactory : this.layoutDoc); -        const referenceLayout = Doc.Layout(referenceDoc); +        const refLayout = Doc.Layout(referenceDoc);          return <button className="fontIconBox-outerDiv" title={StrCast(this.layoutDoc.title)} ref={this._ref} onContextMenu={this.specificContextMenu}              style={{ -                background: StrCast(referenceLayout.backgroundColor), +                padding: Cast(this.layoutDoc._xPadding, "number", null), +                background: StrCast(refLayout._backgroundColor, StrCast(refLayout.backgroundColor)),                  boxShadow: this.layoutDoc.ischecked ? `4px 4px 12px black` : undefined              }}> -            <FontAwesomeIcon className="fontIconBox-icon" icon={this.dataDoc.icon as any} color={this._foregroundColor} size="sm" /> +            <FontAwesomeIcon className="fontIconBox-icon" icon={this.dataDoc.icon as any} color={StrCast(this.layoutDoc.color, this._foregroundColor)} size="sm" />              {!this.rootDoc.label ? (null) : <div className="fontIconBox-label"> {StrCast(this.rootDoc.label).substring(0, 5)} </div>}          </button>;      } diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index d375466c9..b732f5f83 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -169,8 +169,8 @@ export class KeyValueBox extends React.Component<FieldViewProps> {      getTemplate = async () => {          const parent = Docs.Create.StackingDocument([], { _width: 800, _height: 800, title: "Template" }); -        parent.singleColumn = false; -        parent.columnWidth = 100; +        parent._columnsStack = false; +        parent._columnWidth = 100;          for (const row of this.rows.filter(row => row.isChecked)) {              await this.createTemplateField(parent, row);              row.uncheck(); diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 6b1c9fcde..eb2a85eeb 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -55,25 +55,28 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps, PdfDocum          const backup = "oldPath";          const { Document } = this.props; -        const { url: { href } } = Cast(this.dataDoc[this.props.fieldKey], PdfField)!; -        const pathCorrectionTest = /upload\_[a-z0-9]{32}.(.*)/g; -        const matches = pathCorrectionTest.exec(href); -        console.log("\nHere's the { url } being fed into the outer regex:"); -        console.log(href); -        console.log("And here's the 'properPath' build from the captured filename:\n"); -        if (matches !== null && href.startsWith(window.location.origin)) { -            const properPath = Utils.prepend(`/files/pdfs/${matches[0]}`); -            console.log(properPath); -            if (!properPath.includes(href)) { -                console.log(`The two (url and proper path) were not equal`); -                const proto = Doc.GetProto(Document); -                proto[this.props.fieldKey] = new PdfField(properPath); -                proto[backup] = href; +        const pdf = Cast(this.dataDoc[this.props.fieldKey], PdfField); +        const href = pdf?.url?.href; +        if (href) { +            const pathCorrectionTest = /upload\_[a-z0-9]{32}.(.*)/g; +            const matches = pathCorrectionTest.exec(href); +            console.log("\nHere's the { url } being fed into the outer regex:"); +            console.log(href); +            console.log("And here's the 'properPath' build from the captured filename:\n"); +            if (matches !== null && href.startsWith(window.location.origin)) { +                const properPath = Utils.prepend(`/files/pdfs/${matches[0]}`); +                console.log(properPath); +                if (!properPath.includes(href)) { +                    console.log(`The two (url and proper path) were not equal`); +                    const proto = Doc.GetProto(Document); +                    proto[this.props.fieldKey] = new PdfField(properPath); +                    proto[backup] = href; +                } else { +                    console.log(`The two (url and proper path) were equal`); +                }              } else { -                console.log(`The two (url and proper path) were equal`); +                console.log("Outer matches was null!");              } -        } else { -            console.log("Outer matches was null!");          }      } diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 71556bfd3..a5c6c4a48 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -19,6 +19,7 @@ import { FieldView, FieldViewProps } from './FieldView';  import "./VideoBox.scss";  import { documentSchema } from "../../../fields/documentSchemas";  import { Networking } from "../../Network"; +import { SnappingManager } from "../../util/SnappingManager";  const path = require('path');  export const timeSchema = createSchema({ @@ -58,21 +59,21 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD      @action public Play = (update: boolean = true) => {          this._playing = true; -        update && this.player && this.player.play(); -        update && this._youtubePlayer && this._youtubePlayer.playVideo(); +        update && this.player?.play(); +        update && this._youtubePlayer?.playVideo();          this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5));          this.updateTimecode();      }      @action public Seek(time: number) { -        this._youtubePlayer && this._youtubePlayer.seekTo(Math.round(time), true); +        this._youtubePlayer?.seekTo(Math.round(time), true);          this.player && (this.player.currentTime = time);      }      @action public Pause = (update: boolean = true) => {          this._playing = false; -        update && this.player && this.player.pause(); -        update && this._youtubePlayer && this._youtubePlayer.pauseVideo && this._youtubePlayer.pauseVideo(); +        update && this.player?.pause(); +        update && this._youtubePlayer?.pauseVideo();          this._youtubePlayer && this._playTimer && clearInterval(this._playTimer);          this._playTimer = undefined;          this.updateTimecode(); @@ -261,21 +262,20 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD          const onYoutubePlayerStateChange = (event: any) => runInAction(() => {              if (started && event.data === YT.PlayerState.PLAYING) {                  started = false; -                this._youtubePlayer && this._youtubePlayer.unMute(); -                this.Pause(); +                this._youtubePlayer?.unMute(); +                //this.Pause();                  return;              }              if (event.data === YT.PlayerState.PLAYING && !this._playing) this.Play(false);              if (event.data === YT.PlayerState.PAUSED && this._playing) this.Pause(false);          });          const onYoutubePlayerReady = (event: any) => { -            this._reactionDisposer && this._reactionDisposer(); -            this._youtubeReactionDisposer && this._youtubeReactionDisposer(); +            this._reactionDisposer?.(); +            this._youtubeReactionDisposer?.();              this._reactionDisposer = reaction(() => this.layoutDoc.currentTimecode, () => !this._playing && this.Seek((this.layoutDoc.currentTimecode || 0))); -            this._youtubeReactionDisposer = reaction(() => [this.props.isSelected(), DocumentDecorations.Instance.Interacting, Doc.GetSelectedTool()], () => { -                const interactive = Doc.GetSelectedTool() === InkTool.None && this.props.isSelected(true) && !DocumentDecorations.Instance.Interacting; -                iframe.style.pointerEvents = interactive ? "all" : "none"; -            }, { fireImmediately: true }); +            this._youtubeReactionDisposer = reaction( +                () => Doc.GetSelectedTool() === InkTool.None && this.props.isSelected(true) && !SnappingManager.GetIsDragging() && !DocumentDecorations.Instance.Interacting, +                (interactive) => iframe.style.pointerEvents = interactive ? "all" : "none", { fireImmediately: true });          };          this._youtubePlayer = new YT.Player(`${this.youtubeVideoId + this._youtubeIframeId}-player`, {              events: { @@ -346,7 +346,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD          const start = untracked(() => Math.round((this.layoutDoc.currentTimecode || 0)));          return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`}              onLoad={this.youtubeIframeLoaded} className={`${style}`} width={(this.layoutDoc._nativeWidth || 640)} height={(this.layoutDoc._nativeHeight || 390)} -            src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=1&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._showControls ? 1 : 0}`} />; +            src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=0&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._showControls ? 1 : 0}`} />;      }      @action.bound diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx index 8c16f4a1a..8718bf329 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.tsx +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -184,9 +184,9 @@ export class DashFieldViewInternal extends React.Component<IDashFieldViewInterna          if (container) {              const alias = Doc.MakeAlias(container.props.Document);              alias.viewType = CollectionViewType.Time; -            let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); +            let list = Cast(alias._columnHeaders, listSpec(SchemaHeaderField));              if (!list) { -                alias.schemaColumns = list = new List<SchemaHeaderField>(); +                alias._columnHeaders = list = new List<SchemaHeaderField>();              }              list.map(c => c.heading).indexOf(this._fieldKey) === -1 && list.push(new SchemaHeaderField(this._fieldKey, "#f1efeb"));              list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index d26954dbc..11f25a208 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -8,7 +8,7 @@ import { baseKeymap, selectAll } from "prosemirror-commands";  import { history } from "prosemirror-history";  import { inputRules } from 'prosemirror-inputrules';  import { keymap } from "prosemirror-keymap"; -import { Fragment, Mark, Node, Slice } from "prosemirror-model"; +import { Fragment, Mark, Node, Slice, Schema } from "prosemirror-model";  import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from "prosemirror-state";  import { ReplaceStep } from 'prosemirror-transform';  import { EditorView } from "prosemirror-view"; @@ -16,13 +16,14 @@ import { DateField } from '../../../../fields/DateField';  import { DataSym, Doc, DocListCast, DocListCastAsync, Field, HeightSym, Opt, WidthSym, AclSym } from "../../../../fields/Doc";  import { documentSchema } from '../../../../fields/documentSchemas';  import applyDevTools = require("prosemirror-dev-tools"); +import { removeMarkWithAttrs } from "./prosemirrorPatches";  import { Id } from '../../../../fields/FieldSymbols';  import { InkTool } from '../../../../fields/InkField';  import { PrefetchProxy } from '../../../../fields/Proxy';  import { RichTextField } from "../../../../fields/RichTextField";  import { RichTextUtils } from '../../../../fields/RichTextUtils';  import { createSchema, makeInterface } from "../../../../fields/Schema"; -import { Cast, DateCast, NumCast, StrCast } from "../../../../fields/Types"; +import { Cast, DateCast, NumCast, StrCast, ScriptCast } from "../../../../fields/Types";  import { TraceMobx, OVERRIDE_ACL } from '../../../../fields/util';  import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnOne, returnZero, Utils, setupMoveUpEvents } from '../../../../Utils';  import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; @@ -32,7 +33,7 @@ import { DocumentType } from '../../../documents/DocumentTypes';  import { DictationManager } from '../../../util/DictationManager';  import { DragManager } from "../../../util/DragManager";  import { makeTemplate } from '../../../util/DropConverter'; -import buildKeymap from "./ProsemirrorExampleTransfer"; +import buildKeymap, { updateBullets } from "./ProsemirrorExampleTransfer";  import RichTextMenu from './RichTextMenu';  import { RichTextRules } from "./RichTextRules"; @@ -56,7 +57,7 @@ import { DocumentButtonBar } from '../../DocumentButtonBar';  import { AudioBox } from '../AudioBox';  import { FieldView, FieldViewProps } from "../FieldView";  import "./FormattedTextBox.scss"; -import { FormattedTextBoxComment, formattedTextBoxCommentPlugin } from './FormattedTextBoxComment'; +import { FormattedTextBoxComment, formattedTextBoxCommentPlugin, findLinkMark } from './FormattedTextBoxComment';  import React = require("react");  library.add(faEdit); @@ -68,15 +69,10 @@ export interface FormattedTextBoxProps {      xMargin?: number;   // used to override document's settings for xMargin --- see CollectionCarouselView      yMargin?: number;  } - -const richTextSchema = createSchema({ -    documentText: "string", -}); -  export const GoogleRef = "googleDocId"; -type RichTextDocument = makeInterface<[typeof richTextSchema, typeof documentSchema]>; -const RichTextDocument = makeInterface(richTextSchema, documentSchema); +type RichTextDocument = makeInterface<[typeof documentSchema]>; +const RichTextDocument = makeInterface(documentSchema);  type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void; @@ -86,14 +82,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp      public static blankState = () => EditorState.create(FormattedTextBox.Instance.config);      public static Instance: FormattedTextBox;      public ProseRef?: HTMLDivElement; +    public get EditorView() { return this._editorView; }      private _ref: React.RefObject<HTMLDivElement> = React.createRef();      private _scrollRef: React.RefObject<HTMLDivElement> = React.createRef();      private _editorView: Opt<EditorView>;      private _applyingChange: boolean = false;      private _searchIndex = 0; +    private _cachedLinks: Doc[] = [];      private _undoTyping?: UndoManager.Batch;      private _disposers: { [name: string]: IReactionDisposer } = {}; -    private dropDisposer?: DragManager.DragDropDisposer; +    private _dropDisposer?: DragManager.DragDropDisposer;      @computed get _recording() { return this.dataDoc.audioState === "recording"; }      set _recording(value) { this.dataDoc.audioState = value ? "recording" : undefined; } @@ -145,6 +143,33 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp      public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } +    // removes all hyperlink anchors for the removed linkDoc +    // TODO: bcz: Argh... if a section of text has multiple anchors, this should just remove the intended one.  +    // but since removing one anchor from the list of attr anchors isn't implemented, this will end up removing nothing. +    public RemoveLinkFromDoc(linkDoc?: Doc) { +        const state = this._editorView?.state; +        if (state && linkDoc && this._editorView) { +            var allLinks: any[] = []; +            state.doc.nodesBetween(0, state.doc.nodeSize - 2, (node: any, pos: number, parent: any) => { +                const foundMark = findLinkMark(node.marks); +                const newHrefs = foundMark?.attrs.allLinks.filter((a: any) => a.href.includes(linkDoc[Id])) || []; +                allLinks = newHrefs.length ? newHrefs : allLinks; +                return true; +            }); +            if (allLinks.length) { +                this._editorView.dispatch(removeMarkWithAttrs(state.tr, 0, state.doc.nodeSize - 2, state.schema.marks.linkAnchor, { allLinks })); +            } +        } +    } +    // removes all the specified link referneces from the selection.  +    // NOTE: as above, this won't work correctly if there are marks with overlapping but not exact sets of link references. +    public RemoveLinkFromSelection(allLinks: { href: string, title: string, linkId: string, targetId: string }[]) { +        const state = this._editorView?.state; +        if (state && this._editorView) { +            this._editorView.dispatch(removeMarkWithAttrs(state.tr, state.selection.from, state.selection.to, state.schema.marks.link, { allLinks })); +        } +    } +      linkOnDeselect: Map<string, string> = new Map();      doLinkOnDeselect() { @@ -181,8 +206,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp                      this.linkOnDeselect.set(key, value);                      const id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key); -                    const allHrefs = [{ href: Utils.prepend("/doc/" + id), title: value, targetId: id }]; -                    const link = this._editorView.state.schema.marks.link.create({ allHrefs, location: "onRight", title: value }); +                    const allLinks = [{ href: Utils.prepend("/doc/" + id), title: value, targetId: id }]; +                    const link = this._editorView.state.schema.marks.linkAnchor.create({ allLinks, location: "onRight", title: value });                      const mval = this._editorView.state.schema.marks.metadataVal.create();                      const offset = (tx.selection.to === range!.end - 1 ? -1 : 0);                      tx = tx.addMark(textEndSelection - value.length + offset, textEndSelection, link).addMark(textEndSelection - value.length + offset, textEndSelection, mval); @@ -203,13 +228,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp              if (!this.dataDoc[AclSym]) {                  if (!this._applyingChange && json.replace(/"selection":.*/, "") !== curProto?.Data.replace(/"selection":.*/, "")) {                      this._applyingChange = true; -                    this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); +                    (curText !== Cast(this.dataDoc[this.fieldKey], RichTextField)?.Text) && (this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())));                      if ((!curTemp && !curProto) || curText || curLayout?.Data.includes("dash")) { // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended)                          if (json !== curLayout?.Data) {                              !curText && tx.storedMarks?.map(m => m.type.name === "pFontSize" && (Doc.UserDoc().fontSize = this.layoutDoc._fontSize = m.attrs.fontSize));                              !curText && tx.storedMarks?.map(m => m.type.name === "pFontFamily" && (Doc.UserDoc().fontFamily = this.layoutDoc._fontFamily = m.attrs.fontFamily));                              this.dataDoc[this.props.fieldKey] = new RichTextField(json, curText);                              this.dataDoc[this.props.fieldKey + "-noTemplate"] = (curTemp?.Text || "") !== curText; // mark the data field as being split from the template if it has been edited +                            ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.layoutDoc, self: this.rootDoc, text: curText });                          }                      } else { // if we've deleted all the text in a note driven by a template, then restore the template data                          this.dataDoc[this.props.fieldKey] = undefined; @@ -249,8 +275,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp              const lastSel = Math.min(flattened.length - 1, this._searchIndex);              this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex;              const alink = DocUtils.MakeLink({ doc: this.rootDoc }, { doc: target }, "automatic")!; -            const allHrefs = [{ href: Utils.prepend("/doc/" + alink[Id]), title: "a link", targetId: target[Id], linkId: alink[Id] }]; -            const link = this._editorView.state.schema.marks.link.create({ allHrefs, title: "a link", location }); +            const allLinks = [{ href: Utils.prepend("/doc/" + alink[Id]), title: "a link", targetId: target[Id], linkId: alink[Id] }]; +            const link = this._editorView.state.schema.marks.linkAnchor.create({ allLinks, title: "a link", location });              this._editorView.dispatch(tr.addMark(flattened[lastSel].from, flattened[lastSel].to, link));          }      } @@ -331,8 +357,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp      }      protected createDropTarget = (ele: HTMLDivElement) => {          this.ProseRef = ele; -        this.dropDisposer?.(); -        ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.layoutDoc)); +        this._dropDisposer?.(); +        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.layoutDoc));      }      @undoBatch @@ -658,9 +684,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp              let tr = state.tr.addMark(sel.from, sel.to, splitter);              sel.from !== sel.to && tr.doc.nodesBetween(sel.from, sel.to, (node: any, pos: number, parent: any) => {                  if (node.firstChild === null && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { -                    const allHrefs = [{ href, title, targetId, linkId }]; -                    allHrefs.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.link.name)?.attrs.allHrefs ?? [])); -                    const link = state.schema.marks.link.create({ allHrefs, title, location, linkId }); +                    const allLinks = [{ href, title, targetId, linkId }]; +                    allLinks.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.linkAnchor.name)?.attrs.allLinks ?? [])); +                    const link = state.schema.marks.linkAnchor.create({ allLinks, title, location, linkId });                      tr = tr.addMark(pos, pos + node.nodeSize, link);                  }              }); @@ -670,6 +696,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp          }      }      componentDidMount() { +        this._cachedLinks = DocListCast(this.Document.links); +        this._disposers.links = reaction(() => DocListCast(this.Document.links), // if a link is deleted, then remove all hyperlinks that reference it from the text's marks +            newLinks => { +                this._cachedLinks.forEach(l => !newLinks.includes(l) && this.RemoveLinkFromDoc(l)); +                this._cachedLinks = newLinks; +            });          this._disposers.buttonBar = reaction(              () => DocumentButtonBar.Instance,              instance => { @@ -700,8 +732,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp              incomingValue => {                  if (incomingValue !== undefined && this._editorView && !this._applyingChange) {                      const updatedState = JSON.parse(incomingValue); -                    this._editorView.updateState(EditorState.fromJSON(this.config, updatedState)); -                    this.tryUpdateHeight(); +                    if (JSON.stringify(this._editorView.state.toJSON()) !== JSON.stringify(updatedState)) { +                        this._editorView.updateState(EditorState.fromJSON(this.config, updatedState)); +                        this.tryUpdateHeight(); +                    }                  }              }          ); @@ -776,8 +810,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp                          return node.copy(content.frag);                      }                      const marks = [...node.marks]; -                    const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.link); -                    return linkIndex !== -1 && marks[linkIndex].attrs.allHrefs.find((item: { href: string }) => scrollToLinkID === item.href.replace(/.*\/doc\//, "")) ? node : undefined; +                    const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.linkAnchor); +                    return linkIndex !== -1 && marks[linkIndex].attrs.allLinks.find((item: { href: string }) => scrollToLinkID === item.href.replace(/.*\/doc\//, "")) ? node : undefined;                  };                  let start = 0; @@ -959,8 +993,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp              }              const marks = [...node.marks];              const linkIndex = marks.findIndex(mark => mark.type.name === "link"); -            const allHrefs = [{ href: Utils.prepend(`/doc/${linkId}`), title, linkId }]; -            const link = view.state.schema.mark(view.state.schema.marks.link, { allHrefs, location: "onRight", title, docref: true }); +            const allLinks = [{ href: Utils.prepend(`/doc/${linkId}`), title, linkId }]; +            const link = view.state.schema.mark(view.state.schema.marks.linkAnchor, { allLinks, location: "onRight", title, docref: true });              marks.splice(linkIndex === -1 ? 0 : linkIndex, 1, link);              return node.mark(marks);          } @@ -1014,7 +1048,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp          }          (selectOnLoad /* || !rtfField?.Text*/) && this._editorView!.focus();          // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet. -        this._editorView!.state.storedMarks = [...(this._editorView!.state.storedMarks ? this._editorView!.state.storedMarks : []), schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })]; +        if (!this._editorView!.state.storedMarks || !this._editorView!.state.storedMarks.some(mark => mark.type === schema.marks.user_mark)) { +            this._editorView!.state.storedMarks = [...(this._editorView!.state.storedMarks ? this._editorView!.state.storedMarks : []), schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })]; +        }      }      getFont(font: string) {          switch (font) { @@ -1204,18 +1240,27 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp          });      } -    public static HadSelection: boolean = false; -    onBlur = (e: any) => { -        FormattedTextBox.HadSelection = window.getSelection()?.toString() !== ""; -        //DictationManager.Controls.stop(false); +    public startUndoTypingBatch() { +        this._undoTyping = UndoManager.StartBatch("undoTyping"); +    } + +    public endUndoTypingBatch() { +        const wasUndoing = this._undoTyping;          if (this._undoTyping) {              this._undoTyping.end();              this._undoTyping = undefined;          } +        return wasUndoing; +    } +    public static HadSelection: boolean = false; +    onBlur = (e: any) => { +        FormattedTextBox.HadSelection = window.getSelection()?.toString() !== ""; +        //DictationManager.Controls.stop(false); +        this.endUndoTypingBatch();          this.doLinkOnDeselect();          // move the richtextmenu offscreen -        if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overMenu) RichTextMenu.Instance.jumpTo(-300, -300); +        if (!RichTextMenu.Instance.Pinned) RichTextMenu.Instance.delayHide();      }      _lastTimedMark: Mark | undefined = undefined; @@ -1249,11 +1294,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp          // this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(mark));          if (!this._undoTyping) { -            this._undoTyping = UndoManager.StartBatch("undoTyping"); +            this.startUndoTypingBatch();          }      }      ondrop = (eve: React.DragEvent) => { +        this._editorView!.dispatch(updateBullets(this._editorView!.state.tr, this._editorView!.state.schema));          eve.stopPropagation(); // drag n drop of text within text note will generate a new note if not caughst, as will dragging in from outside of Dash.      }      onscrolled = (ev: React.UIEvent) => { @@ -1321,7 +1367,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp                          color: this.props.color ? this.props.color : StrCast(this.layoutDoc[this.props.fieldKey + "-color"], this.props.hideOnLeave ? "white" : "inherit"),                          pointerEvents: interactive ? undefined : "none",                          fontSize: Cast(this.layoutDoc._fontSize, "number", null), -                        fontFamily: StrCast(this.layoutDoc._fontFamily, "inherit") +                        fontFamily: StrCast(this.layoutDoc._fontFamily, "inherit"), +                        transition: "opacity 1s"                      }}                      onContextMenu={this.specificContextMenu}                      onKeyDown={this.onKeyPress} @@ -1349,7 +1396,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp                          onScroll={this.onscrolled} onDrop={this.ondrop} >                          <div className={`formattedTextBox-inner${rounded}${selclass}`} ref={this.createDropTarget}                              style={{ -                                padding: `${Math.max(0, NumCast(this.layoutDoc._yMargin, this.props.yMargin || 0) + selPad)}px  ${NumCast(this.layoutDoc._xMargin, this.props.xMargin || 0) + selPad}px`, +                                padding: this.layoutDoc._textBoxPadding ? StrCast(this.layoutDoc._textBoxPadding) : `${Math.max(0, NumCast(this.layoutDoc._yMargin, this.props.yMargin || 0) + selPad)}px  ${NumCast(this.layoutDoc._xMargin, this.props.xMargin || 0) + selPad}px`,                                  pointerEvents: !this.props.isSelected() ? ((this.layoutDoc.isLinkButton || this.props.onClick) ? "none" : "all") : undefined                              }}                          /> diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx index 90f2c0aa6..4c90b6afd 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx @@ -27,7 +27,7 @@ export function findUserMark(marks: Mark[]): Mark | undefined {      return marks.find(m => m.attrs.userid);  }  export function findLinkMark(marks: Mark[]): Mark | undefined { -    return marks.find(m => m.type === schema.marks.link); +    return marks.find(m => m.type === schema.marks.linkAnchor);  }  export function findStartOfMark(rpos: ResolvedPos, view: EditorView, finder: (marks: Mark[]) => Mark | undefined) {      let before = 0; @@ -182,7 +182,7 @@ export class FormattedTextBoxComment {              state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node));              child = child || (nbef && state.selection.$from.nodeBefore);              const mark = child ? findLinkMark(child.marks) : undefined; -            const href = (!mark?.attrs.docref || naft === nbef) && mark?.attrs.allHrefs.find((item: { href: string }) => item.href)?.href || forceUrl; +            const href = (!mark?.attrs.docref || naft === nbef) && mark?.attrs.allLinks.find((item: { href: string }) => item.href)?.href || forceUrl;              if (forceUrl || (href && child && nbef && naft && mark?.attrs.showPreview)) {                  FormattedTextBoxComment.tooltipText.textContent = "external => " + href;                  (FormattedTextBoxComment.tooltipText as any).href = href; diff --git a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts index 1bbcb9fa8..9d69f4be7 100644 --- a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts +++ b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts @@ -1,7 +1,6 @@  import { chainCommands, exitCode, joinDown, joinUp, lift, deleteSelection, joinBackward, selectNodeBackward, setBlockType, splitBlockKeepMarks, toggleMark, wrapIn, newlineInCode } from "prosemirror-commands";  import { liftTarget } from "prosemirror-transform";  import { redo, undo } from "prosemirror-history"; -import { undoInputRule } from "prosemirror-inputrules";  import { Schema } from "prosemirror-model";  import { liftListItem, sinkListItem } from "./prosemirrorPatches.js";  import { splitListItem, wrapInList, } from "prosemirror-schema-list"; @@ -12,7 +11,6 @@ import { Doc, DataSym } from "../../../../fields/Doc";  import { FormattedTextBox } from "./FormattedTextBox";  import { Id } from "../../../../fields/FieldSymbols";  import { Docs } from "../../../documents/Documents"; -import { update } from "lodash";  const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; @@ -215,10 +213,13 @@ export default function buildKeymap<S extends Schema<any>>(schema: S, props: any                  marks && tx3.setStoredMarks([...marks]);                  dispatch(tx3);              })) { +                const fromattrs = state.selection.$from.node().attrs;                  if (!splitBlockKeepMarks(state, (tx3: Transaction) => { -                    splitMetadata(marks, tx3); -                    if (!liftListItem(schema.nodes.list_item)(tx3, dispatch as ((tx: Transaction<Schema<any, any>>) => void))) { -                        dispatch(tx3); +                    const tonode = tx3.selection.$to.node(); +                    const tx4 = tx3.setNodeMarkup(tx3.selection.to - 1, tonode.type, fromattrs, tonode.marks); +                    splitMetadata(marks, tx4); +                    if (!liftListItem(schema.nodes.list_item)(tx4, dispatch as ((tx: Transaction<Schema<any, any>>) => void))) { +                        dispatch(tx4);                      }                  })) {                      return false; @@ -281,19 +282,6 @@ export default function buildKeymap<S extends Schema<any>>(schema: S, props: any          return false;      }); -    // bind("^", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { -    //     let newNode = schema.nodes.footnote.create({}); -    //     if (dispatch && state.selection.from === state.selection.to) { -    //         let tr = state.tr; -    //         tr.replaceSelectionWith(newNode); // replace insertion with a footnote. -    //         dispatch(tr.setSelection(new NodeSelection( // select the footnote node to open its display -    //             tr.doc.resolve(  // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) -    //                 tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize)))); -    //         return true; -    //     } -    //     return false; -    // }); -      return keys;  } diff --git a/src/client/views/nodes/formattedText/RichTextMenu.scss b/src/client/views/nodes/formattedText/RichTextMenu.scss index 7a0718c16..fbc468292 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.scss +++ b/src/client/views/nodes/formattedText/RichTextMenu.scss @@ -77,6 +77,12 @@              color: white;          }      } +    .richTextMenu-divider { +        margin: auto; +        border-left: solid #ffffff70 0.5px; +        height: 20px; +        width: 1px; +    }  }  .link-menu { diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx index 839943aac..95d6c9fac 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.tsx +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -1,8 +1,8 @@  import React = require("react");  import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; -import { faBold, faCaretDown, faChevronLeft, faEyeDropper, faHighlighter, faIndent, faItalic, faLink, faPaintRoller, faPalette, faStrikethrough, faSubscript, faSuperscript, faUnderline } from "@fortawesome/free-solid-svg-icons"; +import { faBold, faCaretDown, faChevronLeft, faEyeDropper, faHighlighter, faOutdent, faIndent, faHandPointLeft, faHandPointRight, faItalic, faLink, faPaintRoller, faPalette, faStrikethrough, faSubscript, faSuperscript, faUnderline } from "@fortawesome/free-solid-svg-icons";  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, observable } from "mobx"; +import { action, observable, IReactionDisposer, reaction } from "mobx";  import { observer } from "mobx-react";  import { lift, wrapIn } from "prosemirror-commands";  import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos } from "prosemirror-model"; @@ -23,9 +23,10 @@ import { updateBullets } from "./ProsemirrorExampleTransfer";  import "./RichTextMenu.scss";  import { schema } from "./schema_rts";  import { TraceMobx } from "../../../../fields/util"; +import { UndoManager } from "../../../util/UndoManager";  const { toggleMark } = require("prosemirror-commands"); -library.add(faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller); +library.add(faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSuperscript, faSubscript, faOutdent, faIndent, faHandPointLeft, faHandPointRight, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller);  @observer @@ -68,6 +69,8 @@ export default class RichTextMenu extends AntimodeMenu {      @observable private currentLink: string | undefined = "";      @observable private showLinkDropdown: boolean = false; +    _reaction: IReactionDisposer | undefined; +    _delayHide = false;      constructor(props: Readonly<{}>) {          super(props);          RichTextMenu.Instance = this; @@ -138,6 +141,16 @@ export default class RichTextMenu extends AntimodeMenu {          ];      } +    componentDidMount() { +        this._reaction = reaction(() => SelectionManager.SelectedDocuments(), +            () => this._delayHide && !(this._delayHide = false) && this.fadeOut(true)); +    } +    componentWillUnmount() { +        this._reaction?.(); +    } + +    public delayHide = () => this._delayHide = true; +      @action      changeView(view: EditorView) {          this.view = view; @@ -147,16 +160,6 @@ export default class RichTextMenu extends AntimodeMenu {          this.updateFromDash(view, lastState, this.editorProps);      } -    public MakeLinkToSelection = (linkDocId: string, title: string, location: string, targetDocId: string): string => { -        if (this.view) { -            const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); -            this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link). -                addMark(this.view.state.selection.from, this.view.state.selection.to, link)); -            return this.view.state.selection.$from.nodeAfter?.text || ""; -        } -        return ""; -    } -      @action      public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) {          if (!view) { @@ -310,8 +313,11 @@ export default class RichTextMenu extends AntimodeMenu {          function onClick(e: React.PointerEvent) {              e.preventDefault();              e.stopPropagation(); -            self.view && command && command(self.view.state, self.view.dispatch, self.view); -            self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => { +                self.view && command && command(self.view.state, self.view.dispatch, self.view); +                self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view); +            }, "rich text menu command");              self.setActiveMarkButtons(self.getActiveMarksOnSelection());          } @@ -338,9 +344,10 @@ export default class RichTextMenu extends AntimodeMenu {          function onChange(e: React.ChangeEvent<HTMLSelectElement>) {              e.stopPropagation();              e.preventDefault(); +            self.TextView.endUndoTypingBatch();              options.forEach(({ label, mark, command }) => {                  if (e.target.value === label) { -                    self.view && mark && command(mark, self.view); +                    UndoManager.RunInBatch(() => self.view && mark && command(mark, self.view), "text mark dropdown");                  }              });          } @@ -361,9 +368,10 @@ export default class RichTextMenu extends AntimodeMenu {          const self = this;          function onChange(val: string) { +            self.TextView.endUndoTypingBatch();              options.forEach(({ label, node, command }) => {                  if (val === label) { -                    self.view && node && command(node); +                    UndoManager.RunInBatch(() => self.view && node && command(node), "nodes dropdown");                  }              });          } @@ -412,6 +420,85 @@ export default class RichTextMenu extends AntimodeMenu {          dispatch?.(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark));          return true;      } +    alignCenter = (state: EditorState<any>, dispatch: any) => { +        return this.alignParagraphs(state, "center", dispatch); +    } +    alignLeft = (state: EditorState<any>, dispatch: any) => { +        return this.alignParagraphs(state, "left", dispatch); +    } +    alignRight = (state: EditorState<any>, dispatch: any) => { +        return this.alignParagraphs(state, "right", dispatch); +    } + +    alignParagraphs(state: EditorState<any>, align: "left" | "right" | "center", dispatch: any) { +        var tr = state.tr; +        state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { +            if (node.type === schema.nodes.paragraph) { +                tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, align }, node.marks); +                return false; +            } +            return true; +        }); +        dispatch?.(tr); +        return true; +    } + +    insetParagraph(state: EditorState<any>, dispatch: any) { +        var tr = state.tr; +        state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { +            if (node.type === schema.nodes.paragraph) { +                const inset = (node.attrs.inset ? Number(node.attrs.inset) : 0) + 10; +                tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, inset }, node.marks); +                return false; +            } +            return true; +        }); +        dispatch?.(tr); +        return true; +    } +    outsetParagraph(state: EditorState<any>, dispatch: any) { +        var tr = state.tr; +        state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { +            if (node.type === schema.nodes.paragraph) { +                const inset = Math.max(0, (node.attrs.inset ? Number(node.attrs.inset) : 0) - 10); +                tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, inset }, node.marks); +                return false; +            } +            return true; +        }); +        dispatch?.(tr); +        return true; +    } + +    indentParagraph(state: EditorState<any>, dispatch: any) { +        var tr = state.tr; +        state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { +            if (node.type === schema.nodes.paragraph) { +                const nodeval = node.attrs.indent ? Number(node.attrs.indent) : undefined; +                const indent = !nodeval ? 25 : nodeval < 0 ? 0 : nodeval + 25; +                tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, indent }, node.marks); +                return false; +            } +            return true; +        }); +        dispatch?.(tr); +        return true; +    } + +    hangingIndentParagraph(state: EditorState<any>, dispatch: any) { +        var tr = state.tr; +        state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { +            if (node.type === schema.nodes.paragraph) { +                const nodeval = node.attrs.indent ? Number(node.attrs.indent) : undefined; +                const indent = !nodeval ? -25 : nodeval > 0 ? 0 : nodeval - 10; +                tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, indent }, node.marks); +                return false; +            } +            return true; +        }); +        dispatch?.(tr); +        return true; +    }      insertBlockquote(state: EditorState<any>, dispatch: any) {          const path = (state.selection.$from as any).path; @@ -423,6 +510,11 @@ export default class RichTextMenu extends AntimodeMenu {          return true;      } +    insertHorizontalRule(state: EditorState<any>, dispatch: any) { +        dispatch(state.tr.replaceSelectionWith(state.schema.nodes.horizontal_rule.create()).scrollIntoView()); +        return true; +    } +      @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; }      // todo: add brushes to brushMap to save with a style name @@ -439,7 +531,8 @@ export default class RichTextMenu extends AntimodeMenu {          function onBrushClick(e: React.PointerEvent) {              e.preventDefault();              e.stopPropagation(); -            self.view && self.fillBrush(self.view.state, self.view.dispatch); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => self.view && self.fillBrush(self.view.state, self.view.dispatch), "rt brush");          }          let label = "Stored marks: "; @@ -506,19 +599,24 @@ export default class RichTextMenu extends AntimodeMenu {      @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; }      @action setActiveColor(color: string) { this.activeFontColor = color; } +    get TextView() { return (this.view as any).TextView as FormattedTextBox; }      createColorButton() {          const self = this;          function onColorClick(e: React.PointerEvent) {              e.preventDefault();              e.stopPropagation(); -            self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch), "rt menu color"); +            self.TextView.EditorView!.focus();          }          function changeColor(e: React.PointerEvent, color: string) {              e.preventDefault();              e.stopPropagation();              self.setActiveColor(color); -            self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch), "rt menu color"); +            self.TextView.EditorView!.focus();          }          const button = @@ -563,13 +661,15 @@ export default class RichTextMenu extends AntimodeMenu {          function onHighlightClick(e: React.PointerEvent) {              e.preventDefault();              e.stopPropagation(); -            self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch), "rt highligher");          }          function changeHighlight(e: React.PointerEvent, color: string) {              e.preventDefault();              e.stopPropagation();              self.setActiveHighlight(color); -            self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch), "rt highlighter");          }          const button = @@ -609,7 +709,8 @@ export default class RichTextMenu extends AntimodeMenu {          const self = this;          function onLinkChange(e: React.ChangeEvent<HTMLInputElement>) { -            self.setCurrentLink(e.target.value); +            self.TextView.endUndoTypingBatch(); +            UndoManager.RunInBatch(() => self.setCurrentLink(e.target.value), "link change");          }          const link = this.currentLink ? this.currentLink : ""; @@ -636,7 +737,7 @@ export default class RichTextMenu extends AntimodeMenu {          const node = this.view.state.selection.$from.nodeAfter;          const link = node && node.marks.find(m => m.type.name === "link");          if (link) { -            const href = link.attrs.allHrefs.length > 0 ? link.attrs.allHrefs[0].href : undefined; +            const href = link.attrs.allLinks.length > 0 ? link.attrs.allLinks[0].href : undefined;              if (href) {                  if (href.indexOf(Utils.prepend("/doc/")) === 0) {                      const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; @@ -671,40 +772,28 @@ export default class RichTextMenu extends AntimodeMenu {      }      deleteLink = () => { -        if (!this.view) return; - -        const node = this.view.state.selection.$from.nodeAfter; -        const link = node && node.marks.find(m => m.type === this.view!.state.schema.marks.link); -        const href = link!.attrs.allHrefs.length > 0 ? link!.attrs.allHrefs[0].href : undefined; -        if (href) { -            if (href.indexOf(Utils.prepend("/doc/")) === 0) { -                const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; -                if (linkclicked) { -                    DocServer.GetRefField(linkclicked).then(async linkDoc => { -                        if (linkDoc instanceof Doc) { -                            LinkManager.Instance.deleteLink(linkDoc); -                            this.view!.dispatch(this.view!.state.tr.removeMark(this.view!.state.selection.from, this.view!.state.selection.to, this.view!.state.schema.marks.link)); -                        } -                    }); -                } -            } else { -                if (node) { -                    const { tr, schema, selection } = this.view.state; -                    const extension = this.linkExtend(selection.$anchor, href); -                    this.view.dispatch(tr.removeMark(extension.from, extension.to, schema.marks.link)); -                } +        if (this.view) { +            const link = this.view.state.selection.$from.nodeAfter?.marks.find(m => m.type === this.view!.state.schema.marks.linkAnchor); +            if (link) { +                const allLinks = link.attrs.allLinks.slice(); +                this.TextView.RemoveLinkFromSelection(link.attrs.allLinks); +                // bcz: Argh ... this will remove the link from the document even it's anchored somewhere else in the text which happens if only part of the anchor text was selected. +                allLinks.filter((aref: any) => aref?.href.indexOf(Utils.prepend("/doc/")) === 0).forEach((aref: any) => { +                    const linkId = aref.href.replace(Utils.prepend("/doc/"), "").split("?")[0]; +                    linkId && DocServer.GetRefField(linkId).then(linkDoc => LinkManager.Instance.deleteLink(linkDoc as Doc)); +                });              }          }      }      linkExtend($start: ResolvedPos, href: string) { -        const mark = this.view!.state.schema.marks.link; +        const mark = this.view!.state.schema.marks.linkAnchor;          let startIndex = $start.index();          let endIndex = $start.indexAfter(); -        while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.allHrefs.find((item: { href: string }) => item.href === href)).length) startIndex--; -        while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.allHrefs.find((item: { href: string }) => item.href === href)).length) endIndex++; +        while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.allLinks.find((item: { href: string }) => item.href === href)).length) startIndex--; +        while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.allLinks.find((item: { href: string }) => item.href === href)).length) endIndex++;          let startPos = $start.start();          let endPos = startPos; @@ -744,7 +833,7 @@ export default class RichTextMenu extends AntimodeMenu {          return ref_node;      } -    @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; } +    @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; }      @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; }      @action @@ -768,26 +857,41 @@ export default class RichTextMenu extends AntimodeMenu {          TraceMobx();          const row1 = <div className="antimodeMenu-row" key="row1" style={{ display: this.collapsed ? "none" : undefined }}>{[              !this.collapsed ? this.getDragger() : (null), -            this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)), -            this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)), -            this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)), -            this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)), -            this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)), -            this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)), +            !this.Pinned ? (null) : <> {[ +                this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)), +                this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)), +                this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)), +                this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)), +                this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)), +                this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)), +                <div className="richTextMenu-divider" /> +            ]}</>,              this.createColorButton(),              this.createHighlighterButton(),              this.createLinkButton(),              this.createBrushButton(), -            this.createButton("sort-amount-down", "Summarize", undefined, this.insertSummarizer), -            this.createButton("quote-left", "Blockquote", undefined, this.insertBlockquote), +            <div className="richTextMenu-divider" />, +            this.createButton("align-left", "Align Left", undefined, this.alignLeft), +            this.createButton("align-center", "Align Center", undefined, this.alignCenter), +            this.createButton("align-right", "Align Right", undefined, this.alignRight), +            this.createButton("indent", "Inset More", undefined, this.insetParagraph), +            this.createButton("outdent", "Inset Less", undefined, this.outsetParagraph), +            this.createButton("hand-point-left", "Hanging Indent", undefined, this.hangingIndentParagraph), +            this.createButton("hand-point-right", "Indent", undefined, this.indentParagraph),          ]}</div>;          const row2 = <div className="antimodeMenu-row row-2" key="antimodemenu row2">              {this.collapsed ? this.getDragger() : (null)}              <div key="row" style={{ display: this.collapsed ? "none" : undefined }}> +                <div className="richTextMenu-divider" />,                  {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions, "font size"),                  this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions, "font family"), -                this.createNodesDropdown(this.activeListType, this.listTypeOptions, "nodes")]} +                <div className="richTextMenu-divider" />, +                this.createNodesDropdown(this.activeListType, this.listTypeOptions, "nodes"), +                this.createButton("sort-amount-down", "Summarize", undefined, this.insertSummarizer), +                this.createButton("quote-left", "Blockquote", undefined, this.insertBlockquote), +                this.createButton("minus", "Horizontal Rule", undefined, this.insertHorizontalRule), +                <div className="richTextMenu-divider" />,]}              </div>              <div key="button">                  {/* <div key="collapser"> @@ -817,7 +921,7 @@ interface ButtonDropdownProps {  }  @observer -class ButtonDropdown extends React.Component<ButtonDropdownProps> { +export class ButtonDropdown extends React.Component<ButtonDropdownProps> {      @observable private showDropdown: boolean = false;      private ref: HTMLDivElement | null = null; diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index ba3230801..ca30dde9d 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -275,11 +275,11 @@ export class RichTextRules {                      if (!fieldKey) {                          if (docid) {                              DocServer.GetRefField(docid).then(docx => { -                                const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true, }, docid); +                                const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, }, docid);                                  DocUtils.Publish(target, docid, returnFalse, returnFalse);                                  DocUtils.MakeLink({ doc: this.Document }, { doc: target }, "portal to");                              }); -                            const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid }); +                            const link = state.schema.marks.linkAnchor.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid });                              return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 2).addMark(start, end - 3, link);                          }                          return state.tr; @@ -305,7 +305,7 @@ export class RichTextRules {                      if (!fieldKey && !docid) return state.tr;                      docid && DocServer.GetRefField(docid).then(docx => {                          if (!(docx instanceof Doc && docx)) { -                            const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true }, docid); +                            const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500 }, docid);                              DocUtils.Publish(docx, docid, returnFalse, returnFalse);                          }                      }); @@ -315,8 +315,6 @@ export class RichTextRules {                      return node ? state.tr.replaceRangeWith(start, end, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr;                  }), - -              // create an inline view of a tag stored under the '#' field              new InputRule(                  new RegExp(/#([a-zA-Z_\-]+[a-zA-Z_;\-0-9]*)\s$/), @@ -374,7 +372,6 @@ export class RichTextRules {              new InputRule(                  new RegExp(/%\)/),                  (state, match, start, end) => { -                      return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create());                  }), diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts index b09ac0678..3d7d71b14 100644 --- a/src/client/views/nodes/formattedText/marks_rts.ts +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -17,12 +17,12 @@ export const marks: { [index: string]: MarkSpec } = {              return ["div", { className: "dummy" }, 0];          }      }, -    // :: MarkSpec A link. Has `href` and `title` attributes. `title` +    // :: MarkSpec A linkAnchor. The anchor can have multiple links, where each link has an href URL and a title for use in menus and hover (Dash links have linkIDs & targetIDs). `title`      // defaults to the empty string. Rendered and parsed as an `<a>`      // element. -    link: { +    linkAnchor: {          attrs: { -            allHrefs: { default: [] as { href: string, title: string, linkId: string, targetId: string }[] }, +            allLinks: { default: [] as { href: string, title: string, linkId: string, targetId: string }[] },              showPreview: { default: true },              location: { default: null },              title: { default: null }, @@ -31,22 +31,22 @@ export const marks: { [index: string]: MarkSpec } = {          inclusive: false,          parseDOM: [{              tag: "a[href]", getAttrs(dom: any) { -                return { allHrefs: [{ href: dom.getAttribute("href"), title: dom.getAttribute("title"), linkId: dom.getAttribute("linkids"), targetId: dom.getAttribute("targetids") }], location: dom.getAttribute("location"), }; +                return { allLinks: [{ href: dom.getAttribute("href"), title: dom.getAttribute("title"), linkId: dom.getAttribute("linkids"), targetId: dom.getAttribute("targetids") }], location: dom.getAttribute("location"), };              }          }],          toDOM(node: any) { -            const targetids = node.attrs.allHrefs.reduce((p: string, item: { href: string, title: string, targetId: string, linkId: string }) => p + " " + item.targetId, ""); -            const linkids = node.attrs.allHrefs.reduce((p: string, item: { href: string, title: string, targetId: string, linkId: string }) => p + " " + item.linkId, ""); +            const targetids = node.attrs.allLinks.reduce((p: string, item: { href: string, title: string, targetId: string, linkId: string }) => p + " " + item.targetId, ""); +            const linkids = node.attrs.allLinks.reduce((p: string, item: { href: string, title: string, targetId: string, linkId: string }) => p + " " + item.linkId, "");              return node.attrs.docref && node.attrs.title ? -                ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, href: node.attrs.allHrefs[0].href, class: "prosemirror-attribution" }, node.attrs.title], ["br"]] : -                node.attrs.allHrefs.length === 1 ? -                    ["a", { ...node.attrs, class: linkids, targetids, title: `${node.attrs.title}`, href: node.attrs.allHrefs[0].href }, 0] : +                ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, href: node.attrs.allLinks[0].href, class: "prosemirror-attribution" }, node.attrs.title], ["br"]] : +                node.attrs.allLinks.length === 1 ? +                    ["a", { ...node.attrs, class: linkids, targetids, title: `${node.attrs.title}`, href: node.attrs.allLinks[0].href }, 0] :                      ["div", { class: "prosemirror-anchor" },                          ["span", { class: "prosemirror-linkBtn" },                              ["a", { ...node.attrs, class: linkids, targetids, title: `${node.attrs.title}` }, 0],                              ["input", { class: "prosemirror-hrefoptions" }],                          ], -                        ["div", { class: "prosemirror-links" }, ...node.attrs.allHrefs.map((item: { href: string, title: string }) => +                        ["div", { class: "prosemirror-links" }, ...node.attrs.allLinks.map((item: { href: string, title: string }) =>                              ["a", { class: "prosemirror-dropdownlink", href: item.href }, item.title]                          )]                      ]; @@ -270,6 +270,7 @@ export const marks: { [index: string]: MarkSpec } = {              userid: { default: "" },              modified: { default: "when?" }, // 1 second intervals since 1970          }, +        excludes: "user_mark",          group: "inline",          toDOM(node: any) {              const uid = node.attrs.userid.replace(".", "").replace("@", ""); diff --git a/src/client/views/nodes/formattedText/nodes_rts.ts b/src/client/views/nodes/formattedText/nodes_rts.ts index afb1f57b7..f83cff9b9 100644 --- a/src/client/views/nodes/formattedText/nodes_rts.ts +++ b/src/client/views/nodes/formattedText/nodes_rts.ts @@ -312,7 +312,7 @@ export const nodes: { [index: string]: NodeSpec } = {              const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : "";              return node.attrs.visibility ?                  ["li", { class: `${map}`, "data-mapStyle": node.attrs.mapStyle, "data-bulletStyle": node.attrs.bulletStyle }, 0] : -                ["li", { class: `${map}`, "data-mapStyle": node.attrs.mapStyle, "data-bulletStyle": node.attrs.bulletStyle }, "..."]; +                ["li", { class: `${map}`, "data-mapStyle": node.attrs.mapStyle, "data-bulletStyle": node.attrs.bulletStyle }, `${node.firstChild?.textContent}...`];          }      },  };
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/prosemirrorPatches.js b/src/client/views/nodes/formattedText/prosemirrorPatches.js index 763961958..0969ea4ef 100644 --- a/src/client/views/nodes/formattedText/prosemirrorPatches.js +++ b/src/client/views/nodes/formattedText/prosemirrorPatches.js @@ -9,6 +9,7 @@ var prosemirrorModel = require('prosemirror-model');  exports.liftListItem = liftListItem;  exports.sinkListItem = sinkListItem;  exports.wrappingInputRule = wrappingInputRule; +exports.removeMarkWithAttrs = removeMarkWithAttrs;  // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool  // Create a command to lift the list item around the selection up into  // a wrapping list. @@ -139,3 +140,57 @@ function wrappingInputRule(regexp, nodeType, getAttrs, joinPredicate, customWith  } +// :: ([Mark]) → ?Mark +// Tests whether there is a mark of this type in the given set. +function isInSetWithAttrs(mark, set, attrs) { +    for (var i = 0; i < set.length; i++) { +        if (set[i].type == mark) { +            if (Array.from(Object.keys(attrs)).reduce((p, akey) => { +                return p && JSON.stringify(set[i].attrs[akey]) === JSON.stringify(attrs[akey]); +            }, true)) { +                return set[i]; +            } +        } +    } +}; + +// :: (number, number, ?union<Mark, MarkType>) → this +// Remove marks from inline nodes between `from` and `to`. When `mark` +// is a single mark, remove precisely that mark. When it is a mark type, +// remove all marks of that type. When it is null, remove all marks of +// any type. +function removeMarkWithAttrs(tr, from, to, mark, attrs) { +    if (mark === void 0) mark = null; + +    var matched = [], step = 0; +    tr.doc.nodesBetween(from, to, function (node, pos) { +        if (!node.isInline) { return } +        step++; +        var toRemove = null; +        if (mark) { +            if (isInSetWithAttrs(mark, node.marks, attrs)) { toRemove = [mark]; } +        } else { +            toRemove = node.marks; +        } +        if (toRemove && toRemove.length) { +            var end = Math.min(pos + node.nodeSize, to); +            for (var i = 0; i < toRemove.length; i++) { +                var style = toRemove[i], found$1 = (void 0); +                for (var j = 0; j < matched.length; j++) { +                    var m = matched[j]; +                    if (m.step == step - 1 && style.eq(matched[j].style)) { found$1 = m; } +                } +                if (found$1) { +                    found$1.to = end; +                    found$1.step = step; +                } else { +                    matched.push({ style: style, from: Math.max(pos, from), to: end, step: step }); +                } +            } +        } +    }); +    matched.forEach(function (m) { return tr.step(new prosemirrorTransform.RemoveMarkStep(m.from, m.to, m.style)); }); +    return tr +}; + + | 
