diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/client/util/LinkManager.ts | 2 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.tsx | 61 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx | 4 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/RichTextMenu.tsx | 40 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/RichTextRules.ts | 2 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/marks_rts.ts | 20 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/prosemirrorPatches.js | 55 | ||||
-rw-r--r-- | src/fields/RichTextUtils.ts | 2 |
8 files changed, 132 insertions, 54 deletions
diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts index 9b4dc2630..749fabfcc 100644 --- a/src/client/util/LinkManager.ts +++ b/src/client/util/LinkManager.ts @@ -49,7 +49,7 @@ export class LinkManager { } public deleteLink(linkDoc: Doc): boolean { - if (LinkManager.Instance.LinkManagerDoc) { + if (LinkManager.Instance.LinkManagerDoc && linkDoc instanceof Doc) { Doc.RemoveDocFromList(LinkManager.Instance.LinkManagerDoc, "data", linkDoc); return true; } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 664141607..9c7c46ae9 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'; @@ -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); @@ -87,6 +88,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp 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; @@ -141,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() { @@ -177,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); @@ -246,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)); } } @@ -614,9 +643,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); } }); @@ -626,6 +655,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 => { @@ -731,8 +766,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; @@ -914,8 +949,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); } 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/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx index b9ca82243..95d6c9fac 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.tsx +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -737,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]; @@ -772,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; diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index 612a824fc..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; 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/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 +}; + + diff --git a/src/fields/RichTextUtils.ts b/src/fields/RichTextUtils.ts index 7c7bf3e12..a590c88c4 100644 --- a/src/fields/RichTextUtils.ts +++ b/src/fields/RichTextUtils.ts @@ -392,7 +392,7 @@ export namespace RichTextUtils { const { attrs } = mark; switch (converted) { case "link": - let url = attrs.allHrefs.length ? attrs.allHrefs[0].href : ""; + let url = attrs.allLinks.length ? attrs.allLinks[0].href : ""; const delimiter = "/doc/"; const alreadyShared = "?sharing=true"; if (new RegExp(window.location.origin + delimiter).test(url) && !url.endsWith(alreadyShared)) { |