diff options
Diffstat (limited to 'src/client/views/nodes/formattedText')
11 files changed, 888 insertions, 208 deletions
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index 678494b27..afdd8fea2 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -37,6 +37,7 @@ position: absolute; } } + .formattedTextBox-outer { position: relative; overflow: auto; @@ -72,7 +73,7 @@ .collectionfreeformview-container { position: relative; } - + >.formattedTextBox-sidebar-handle { right: unset; left: -5; @@ -95,7 +96,7 @@ .formattedTextBox-inner-rounded, .formattedTextBox-inner-rounded-selected, .formattedTextBox-inner, .formattedTextBox-inner-selected { height: 100%; - white-space: pre-wrap; + white-space: pre-wrap; .ProseMirror:hover { background: rgba(200,200,200,0.8); } @@ -262,19 +263,19 @@ footnote::after { border:unset; padding:0px; } - + .prosemirror-links a { float: left; color: white; text-decoration: none; border-radius: 3px; } - + .prosemirror-links a:hover { background-color: #eee; color: black; } - + .prosemirror-anchor:hover .prosemirror-links { display: grid; } @@ -302,27 +303,26 @@ footnote::after { font-family: inherit; } ol { - margin-left: 1em; font-family: inherit; } - .bullet { p {display: inline-block; font-family: inherit} margin-left: 0; } - .bullet1 { p {display: inline-block; font-family: inherit} } - .bullet2,.bullet3,.bullet4,.bullet5,.bullet6 { p {display: inline-block; font-family: inherit} font-size: smaller; } + .bullet { p { font-family: inherit} margin-left: 0; } + .bullet1 { p { font-family: inherit} } + .bullet2,.bullet3,.bullet4,.bullet5,.bullet6 { p { font-family: inherit} font-size: smaller; } .decimal1-ol { counter-reset: deci1; p {display: inline-block; font-family: inherit} margin-left: 0; } - .decimal2-ol { counter-reset: deci2; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 1em;} - .decimal3-ol { counter-reset: deci3; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 2em;} - .decimal4-ol { counter-reset: deci4; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 3em;} + .decimal2-ol { counter-reset: deci2; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 2.1em;} + .decimal3-ol { counter-reset: deci3; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 2.85em;} + .decimal4-ol { counter-reset: deci4; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 3.85em;} .decimal5-ol { counter-reset: deci5; p {display: inline-block; font-family: inherit} font-size: smaller; } .decimal6-ol { counter-reset: deci6; p {display: inline-block; font-family: inherit} font-size: smaller; } .decimal7-ol { counter-reset: deci7; p {display: inline-block; font-family: inherit} font-size: smaller; } .multi1-ol { counter-reset: multi1; p {display: inline-block; font-family: inherit} margin-left: 0; padding-left: 1.2em } - .multi2-ol { counter-reset: multi2; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 1.4em;} - .multi3-ol { counter-reset: multi3; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 2em;} - .multi4-ol { counter-reset: multi4; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 3.4em;} - - .bullet:before, .bullet1:before, .bullet2:before, .bullet3:before, .bullet4:before, .bullet5:before { transition: 0.5s; display: inline-block; vertical-align: top; margin-left: -1em; width: 1em; content:" " } + .multi2-ol { counter-reset: multi2; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 2em;} + .multi3-ol { counter-reset: multi3; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 2.85em;} + .multi4-ol { counter-reset: multi4; p {display: inline-block; font-family: inherit} font-size: smaller; padding-left: 3.85em;} + + //.bullet:before, .bullet1:before, .bullet2:before, .bullet3:before, .bullet4:before, .bullet5:before { transition: 0.5s; display: inline-block; vertical-align: top; margin-left: -1em; width: 1em; content:" " } .decimal1:before { transition: 0.5s;counter-increment: deci1; display: inline-block; vertical-align: top; margin-left: -1em; width: 1em; content: counter(deci1) ". "; } .decimal2:before { transition: 0.5s;counter-increment: deci2; display: inline-block; vertical-align: top; margin-left: -2.1em; width: 2.1em; content: counter(deci1) "."counter(deci2) ". "; } @@ -331,8 +331,8 @@ footnote::after { .decimal5:before { transition: 0.5s;counter-increment: deci5; display: inline-block; vertical-align: top; margin-left: -2em; width: 5em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) ". "; } .decimal6:before { transition: 0.5s;counter-increment: deci6; display: inline-block; vertical-align: top; margin-left: -2em; width: 6em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) ". "; } .decimal7:before { transition: 0.5s;counter-increment: deci7; display: inline-block; vertical-align: top; margin-left: -2em; width: 7em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) "."counter(deci7) ". "; } - - .multi1:before { transition: 0.5s;counter-increment: multi1; display: inline-block; vertical-align: top; margin-left: -1em; width: 1.2em; content: counter(multi1, upper-alpha) ". "; } + + .multi1:before { transition: 0.5s;counter-increment: multi1; display: inline-block; vertical-align: top; margin-left: -1.3em; width: 1.2em; content: counter(multi1, upper-alpha) ". "; } .multi2:before { transition: 0.5s;counter-increment: multi2; display: inline-block; vertical-align: top; margin-left: -2em; width: 2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) ". "; } .multi3:before { transition: 0.5s;counter-increment: multi3; display: inline-block; vertical-align: top; margin-left: -2.85em; width:2.85em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) ". "; } .multi4:before { transition: 0.5s;counter-increment: multi4; display: inline-block; vertical-align: top; margin-left: -4.2em; width: 4.2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) "."counter(multi4, lower-roman) ". "; } @@ -346,4 +346,284 @@ footnote::after { .ProseMirror:hover { background: unset; } -}
\ No newline at end of file +} + +@media only screen and (max-width: 1000px) { + @import "../../globalCssVariables"; + + .ProseMirror { + width: 100%; + height: 100%; + min-height: 100%; + } + + .ProseMirror:focus { + outline: none !important; + } + + .formattedTextBox-cont { + touch-action: none; + cursor: text; + background: inherit; + padding: 0; + border-width: 0px; + border-radius: inherit; + border-color: $intermediate-color; + box-sizing: border-box; + background-color: inherit; + border-style: solid; + overflow-y: auto; + overflow-x: hidden; + color: initial; + max-height: 100%; + display: flex; + flex-direction: row; + transition: opacity 1s; + + .formattedTextBox-dictation { + height: 12px; + width: 10px; + top: 0px; + left: 0px; + position: absolute; + } + } + + .formattedTextBox-outer { + position: relative; + overflow: auto; + display: inline-block; + width: 100%; + height: 100%; + } + + .formattedTextBox-sidebar-handle { + position: absolute; + top: calc(50% - 17.5px); + width: 10px; + height: 35px; + background: lightgray; + border-radius: 20px; + cursor:grabbing; + } + + .formattedTextBox-cont>.formattedTextBox-sidebar-handle { + right: 0; + left: unset; + } + + .formattedTextBox-sidebar, + .formattedTextBox-sidebar-inking { + border-left: dashed 1px black; + height: 100%; + display: inline-block; + position: absolute; + right: 0; + + .collectionfreeformview-container { + position: relative; + } + + >.formattedTextBox-sidebar-handle { + right: unset; + left: -5; + } + } + + .formattedTextBox-sidebar-inking { + pointer-events: all; + } + + .formattedTextBox-inner-rounded { + height: 70%; + width: 85%; + position: absolute; + overflow: auto; + top: 15%; + left: 10%; + } + + .formattedTextBox-inner-rounded, + .formattedTextBox-inner { + height: 100%; + white-space: pre-wrap; + hr { + display: block; + unicode-bidi: isolate; + margin-block-start: 0.5em; + margin-block-end: 0.5em; + margin-inline-start: auto; + margin-inline-end: auto; + overflow: hidden; + border-style: inset; + border-width: 1px; + } + } + + // .menuicon { + // display: inline-block; + // border-right: 1px solid rgba(0, 0, 0, 0.2); + // color: #888; + // line-height: 1; + // padding: 0 7px; + // margin: 1px; + // cursor: pointer; + // text-align: center; + // min-width: 1.4em; + // } + + .strong, + .heading { + font-weight: bold; + } + + .em { + font-style: italic; + } + + .userMarkOpen { + background: rgba(255, 255, 0, 0.267); + display: inline; + } + + .userMark { + background: rgba(255, 255, 0, 0.267); + font-size: 2px; + display: inline-grid; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 10px; + min-height: 10px; + text-align: center; + align-content: center; + } + + footnote { + display: inline-block; + position: relative; + cursor: pointer; + + div { + padding: 0 !important; + } + } + + footnote::after { + content: counter(prosemirror-footnote); + vertical-align: super; + font-size: 75%; + counter-increment: prosemirror-footnote; + } + + .ProseMirror { + counter-reset: prosemirror-footnote; + } + + .footnote-tooltip { + cursor: auto; + font-size: 75%; + position: absolute; + left: -30px; + top: calc(100% + 10px); + background: silver; + padding: 3px; + border-radius: 2px; + max-width: 100px; + min-width: 50px; + width: max-content; + } + + .prosemirror-attribution { + font-size: 8px; + } + + .footnote-tooltip::before { + border: 5px solid silver; + border-top-width: 0px; + border-left-color: transparent; + border-right-color: transparent; + position: absolute; + top: -5px; + left: 27px; + content: " "; + height: 0; + width: 0; + } + + + .formattedTextBox-inlineComment { + position: relative; + width: 40px; + height: 20px; + &::before { + content: "→"; + } + &:hover { + background: orange; + } + } + + .formattedTextBox-summarizer { + opacity: 0.5; + position: relative; + width: 40px; + height: 20px; + &::after { + content: "←"; + } + } + + .formattedTextBox-summarizer-collapsed { + opacity: 0.5; + position: relative; + width: 40px; + height: 20px; + &::after { + content: "..."; + } + } + + .ProseMirror { + touch-action: none; + span { + font-family: inherit; + } + + ol, ul { + counter-reset: deci1 0 multi1 0; + padding-left: 1em; + font-family: inherit; + } + ol { + margin-left: 1em; + font-family: inherit; + } + + .decimal1-ol { counter-reset: deci1; p {display: inline; font-family: inherit} margin-left: 0; } + .decimal2-ol { counter-reset: deci2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1em;} + .decimal3-ol { counter-reset: deci3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} + .decimal4-ol { counter-reset: deci4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3em;} + .decimal5-ol { counter-reset: deci5; p {display: inline; font-family: inherit} font-size: smaller; } + .decimal6-ol { counter-reset: deci6; p {display: inline; font-family: inherit} font-size: smaller; } + .decimal7-ol { counter-reset: deci7; p {display: inline; font-family: inherit} font-size: smaller; } + + .multi1-ol { counter-reset: multi1; p {display: inline; font-family: inherit} margin-left: 0; padding-left: 1.2em } + .multi2-ol { counter-reset: multi2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1.4em;} + .multi3-ol { counter-reset: multi3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} + .multi4-ol { counter-reset: multi4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3.4em;} + + .decimal1:before { transition: 0.5s;counter-increment: deci1; display: inline-block; margin-left: -1em; width: 1em; content: counter(deci1) ". "; } + .decimal2:before { transition: 0.5s;counter-increment: deci2; display: inline-block; margin-left: -2.1em; width: 2.1em; content: counter(deci1) "."counter(deci2) ". "; } + .decimal3:before { transition: 0.5s;counter-increment: deci3; display: inline-block; margin-left: -2.85em;width: 2.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) ". "; } + .decimal4:before { transition: 0.5s;counter-increment: deci4; display: inline-block; margin-left: -3.85em;width: 3.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) ". "; } + .decimal5:before { transition: 0.5s;counter-increment: deci5; display: inline-block; margin-left: -2em; width: 5em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) ". "; } + .decimal6:before { transition: 0.5s;counter-increment: deci6; display: inline-block; margin-left: -2em; width: 6em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) ". "; } + .decimal7:before { transition: 0.5s;counter-increment: deci7; display: inline-block; margin-left: -2em; width: 7em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) "."counter(deci7) ". "; } + + .multi1:before { transition: 0.5s;counter-increment: multi1; display: inline-block; margin-left: -1em; width: 1.2em; content: counter(multi1, upper-alpha) ". "; } + .multi2:before { transition: 0.5s;counter-increment: multi2; display: inline-block; margin-left: -2em; width: 2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) ". "; } + .multi3:before { transition: 0.5s;counter-increment: multi3; display: inline-block; margin-left: -2.85em; width:2.85em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) ". "; } + .multi4:before { transition: 0.5s;counter-increment: multi4; display: inline-block; margin-left: -4.2em; width: 4.2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) "."counter(multi4, lower-roman) ". "; } + } +} diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 2cb55e0fa..8e3087672 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,6 +16,7 @@ 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'; @@ -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,8 +57,9 @@ 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"); +import { DocumentManager } from '../../../util/DocumentManager'; library.add(faEdit); library.add(faSmile, faTextHeight, faUpload); @@ -68,15 +70,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 +83,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 +144,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 +207,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); @@ -250,8 +276,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)); } } @@ -271,7 +297,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } public unhighlightSearchTerms = () => { - if (this._editorView && (this._editorView as any).docView) { + if (window.screen.width < 600) null; + else if (this._editorView && (this._editorView as any).docView) { const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); const end = this._editorView.state.doc.nodeSize - 2; @@ -291,8 +318,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 @@ -618,9 +645,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); } }); @@ -630,6 +657,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 => { @@ -660,8 +693,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(); + } } } ); @@ -733,8 +768,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; @@ -916,8 +951,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); } @@ -997,6 +1032,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp _downX = 0; _downY = 0; _break = false; + _collapsed = false; onPointerDown = (e: React.PointerEvent): void => { if (this._recording && !e.ctrlKey && e.button === 0) { this.stopDictation(true); @@ -1045,7 +1081,42 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } @action + onDoubleClick = (e: React.MouseEvent): void => { + + this.doLinkOnDeselect(); + FormattedTextBox._downEvent = true; + FormattedTextBoxComment.textBox = this; + if (this.props.onClick && e.button === 0 && !this.props.isSelected(false)) { + e.preventDefault(); + } + if (e.button === 0 && this.props.isSelected(true) && !e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // stop propagation if not in sidebar + e.stopPropagation(); // if the text box is selected, then it consumes all down events + } + } + if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { + e.preventDefault(); + } + FormattedTextBoxComment.Hide(); + if (FormattedTextBoxComment.linkDoc) { + if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { + this.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); + } else { + DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, this.props.Document, + (doc: Doc, followLinkLocation: string) => this.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); + } + } + + (e.nativeEvent as any).formattedHandled = true; + + if (e.buttons === 1 && this.props.isSelected(true) && !e.altKey) { + e.stopPropagation(); + } + } + + @action onFocused = (e: React.FocusEvent): void => { + console.log("FOUCSS"); FormattedTextBox.FocusedBox = this; this.tryUpdateHeight(); @@ -1077,9 +1148,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp static _bulletStyleSheet: any = addStyleSheet(); static _userStyleSheet: any = addStyleSheet(); - + _forceUncollapse = true; // if the cursor doesn't move between clicks, then the selection will disappear for some reason. This flags the 2nd click as happening on a selection which allows bullet points to toggle + _forceDownNode: Node | undefined; onClick = (e: React.MouseEvent): void => { - if ((this._editorView!.root as any).getSelection().isCollapsed) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text. + if (Math.abs(e.clientX - this._downX) > 4 || Math.abs(e.clientY - this._downY) > 4) { + this._forceDownNode = undefined; + return; + } + if (!this._forceUncollapse || (this._editorView!.root as any).getSelection().isCollapsed) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text. const pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); const node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text) if (pcords && node?.type === this._editorView!.state.schema.nodes.dashComment) { @@ -1091,6 +1167,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp if (e.clientY > lastNode?.getBoundingClientRect().bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, this._editorView!.state.doc.content.size))); } + } else if ([this._editorView!.state.schema.nodes.ordered_list, this._editorView!.state.schema.nodes.listItem].includes(node?.type) && + node !== (this._editorView!.state.selection as NodeSelection)?.node && pcords) { + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(NodeSelection.create(this._editorView!.state.doc, pcords.pos!))); } } if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); return; } @@ -1098,12 +1177,15 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp if (this.props.isSelected(true)) { // if text box is selected, then it consumes all click events e.stopPropagation(); - this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); + this.hitBulletTargets(e.clientX, e.clientY, !this._editorView?.state.selection.empty || this._forceUncollapse, false, this._forceDownNode, e.shiftKey); } + this._forceUncollapse = !(this._editorView!.root as any).getSelection().isCollapsed; + this._forceDownNode = (this._editorView!.state.selection as NodeSelection)?.node; } // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. - hitBulletTargets(x: number, y: number, collapse: boolean, highlightOnly: boolean) { + hitBulletTargets(x: number, y: number, collapse: boolean, highlightOnly: boolean, downNode: Node | undefined = undefined, selectOrderedList: boolean = false) { + this._forceUncollapse = false; clearStyleSheetRules(FormattedTextBox._bulletStyleSheet); const clickPos = this._editorView!.posAtCoords({ left: x, top: y }); let olistPos = clickPos?.pos; @@ -1119,20 +1201,18 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp $olistPos = this._editorView?.state.doc.resolve(($olistPos as any).path[($olistPos as any).path.length - 4]); } } + const listPos = this._editorView?.state.doc.resolve(clickPos.pos); const listNode = this._editorView?.state.doc.nodeAt(clickPos.pos); - if (olistNode && olistNode.type === this._editorView?.state.schema.nodes.ordered_list) { - if (!collapse) { - if (!highlightOnly) { - this._editorView.dispatch(this._editorView.state.tr.setSelection(new NodeSelection($olistPos!))); - } - addStyleSheetRule(FormattedTextBox._bulletStyleSheet, olistNode.attrs.mapStyle + olistNode.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); - } else if (listNode && listNode.type === this._editorView.state.schema.nodes.list_item) { - if (!highlightOnly) { + if (olistNode && olistNode.type === this._editorView?.state.schema.nodes.ordered_list && listNode) { + if (!highlightOnly) { + if (selectOrderedList || (!collapse && listNode.attrs.visibility)) { + this._editorView.dispatch(this._editorView.state.tr.setSelection(new NodeSelection(selectOrderedList ? $olistPos! : listPos!))); + } else if (!listNode.attrs.visibility || downNode === listNode) { this._editorView.dispatch(this._editorView.state.tr.setNodeMarkup(clickPos.pos, listNode.type, { ...listNode.attrs, visibility: !listNode.attrs.visibility })); this._editorView.dispatch(this._editorView.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, clickPos.pos))); } - addStyleSheetRule(FormattedTextBox._bulletStyleSheet, olistNode.attrs.mapStyle + olistNode.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); } + addStyleSheetRule(FormattedTextBox._bulletStyleSheet, olistNode.attrs.mapStyle + olistNode.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); } } } @@ -1140,7 +1220,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp e.stopPropagation(); const view = this._editorView as any; - // this interposes on prosemirror's upHandler to prevent prosemirror's up from invoked multiple times when there + // this interposes on prosemirror's upHandler to prevent prosemirror's up from invoked multiple times when there // are nested prosemirrors. We only want the lowest level prosemirror to be invoked. if (view.mouseDown) { const originalUpHandler = view.mouseDown.up; @@ -1163,18 +1243,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; @@ -1208,11 +1297,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) => { @@ -1280,7 +1370,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} @@ -1302,13 +1393,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } } })} + onDoubleClick={this.onDoubleClick} > <div className={`formattedTextBox-outer`} ref={this._scrollRef} style={{ width: `calc(100% - ${this.sidebarWidthPercent})`, pointerEvents: !this.props.isSelected() ? "none" : undefined }} onScroll={this.onscrolled} onDrop={this.ondrop} > <div className={`formattedTextBox-inner${rounded}${selclass}`} ref={this.createDropTarget} style={{ - padding: this.layoutDoc._textBoxPadding ? 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`, + 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 }} /> @@ -1348,7 +1440,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp setTimeout(() => this._editorView!.focus(), 500); e.stopPropagation(); }} > - <FontAwesomeIcon className="formattedTExtBox-audioFont" + <FontAwesomeIcon className="formattedTextBox-audioFont" style={{ color: this._recording ? "red" : "blue", opacity: this._recording ? 1 : 0.5, display: this.props.isSelected() ? "" : "none" }} icon={"microphone"} size="sm" /> </div>} </div> diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss index 2dd63ec21..9089e7039 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss @@ -8,10 +8,12 @@ margin-bottom: 7px; -webkit-transform: translateX(-50%); transform: translateX(-50%); - } - .FormattedTextBox-tooltip:before { +} + +.FormattedTextBox-tooltip:before { content: ""; - height: 0; width: 0; + height: 0; + width: 0; position: absolute; left: 50%; margin-left: -5px; @@ -19,10 +21,12 @@ border: 5px solid transparent; border-bottom-width: 0; border-top-color: silver; - } - .FormattedTextBox-tooltip:after { +} + +.FormattedTextBox-tooltip:after { content: ""; - height: 0; width: 0; + height: 0; + width: 0; position: absolute; left: 50%; margin-left: -5px; @@ -30,4 +34,72 @@ border: 5px solid transparent; border-bottom-width: 0; border-top-color: white; - }
\ No newline at end of file +} + +.FormattedTextBoxComment-buttons { + display: none; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + + .FormattedTextBoxComment-button { + width: 20px; + height: 20px; + margin: 0; + margin-right: 6px; + border-radius: 50%; + pointer-events: auto; + background-color: rgb(38, 40, 41); + color: rgb(178, 181, 184); + font-size: 65%; + transition: transform 0.2s; + text-align: center; + position: relative; + + // margin-top: "auto"; + // margin-bottom: "auto"; + // background: black; + // color: white; + // display: inline-block; + // border-radius: 18px; + // font-size: 12.5px; + // width: 18px; + // height: 18px; + // margin-top: auto; + // margin-bottom: auto; + // margin-right: 3px; + // cursor: pointer; + // transition: transform 0.2s; + + .FormattedTextBoxComment-fa-icon { + margin-top: "auto"; + margin-bottom: "auto"; + background: black; + color: white; + display: inline-block; + border-radius: 18px; + font-size: 12.5px; + width: 18px; + height: 18px; + margin-top: auto; + margin-bottom: auto; + margin-right: 3px; + cursor: pointer; + transition: transform 0.2s; + // position: absolute; + // top: 50%; + // left: 50%; + // transform: translate(-50%, -50%); + } + + &:last-child { + margin-right: 0; + } + + &:hover { + background: rgb(53, 146, 199); + ; + } + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx index 90f2c0aa6..56826e5c7 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx @@ -2,7 +2,7 @@ import { Mark, ResolvedPos } from "prosemirror-model"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import * as ReactDOM from 'react-dom'; -import { Doc, DocCastAsync } from "../../../../fields/Doc"; +import { Doc, DocCastAsync, Opt } from "../../../../fields/Doc"; import { Cast, FieldValue, NumCast } from "../../../../fields/Types"; import { emptyFunction, returnEmptyString, returnFalse, Utils, emptyPath, returnZero, returnOne, returnEmptyFilter } from "../../../../Utils"; import { DocServer } from "../../../DocServer"; @@ -16,6 +16,11 @@ import React = require("react"); import { Docs } from "../../../documents/Documents"; import wiki from "wikijs"; import { DocumentType } from "../../../documents/DocumentTypes"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { action } from "mobx"; +import { LinkManager } from "../../../util/LinkManager"; +import { LinkDocPreview } from "../LinkDocPreview"; +import { DocumentLinksButton } from "../DocumentLinksButton"; export let formattedTextBoxCommentPlugin = new Plugin({ view(editorView) { return new FormattedTextBoxComment(editorView); } @@ -27,7 +32,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; @@ -62,6 +67,10 @@ export class FormattedTextBoxComment { static mark: Mark; static textBox: FormattedTextBox | undefined; static linkDoc: Doc | undefined; + + static _deleteRef: Opt<HTMLDivElement | null>; + static _followRef: Opt<HTMLDivElement | null>; + constructor(view: any) { if (!FormattedTextBoxComment.tooltip) { const root = document.getElementById("root"); @@ -75,8 +84,8 @@ export class FormattedTextBoxComment { FormattedTextBoxComment.tooltip.appendChild(FormattedTextBoxComment.tooltipText); FormattedTextBoxComment.tooltip.className = "FormattedTextBox-tooltip"; FormattedTextBoxComment.tooltip.style.pointerEvents = "all"; - FormattedTextBoxComment.tooltip.style.maxWidth = "350px"; - FormattedTextBoxComment.tooltip.style.maxHeight = "250px"; + FormattedTextBoxComment.tooltip.style.maxWidth = "200px"; + FormattedTextBoxComment.tooltip.style.maxHeight = "206px"; FormattedTextBoxComment.tooltip.style.width = "100%"; FormattedTextBoxComment.tooltip.style.height = "100%"; FormattedTextBoxComment.tooltip.style.overflow = "hidden"; @@ -87,12 +96,25 @@ export class FormattedTextBoxComment { const textBox = FormattedTextBoxComment.textBox; if (FormattedTextBoxComment.linkDoc && !keep && textBox) { if (FormattedTextBoxComment.linkDoc.author) { - if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { - textBox.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); + + if (FormattedTextBoxComment._deleteRef && FormattedTextBoxComment._deleteRef.contains(e.target as any)) { + this.deleteLink(); + } else if (FormattedTextBoxComment._followRef && FormattedTextBoxComment._followRef.contains(e.target as any)) { + if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { + textBox.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); + } else { + DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, + (doc: Doc, followLinkLocation: string) => textBox.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); + } } else { - DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, - (doc: Doc, followLinkLocation: string) => textBox.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); + if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { + textBox.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); + } else { + DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, + (doc: Doc, followLinkLocation: string) => textBox.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); + } } + } } else if (textBox && (FormattedTextBoxComment.tooltipText as any).href) { textBox.props.addDocTab(Docs.Create.WebDocument((FormattedTextBoxComment.tooltipText as any).href, { title: (FormattedTextBoxComment.tooltipText as any).href, _width: 200, _height: 400, UseCors: true }), "onRight"); @@ -106,6 +128,15 @@ export class FormattedTextBoxComment { } } + @action + deleteLink = () => { + FormattedTextBoxComment.linkDoc ? LinkManager.Instance.deleteLink(FormattedTextBoxComment.linkDoc) : null; + LinkDocPreview.LinkInfo = undefined; + DocumentLinksButton.EditLink = undefined; + //FormattedTextBoxComment.tooltipText = undefined; + FormattedTextBoxComment.Hide(); + } + public static Hide() { FormattedTextBoxComment.textBox = undefined; FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = "none"); @@ -182,7 +213,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; @@ -210,32 +241,87 @@ export class FormattedTextBoxComment { } if (target?.author) { FormattedTextBoxComment.showCommentbox("", view, nbef); - ReactDOM.render(<ContentFittingDocumentView - Document={target} - LibraryPath={emptyPath} - fitToBox={true} - moveDocument={returnFalse} - rootSelected={returnFalse} - ScreenToLocalTransform={Transform.Identity} - parentActive={returnFalse} - addDocument={returnFalse} - removeDocument={returnFalse} - addDocTab={returnFalse} - pinToPres={returnFalse} - dontRegisterView={true} - docFilters={returnEmptyFilter} - ContainingCollectionDoc={undefined} - ContainingCollectionView={undefined} - renderDepth={0} - PanelWidth={() => Math.min(350, NumCast(target._width, 350))} - PanelHeight={() => Math.min(250, NumCast(target._height, 250))} - focus={emptyFunction} - whenActiveChanged={returnFalse} - bringToFront={returnFalse} - ContentScaling={returnOne} - NativeWidth={returnZero} - NativeHeight={returnZero} - />, FormattedTextBoxComment.tooltipText); + const docPreview = <div style={{ backgroundColor: "white", border: "8px solid white" }}> + {target.title} + <div className="wrapper" style={{ float: "right" }}> + <div title="Delete link" className="FormattedTextBoxComment-button" style={{ + display: "inline", + paddingLeft: "6px", + paddingRight: "6px", + paddingTop: "2.5px", + paddingBottom: "2.5px", + width: "20px", + height: "20px", + margin: 0, + marginRight: "6px", + borderRadius: "50%", + pointerEvents: "auto", + backgroundColor: "rgb(38, 40, 41)", + color: "rgb(178, 181, 184)", + transition: "transform 0.2s", + textAlign: "center", + position: "relative" + }} ref={(r) => this._deleteRef = r}> + <FontAwesomeIcon className="FormattedTextBox-fa-icon" icon="trash" + size="sm" /></div> + <div title="Follow link" className="FormattedTextBoxComment-button" style={{ + display: "inline", + paddingLeft: "6px", + paddingRight: "6px", + paddingTop: "2.5px", + paddingBottom: "2.5px", + width: "20px", + height: "20px", + margin: 0, + marginRight: "6px", + borderRadius: "50%", + pointerEvents: "auto", + backgroundColor: "rgb(38, 40, 41)", + color: "rgb(178, 181, 184)", + transition: "transform 0.2s", + textAlign: "center", + position: "relative" + }} ref={(r) => this._followRef = r}> + <FontAwesomeIcon className="FormattedTextBox-fa-icon" icon="arrow-right" + size="sm" /> + </div> + </div> + <div className="wrapper" style={{ + maxWidth: "180px", maxHeight: "168px", overflow: "hidden", + overflowY: "hidden", paddingTop: "5px" + }}> + <ContentFittingDocumentView + Document={target} + LibraryPath={emptyPath} + fitToBox={true} + moveDocument={returnFalse} + rootSelected={returnFalse} + ScreenToLocalTransform={Transform.Identity} + parentActive={returnFalse} + addDocument={returnFalse} + removeDocument={returnFalse} + addDocTab={returnFalse} + pinToPres={returnFalse} + dontRegisterView={true} + docFilters={returnEmptyFilter} + ContainingCollectionDoc={undefined} + ContainingCollectionView={undefined} + renderDepth={0} + PanelWidth={() => Math.min(350, NumCast(target._width, 350))} + PanelHeight={() => Math.min(250, NumCast(target._height, 250))} + focus={emptyFunction} + whenActiveChanged={returnFalse} + bringToFront={returnFalse} + ContentScaling={returnOne} + NativeWidth={returnZero} + NativeHeight={returnZero} + /> + </div> + </div>; + FormattedTextBoxComment.showCommentbox("", view, nbef); + + ReactDOM.render(docPreview, FormattedTextBoxComment.tooltipText); + FormattedTextBoxComment.tooltip.style.width = NumCast(target._width) ? `${NumCast(target._width)}` : "100%"; FormattedTextBoxComment.tooltip.style.height = NumCast(target._height) ? `${NumCast(target._height)}` : "100%"; } @@ -249,4 +335,4 @@ export class FormattedTextBoxComment { } destroy() { } -} +}
\ No newline at end of file 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 6a85e3b7c..ca30dde9d 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -279,7 +279,7 @@ export class RichTextRules { 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; @@ -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 54b61aa20..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] )] ]; diff --git a/src/client/views/nodes/formattedText/nodes_rts.ts b/src/client/views/nodes/formattedText/nodes_rts.ts index afb1f57b7..1af821738 100644 --- a/src/client/views/nodes/formattedText/nodes_rts.ts +++ b/src/client/views/nodes/formattedText/nodes_rts.ts @@ -310,9 +310,9 @@ export const nodes: { [index: string]: NodeSpec } = { }], toDOM(node: any) { 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 }, "..."]; + return ["li", { class: `${map}`, "data-mapStyle": node.attrs.mapStyle, "data-bulletStyle": node.attrs.bulletStyle }, node.attrs.visibility ? 0 : + ["span", { style: `position: relative; width: 100%; height: 1.5em; overflow: hidden; display: ${node.attrs.mapStyle !== "bullet" ? "inline-block" : "list-item"}; text-overflow: ellipsis; white-space: pre` }, + `${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 +}; + + |
