import {NodeSelection, Plugin, PluginKey} from "prosemirror-state" import {RemoveMarkStep} from "prosemirror-transform" import {Decoration, DecorationSet} from "prosemirror-view" import type {Node, Mark, NodeType, ResolvedPos} from "prosemirror-model" import type {EditorState} from "prosemirror-state" import type {EditorView} from "prosemirror-view" import { randomFigureId, randomHeadingId, randomListId, randomTableId } from "@fiduswriter/document/schema/common/index" import {CATS} from "@fiduswriter/document/schema/i18n" import type {Track} from "@fiduswriter/document" import {addAlert, noSpaceTmp} from "fwtoolkit" import {CitationDialog, LinkDialog} from "../dialogs/index.js" import type {Editor} from "../types.js" type NodeTypeWithGroups = NodeType & {groups: string[]} const key = new PluginKey("links") const copyLink = (href: string): void => { const textarea = document.createElement("textarea") textarea.textContent = href textarea.style.position = "fixed" // Prevent scrolling to bottom of page in MS Edge. document.body.appendChild(textarea) textarea.select() try { document.execCommand("copy") // Security exception may be thrown by some browsers. document.body.removeChild(textarea) addAlert("info", gettext("Link copied to clipboard")) } catch (_ex) { addAlert( "info", gettext("Copy to clipboard failed. Please copy manually.") ) } } const nonDeletedTextContent = (node: Node): string => { let text = "" node.descendants(subNode => { if ( subNode.isText && !subNode.marks.find(mark => mark.type.name === "deletion") ) { text += subNode.text } }) return text } export const getInternalTargets = ( state: EditorState, language: string, editor: "main" | "foot" ): Array<{id: string; text: string}> => { const internalTargets: Array<{id: string; text: string}> = [] const anchors: Record = {} const categories: Record = {} state.doc.descendants(node => { const track = node.attrs.track as Track[] | undefined if (track?.find(trackAttr => trackAttr.type === "deletion")) { return true } if ((node.type as NodeTypeWithGroups).groups.includes("heading")) { const textContent = nonDeletedTextContent(node) if (textContent.length) { internalTargets.push({ id: node.attrs.id as string, text: textContent }) } return true } if ( ["figure", "table"].includes(node.type.name) && node.attrs.category && node.attrs.category !== "none" ) { if (!categories[node.attrs.category as string]) { categories[node.attrs.category as string] = 0 } categories[node.attrs.category as string]++ internalTargets.push({ id: node.attrs.id as string, text: editor === "main" ? `${CATS[node.attrs.category as string][language]} ${categories[node.attrs.category as string]}` : `${CATS[node.attrs.category as string][language]} ${categories[node.attrs.category as string]}A` }) return true } if ( node.type.name === "code_block" && node.attrs.category && node.attrs.id ) { if (!categories[node.attrs.category as string]) { categories[node.attrs.category as string] = 0 } categories[node.attrs.category as string]++ const categoryLabel = CATS[node.attrs.category as string]?.[language] || (node.attrs.category as string) const text = node.attrs.title ? `${categoryLabel} ${categories[node.attrs.category as string]}: ${node.attrs.title}` : `${categoryLabel} ${categories[node.attrs.category as string]}` internalTargets.push({ id: node.attrs.id as string, text: editor === "main" ? text : `${text}A` }) return true } if (node.type.name === "text") { const anchor = node.marks.find(mark => mark.type.name === "anchor") if (anchor) { anchors[anchor.attrs.id as string] = (anchors[anchor.attrs.id as string] || "") + node.text } } return true }) Object.entries(anchors).forEach(([id, text]) => internalTargets.push({id, text}) ) return internalTargets } interface LinksPluginState { url: string decos: DecorationSet linkMark: Mark | false anchorMark: Mark | false crossReference: Node | undefined citation: Node | undefined selectedIndex: number } export const linksPlugin = (options: {editor: Editor}): Plugin => { // Holds the action functions for every visible
  • in the current dropup, // ordered exactly as they appear in the DOM. Populated by createDropUp() // and consumed by the handleKeyDown prop. let currentDropUpActions: Array<() => void> = [] const editor = options.editor function getUrl(state: EditorState, oldState: EditorState, oldUrl: string) { const id = state.selection.$head.parent.attrs.id as string | undefined, mark = state.selection.$head .marks() .find(mark => mark.type.name === "anchor") let newUrl = oldUrl.split("#")[0] if (mark) { newUrl += `#${mark.attrs.id}` } else if (id) { newUrl += `#${id}` } const changed = oldUrl === newUrl ? false : true // TODO: Should the following be moved to a view? // Not sure if this counts as a DOM update. if (changed && options.editor.currentView.state === oldState) { window.history.replaceState("", "", newUrl) } return newUrl } function getLinkMark(state: EditorState): Mark | undefined { return state.selection.$head .marks() .find(mark => mark.type.name === "link") } function getAnchorMark(state: EditorState): Mark | undefined { return state.selection.$head .marks() .find(mark => mark.type.name === "anchor") } function getCrossReference(state: EditorState): Node | undefined { // When inline_references is enabled the inline reference editor // handles cross-references; don't show the links dropup for them. const appConfig = editor.app as { config?: {user?: {preferences?: {inline_references?: boolean}}} } if ( appConfig.config?.user?.preferences?.inline_references === true ) { return undefined } return state.selection instanceof NodeSelection ? state.selection.node.type.name == "cross_reference" ? state.selection.node : undefined : undefined } function getCitation(state: EditorState): Node | undefined { // Only surface a citation in the links dropup when inline_references // is disabled (otherwise the inline reference editor handles it). const appConfig = editor.app as { config?: {user?: {preferences?: {inline_references?: boolean}}} } if ( appConfig.config?.user?.preferences?.inline_references === true ) { return undefined } return state.selection instanceof NodeSelection ? state.selection.node.type.name === "citation" ? state.selection.node : undefined : undefined } function getDecos(state: EditorState, selectedIndex = -1): DecorationSet { const $head = state.selection.$head const linkMark = $head.marks().find(mark => mark.type.name === "link") const anchorMark = $head .marks() .find(mark => mark.type.name === "anchor") const currentMarks: Array = [] const crossRef = getCrossReference(state) const citation = getCitation(state) if (linkMark) { currentMarks.push(linkMark) } if (anchorMark) { currentMarks.push(anchorMark) } if (crossRef) { currentMarks.push(crossRef as Node) } if (citation) { currentMarks.push(citation as Node) } if (!currentMarks.length) { return DecorationSet.empty } let startPos = $head.start() // position of block start. const inlineNode = crossRef || citation if (inlineNode) { // For inline nodes, place dropup after the node startPos = state.selection.from + inlineNode.nodeSize } else { let index = $head.index() while ( index < $head.parent.childCount - 1 && currentMarks.some(mark => (mark as Mark).isInSet ? (mark as Mark).isInSet( $head.parent.child(index + 1).marks ) : false ) ) { index++ } for (let i = 0; i <= index; i++) { startPos += $head.parent.child(i).nodeSize } } const dom = createDropUp( linkMark, anchorMark, crossRef, $head, citation, selectedIndex ), deco = Decoration.widget(startPos, dom) return DecorationSet.create(state.doc, [deco]) } function createDropUp( linkMark: Mark | undefined, anchorMark: Mark | undefined, crossRef: Node | undefined, $head: ResolvedPos, citation: Node | undefined, selectedIndex = -1 ): HTMLElement { const dropUp = document.createElement("span"), writeAccess = editor.docInfo.access_rights === "write" ? true : false, editAccess = ["write", "write-tracked", "review-tracked"].includes( editor.docInfo.access_rights as string ) ? true : false let linkType: "internal" | "external" | undefined, linkHref: string | undefined, anchorHref: string | undefined, requiredPx = 10 if (linkMark) { linkType = (linkMark.attrs.href as string)[0] === "#" ? "internal" : "external" linkHref = linkType === "internal" ? window.location.href.split("#")[0] + linkMark.attrs.href : (linkMark.attrs.href as string) requiredPx += 120 } if (anchorMark) { anchorHref = window.location.href.split("#")[0] + "#" + anchorMark.attrs.id requiredPx += 92 } if (citation) { requiredPx += 92 } dropUp.classList.add("drop-up-outer") dropUp.innerHTML = noSpaceTmp` ` if (linkType === "internal") { const el = dropUp.querySelector("a.internal") el?.addEventListener("click", event => { event.preventDefault() event.stopImmediatePropagation() options.editor.scrollIdIntoView( (linkMark!.attrs.href as string).slice(1) ) }) } // Rebuild the actions array so handleKeyDown always has the current // set of handlers in the same order the
  • items appear in the DOM. currentDropUpActions = [] const setupAction = ( selector: string, action: () => void ): void => { const el = dropUp.querySelector(selector) if (!el) { return } currentDropUpActions.push(action) el.addEventListener("mousedown", event => { event.preventDefault() event.stopImmediatePropagation() action() }) } // Actions must be registered in the same order they appear in the HTML // template so that selectedIndex maps to the correct
  • element. setupAction(".copy-link", () => copyLink(linkHref || "")) setupAction(".edit-link", () => { const dialog = new LinkDialog(editor) dialog.init() }) setupAction(".remove-link", () => { editor.view.dispatch( editor.view.state.tr.removeMark( $head.start(), $head.end(), linkMark! ) ) }) setupAction(".copy-anchor", () => copyLink(anchorHref || "")) setupAction(".remove-anchor", () => { editor.view.dispatch( editor.view.state.tr.removeMark( $head.start(), $head.end(), anchorMark! ) ) }) setupAction(".edit-crossRef", () => { const dialog = new LinkDialog(editor) dialog.init() }) setupAction(".remove-crossRef", () => { editor.view.dispatch( editor.view.state.tr.delete($head.pos - 1, $head.pos) ) }) setupAction(".edit-citation", () => { const dialog = new CitationDialog(editor) dialog.init() }) setupAction(".remove-citation", () => { editor.view.dispatch( editor.view.state.tr.delete($head.pos - 1, $head.pos) ) }) // Apply keyboard-focus highlight to the currently selected item. if (selectedIndex >= 0 && selectedIndex < currentDropUpActions.length) { const items = Array.from( dropUp.querySelectorAll(".drop-up-options li") ) if (items[selectedIndex]) { items[selectedIndex].classList.add("fw-focused") } } return dropUp } return new Plugin({ key, state: { init(): LinksPluginState { return { url: window.location.href, decos: DecorationSet.empty, linkMark: false, anchorMark: false, crossReference: undefined, citation: undefined, selectedIndex: -1 } }, apply(tr, _prev, oldState, state): LinksPluginState { let { url, decos, linkMark, anchorMark, crossReference, citation, selectedIndex } = key.getState(oldState) as LinksPluginState url = getUrl(state, oldState, url) const newLinkMark = getLinkMark(state) const newAnchorMark = getAnchorMark(state) const newCrossReference = getCrossReference(state) const newCitation = getCitation(state) const meta = tr.getMeta(key) if (meta?.action === "navigate") { // Keyboard navigation: only selectedIndex changes; rebuild // the decoration so the focused class is applied correctly. selectedIndex = meta.index as number decos = getDecos(state, selectedIndex) } else if ( newLinkMark === linkMark && newAnchorMark === anchorMark && newCrossReference === crossReference && newCitation === citation ) { decos = decos.map(tr.mapping, tr.doc) } else { // The cursor moved to a different mark — reset selection. selectedIndex = -1 decos = getDecos(state, selectedIndex) linkMark = newLinkMark || false anchorMark = newAnchorMark || false crossReference = newCrossReference citation = newCitation } if (!tr.getMeta("remote")) { // We look for changes to figures or headings. let foundIdElement = false // found heading or figure let ranges: Array<[number, number]> = [] tr.steps.forEach((step, index) => { const stepWithRange = step as unknown as { from: number to: number } ranges.push([stepWithRange.from, stepWithRange.to]) tr.docs[index].nodesBetween( stepWithRange.from, stepWithRange.to, node => { if ( (node.type as NodeTypeWithGroups).groups.includes("heading") || node.type.name === "figure" ) { foundIdElement = true } } ) ranges = ranges.map(range => { return [ tr.mapping.maps[index].map(range[0], -1), tr.mapping.maps[index].map(range[1], 1) ] }) }) let foundAnchorWithoutId = false // found an anchor without an ID ranges.forEach(range => { state.doc.nodesBetween(range[0], range[1], node => { if ( !foundIdElement && ((node.type as NodeTypeWithGroups).groups.includes("heading") || [ "figure", "table", "bullet_list", "ordered_list" ].includes(node.type.name)) ) { foundIdElement = true } if (!foundAnchorWithoutId) { node.marks.forEach(mark => { if ( mark.type.name === "anchor" && !mark.attrs.id ) { foundAnchorWithoutId = true } }) } }) }) if (foundIdElement || foundAnchorWithoutId) { const linkUpdate = {foundAnchorWithoutId} tr.setMeta("linkUpdate", linkUpdate) if ( oldState.schema === options.editor.view.state.schema ) { tr.setMeta("toFoot", {linkUpdate: true}) } else { tr.setMeta("toMain", {linkUpdate: true}) } } } return { url, decos, linkMark, anchorMark, crossReference, citation, selectedIndex } } }, appendTransaction: (trs, oldState, newState) => { // Check if any of the transactions are local. if (trs.every(tr => !tr.getMeta("linkUpdate"))) { // All transactions are remote or don't change anything. Give up. return } const foundAnchorWithoutId = trs.find(tr => { const linkUpdate = tr.getMeta("linkUpdate") return linkUpdate && linkUpdate.foundAnchorWithoutId }) // ID should not be found in the other pm either. So we look through // those as well. let otherState: EditorState, language: string if (oldState.schema === options.editor.view.state.schema) { otherState = (options.editor.mod.footnotes as { fnEditor: {view: EditorView} }).fnEditor.view.state language = newState.doc.attrs.language as string } else { otherState = options.editor.view.state language = options.editor.view.state.doc.attrs.language as string } const internalTargets = getInternalTargets( newState, language, oldState.schema === options.editor.view.state.schema ? "main" : "foot" ).concat( getInternalTargets( otherState, language, oldState.schema === options.editor.view.state.schema ? "foot" : "main" ) ) // Check if there are any headings or figures in the affected range. // Otherwise, skip. // Check that unique IDs only exist once in the document and that the // text values are up to date for all IDs if they are referenced. // // If an ID is used more than once, add steps to change the ID of all // but the first occurence. const ids: string[] = [] otherState.doc.descendants(node => { if ( (node.type as NodeTypeWithGroups).groups.includes("heading") || ["figure", "table", "bullet_list", "ordered_list"].includes( node.type.name ) ) { ids.push(node.attrs.id as string) } }) const newTr = newState.tr.setMeta("fixIds", true) newState.doc.descendants((node, pos) => { if ( (node.type as NodeTypeWithGroups).groups.includes("heading") || ["figure", "table", "bullet_list", "ordered_list"].includes( node.type.name ) ) { if (ids.includes(node.attrs.id as string) || !node.attrs.id) { // Add node if the id is false (default) or it is present twice const randomIdGenerator = (node.type as NodeTypeWithGroups).groups.includes( "heading" ) ? randomHeadingId : node.type.name === "figure" ? randomFigureId : node.type.name === "table" ? randomTableId : randomListId let id: string | undefined while (!id || ids.includes(id)) { id = randomIdGenerator() } const attrs = Object.assign({}, node.attrs, {id}) // Because we only change attributes, positions should stay the // the same throughout all our extra steps. We therefore do no // mapping of positions through these steps. newTr.setNodeMarkup(pos, null, attrs) ids.push(id) } else { ids.push(node.attrs.id as string) } } else if ( node.type.name === "cross_reference" && !internalTargets.find( it => it.id === node.attrs.id && it.text === node.attrs.title ) ) { const iTarget = internalTargets.find( it => it.id === node.attrs.id ) const attrs = Object.assign({}, node.attrs, { title: iTarget ? iTarget.text : null }) newTr.setNodeMarkup(pos, null, attrs) } node.marks.forEach(mark => { if ( mark.type.name === "link" && (mark.attrs.href as string)[0] === "#" && !internalTargets.find( it => it.id === (mark.attrs.href as string).slice(1) && it.text === node.attrs.title ) ) { const iTarget = internalTargets.find( it => it.id === (mark.attrs.href as string).slice(1) ) const attrs = Object.assign({}, mark.attrs, { title: iTarget ? iTarget.text : null }) newTr.addMark( pos, pos + node.nodeSize, newState.schema.marks.link.create(attrs) ) } }) }) // Remove anchor marks without ID if (foundAnchorWithoutId) { const markType = newState.schema.marks.anchor.create({ id: false }) newTr.step( new RemoveMarkStep(0, newState.doc.content.size, markType) ) } return newTr }, props: { handleDOMEvents: { focus: (view: EditorView, _event: FocusEvent) => { const {url} = key.getState(view.state) as LinksPluginState window.history.replaceState("", "", url) } }, handleKeyDown(view: EditorView, event: KeyboardEvent) { const pluginState = key.getState(view.state) as | LinksPluginState | undefined if (!pluginState) { return false } const { linkMark, anchorMark, crossReference, citation, selectedIndex } = pluginState // Only intercept arrow/enter keys when a dropup is visible. if (!linkMark && !anchorMark && !crossReference && !citation) { return false } const totalItems = currentDropUpActions.length if (totalItems === 0) { return false } if (event.key === "ArrowDown") { event.preventDefault() const newIndex = selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0 view.dispatch( view.state.tr.setMeta(key, { action: "navigate", index: newIndex }) ) return true } if (event.key === "ArrowUp") { event.preventDefault() const newIndex = selectedIndex <= 0 ? totalItems - 1 : selectedIndex - 1 view.dispatch( view.state.tr.setMeta(key, { action: "navigate", index: newIndex }) ) return true } if ( event.key === "Enter" && selectedIndex >= 0 && selectedIndex < totalItems ) { event.preventDefault() currentDropUpActions[selectedIndex]() return true } return false }, decorations(state: EditorState) { const pluginState = key.getState(state) as LinksPluginState | undefined if (!pluginState) { return DecorationSet.empty } const {decos} = pluginState return decos } } }) }