From 97e750638c893d9cd1c233632d135ce3251d564b Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Sun, 23 Jul 2023 23:47:46 +0000 Subject: [PATCH] Implement inline and node decorations --- demo/main.tsx | 165 +++++++++++- package.json | 3 + src/components/EditorView.tsx | 157 ++++++++---- src/dom.ts | 9 + src/hooks/useContentEditable.ts | 12 +- src/hooks/useDomAtPos.tsx | 3 +- src/hooks/usePosAtCoords.ts | 428 -------------------------------- src/hooks/useSyncSelection.ts | 6 +- src/keydownHandler.tsx | 82 ++++++ src/nodeViews/render.tsx | 27 +- yarn.lock | 8 + 11 files changed, 413 insertions(+), 487 deletions(-) delete mode 100644 src/hooks/usePosAtCoords.ts create mode 100644 src/keydownHandler.tsx diff --git a/demo/main.tsx b/demo/main.tsx index 1e9608d3..ff505b80 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,9 +1,27 @@ -import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { + baseKeymap, + chainCommands, + createParagraphNear, + deleteSelection, + joinBackward, + liftEmptyBlock, + newlineInCode, + selectNodeBackward, + splitBlock, + toggleMark, +} from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; import { Schema } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; import "prosemirror-view/style/prosemirror.css"; -import React, { Ref, forwardRef } from "react"; +import React, { + DetailedHTMLProps, + HTMLAttributes, + Ref, + forwardRef, + useState, +} from "react"; import { createRoot } from "react-dom/client"; import { @@ -49,14 +67,39 @@ const schema = new Schema({ const editorState = EditorState.create({ schema, + doc: schema.nodes.doc.create({}, [ + schema.nodes.paragraph.create( + {}, + schema.text("This is the first paragraph") + ), + schema.nodes.paragraph.create( + {}, + schema.text("This is the second paragraph") + ), + schema.nodes.paragraph.create( + {}, + schema.text("This is the third paragraph") + ), + ]), plugins: [keymap(baseKeymap)], }); const Paragraph = forwardRef(function Paragraph( - { children }: NodeViewComponentProps, + { + children, + className, + }: NodeViewComponentProps & + DetailedHTMLProps< + HTMLAttributes, + HTMLParagraphElement + >, ref: Ref ) { - return

{children}

; + return ( +

+ {children} +

+ ); }); function DemoEditor() { @@ -65,8 +108,24 @@ function DemoEditor() {

React ProseMirror Demo

document.createElement("div")), + ])} keymap={{ "Mod-i": toggleMark(schema.marks.em), + Backspace: chainCommands( + deleteSelection, + joinBackward, + selectNodeBackward + ), + Enter: chainCommands( + newlineInCode, + createParagraphNear, + liftEmptyBlock, + splitBlock + ), }} nodeViews={{ paragraph: Paragraph }} > @@ -77,9 +136,105 @@ function DemoEditor() { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const root = createRoot(document.getElementById("root")!); +root.render(); + +// import { baseKeymap } from "prosemirror-commands"; +// import { keymap } from "prosemirror-keymap"; +// import { Schema } from "prosemirror-model"; +// import { EditorState } from "prosemirror-state"; +// import { Decoration, DecorationSet } from "prosemirror-view"; +// import "prosemirror-view/style/prosemirror.css"; +// import React, { useState } from "react"; +// import { createRoot } from "react-dom/client"; + +// import { +// NodeViewComponentProps, +// ProseMirror, +// useEditorEffect, +// useEditorState, +// useNodeViews, +// } from "../src/index.js"; +// import { ReactNodeViewConstructor } from "../src/nodeViews/createReactNodeViewConstructor.js"; + +// import "./main.css"; + +// const schema = new Schema({ +// nodes: { +// doc: { content: "block+" }, +// paragraph: { group: "block", content: "inline*" }, +// text: { group: "inline" }, +// }, +// marks: { +// em: { +// toDOM() { +// return ["em", 0]; +// }, +// }, +// }, +// }); + +// const editorState = EditorState.create({ +// schema, +// doc: schema.nodes.doc.create({}, [ +// schema.nodes.paragraph.create({}, [ +// schema.text("This is "), +// schema.text("the", [schema.marks.em.create()]), +// schema.text(" first paragraph"), +// ]), +// schema.nodes.paragraph.create( +// {}, +// schema.text("This is the second paragraph") +// ), +// schema.nodes.paragraph.create( +// {}, +// schema.text("This is the third paragraph") +// ), +// ]), +// plugins: [keymap(baseKeymap)], +// }); + +// function Paragraph({ children }: NodeViewComponentProps) { +// return

{children}

; +// } + +// const reactNodeViews: Record = { +// paragraph: () => ({ +// component: Paragraph, +// dom: document.createElement("div"), +// contentDOM: document.createElement("span"), +// }), +// }; + +// function DemoEditor() { +// const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); +// const [mount, setMount] = useState(null); + +// return ( +//
+//

React ProseMirror Demo

+// +// DecorationSet.create(state.doc, [ +// Decoration.inline(5, 15, { class: "inline-deco" }), +// Decoration.node(35, 55, { class: "node-deco" }), +// ]) +// } +// > +//
+// {renderNodeViews()} +// +//
+// ); +// } + +// // eslint-disable-next-line @typescript-eslint/no-non-null-assertion +// const root = createRoot(document.getElementById("root")!); + // root.render( // // // // ); -root.render(); diff --git a/package.json b/package.json index 4ac2da61..f363774c 100644 --- a/package.json +++ b/package.json @@ -86,5 +86,8 @@ "packageManager": "yarn@3.4.1", "engines": { "node": ">=16.9" + }, + "dependencies": { + "w3c-keyname": "^2.2.8" } } diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index 8e1c73bc..c341aa65 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -1,18 +1,15 @@ -import { keydownHandler } from "prosemirror-keymap"; -import { Node as ProseMirrorNode } from "prosemirror-model"; +import { DOMOutputSpec, Node } from "prosemirror-model"; import { Command, EditorState, NodeSelection, Transaction, } from "prosemirror-state"; -import { - Decoration, - DirectEditorProps, - EditorView as EditorViewPM, -} from "prosemirror-view"; +import { Decoration, DecorationSet, DirectEditorProps } from "prosemirror-view"; import React, { ComponentType, + DetailedHTMLProps, + HTMLAttributes, KeyboardEventHandler, ReactNode, cloneElement, @@ -26,16 +23,35 @@ import React, { import { EditorViewContext } from "../contexts/EditorViewContext.js"; import { LayoutGroup } from "../contexts/LayoutGroup.js"; import { NodeViewPositionsContext } from "../contexts/NodeViewPositionsContext.js"; +import { DOMNode } from "../dom.js"; import { useContentEditable } from "../hooks/useContentEditable.js"; import { useSyncSelection } from "../hooks/useSyncSelection.js"; -import { renderSpec, wrapInMarks } from "../nodeViews/render.js"; +import { keydownHandler } from "../keydownHandler.js"; +import { + renderSpec, + wrapInDecorations, + wrapInMarks, +} from "../nodeViews/render.js"; import { NodeWrapper } from "./NodeWrapper.js"; import { TextNodeWrapper } from "./TextNodeWrapper.js"; +function makeCuts(cuts: number[], node: Node) { + const sortedCuts = cuts.sort((a, b) => a - b); + const nodes: [Node, ...Node[]] = [node]; + let curr = 0; + for (const cut of sortedCuts) { + const lastNode = nodes.pop()!; + nodes.push(lastNode.cut(0, cut - curr)); + nodes.push(lastNode.cut(cut - curr)); + curr = cut; + } + return nodes; +} + export type NodeViewComponentProps = { decorations: readonly Decoration[]; - node: ProseMirrorNode; + node: Node; children?: ReactNode | ReactNode[]; isSelected: boolean; pos: number; @@ -44,48 +60,57 @@ export type NodeViewComponentProps = { type EditorStateProps = | { state: EditorState; + defaultState?: never; } | { + state?: never; defaultState: EditorState; }; -export type EditorProps = Omit & +export type EditorProps = Omit< + DirectEditorProps, + "state" | "nodeViews" | "decorations" +> & EditorStateProps & { keymap?: { [key: string]: Command }; nodeViews?: { [nodeType: string]: ComponentType }; + decorations?: DecorationSet; }; -export type Props = EditorProps & { - children?: ReactNode | null; -}; +export type Props = EditorProps & + DetailedHTMLProps, HTMLDivElement>; export function EditorView({ children, editable, keymap = {}, nodeViews = {}, - ...editorProps + dispatchTransaction: dispatchProp, + decorations = DecorationSet.empty, + defaultState, + state: stateProp, + ...mountProps }: Props) { const [internalState, setInternalState] = useState( - "defaultState" in editorProps ? editorProps.defaultState : null + defaultState ?? null ); - const posToDOM = useRef(new Map()); - posToDOM.current = new Map(); - const domToPos = useRef(new Map()); - domToPos.current = new Map(); + const posToDOM = useRef(new Map()); + posToDOM.current = new Map(); + const domToPos = useRef(new Map()); + domToPos.current = new Map(); // We always set internalState above if there's no state prop // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const state = "state" in editorProps ? editorProps.state : internalState!; + const state = stateProp ?? internalState!; const dispatchTransaction = useMemo( () => - editorProps.dispatchTransaction ?? + dispatchProp ?? ((tr: Transaction) => { setInternalState((prevState) => prevState?.apply(tr) ?? null); }), - [editorProps.dispatchTransaction] + [dispatchProp] ); const mountRef = useContentEditable(state, dispatchTransaction); @@ -93,49 +118,75 @@ export function EditorView({ useSyncSelection(state, dispatchTransaction, posToDOM, domToPos); const onKeyDown: KeyboardEventHandler = (event) => { - if ( - keydownHandler(keymap)( - { state: state, dispatch: dispatchTransaction } as EditorViewPM, - event.nativeEvent - ) - ) { + if (keydownHandler(keymap)(state, dispatchTransaction, event.nativeEvent)) { event.preventDefault(); } }; function buildReactTree( parentElement: JSX.Element, - node: ProseMirrorNode, - pos: number + node: Node, + pos: number, + decorations: DecorationSet ) { const childElements: ReactNode[] = []; if (node.childCount === 0 && node.isTextblock) { childElements.push(
); } node.forEach((childNode, offset) => { - if (childNode.isText && childNode.text !== undefined) { - const element = wrapInMarks( - - {childNode.text} - , - childNode.marks, - childNode.isInline + if (childNode.isText) { + const inlineDecorations = decorations + .find(pos + offset + 1, pos + offset + 1 + childNode.nodeSize) + .filter( + (decoration) => + (decoration as Decoration & { inline: boolean }).inline + ); + const textNodes: Node[] = makeCuts( + inlineDecorations.flatMap((decoration) => [ + decoration.from - (pos + offset + 1), + decoration.to - (pos + offset + 1), + ]), + childNode ); - childElements.push(element); + let subOffset = 0; + for (const textNode of textNodes) { + const marked = wrapInMarks( + + {/* Text nodes always have text */} + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + {textNode.text!} + , + textNode.marks, + textNode.isInline + ); + const decorated = wrapInDecorations( + marked, + inlineDecorations.filter( + (deco) => + deco.from <= pos + offset + 1 + subOffset && + deco.to >= pos + offset + 1 + subOffset + textNode.nodeSize + ), + true + ); + childElements.push(decorated); + subOffset += textNode.nodeSize; + } return false; } - const outputSpec = childNode.type.spec.toDOM?.(childNode); + const outputSpec: DOMOutputSpec | undefined = + childNode.type.spec.toDOM?.(childNode); if (!outputSpec) throw new Error( `Node spec for ${childNode.type.name} is missing toDOM` ); - const Component = nodeViews[childNode.type.name]; + const Component: ComponentType | undefined = + nodeViews[childNode.type.name]; - let element = Component + let element: ReactNode = Component ? createElement(Component, { node: childNode, decorations: [], @@ -149,7 +200,12 @@ export function EditorView({ : renderSpec(outputSpec); if (isValidElement(element)) { - element = buildReactTree(element, childNode, pos + offset + 1); + element = buildReactTree( + element, + childNode, + pos + offset + 1, + decorations + ); } childElements.push(element); @@ -159,9 +215,18 @@ export function EditorView({ const element = cloneElement(parentElement, undefined, ...childElements); - const markedElement = wrapInMarks(element, node.marks, node.isInline); + const marked = wrapInMarks(element, node.marks, node.isInline); + const nodeDecorations = decorations + .find(pos, pos + node.nodeSize) + .filter( + (decoration) => + !(decoration as Decoration & { inline: boolean }).inline && + pos === decoration.from && + pos + node.nodeSize === decoration.to + ); + const decorated = wrapInDecorations(marked, nodeDecorations, false); - return {markedElement}; + return {decorated}; } const content = buildReactTree( @@ -170,9 +235,11 @@ export function EditorView({ contentEditable={editable ? editable(state) : true} suppressContentEditableWarning={true} onKeyDown={onKeyDown} + {...mountProps} >, state.doc, - -1 + -1, + decorations ); return ( diff --git a/src/dom.ts b/src/dom.ts index 5020f1c4..b3cf5baf 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -1,3 +1,12 @@ +export type DOMNode = InstanceType; +export type DOMSelection = InstanceType; +export type DOMSelectionRange = { + focusNode: DOMNode | null; + focusOffset: number; + anchorNode: DOMNode | null; + anchorOffset: number; +}; + export const BIDI = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; export function nonZero(rect: DOMRect) { return rect.top < rect.bottom || rect.left < rect.right; diff --git a/src/hooks/useContentEditable.ts b/src/hooks/useContentEditable.ts index b3ee662b..16be1004 100644 --- a/src/hooks/useContentEditable.ts +++ b/src/hooks/useContentEditable.ts @@ -31,12 +31,14 @@ export function useContentEditable( break; } case "deleteContentBackward": { - const deleteContentBackward = chainCommands( - deleteSelection, - joinBackward, - selectNodeBackward + const { tr } = state; + tr.delete( + state.selection.empty + ? state.selection.from - 1 + : state.selection.from, + state.selection.from ); - deleteContentBackward(state, dispatchTransaction); + dispatchTransaction(tr); break; } } diff --git a/src/hooks/useDomAtPos.tsx b/src/hooks/useDomAtPos.tsx index d0e6229d..2f64a33a 100644 --- a/src/hooks/useDomAtPos.tsx +++ b/src/hooks/useDomAtPos.tsx @@ -2,12 +2,13 @@ import { useContext } from "react"; import { useLayoutGroupEffect } from "../contexts/LayoutGroup.js"; import { NodeViewPositionsContext } from "../contexts/NodeViewPositionsContext.js"; +import { DOMNode } from "../dom.js"; export function useDomAtPos( pos: number, // TODO: Implement side affinity side = 0, - effect: (dom: { node: Node; offset: number }) => void + effect: (dom: { node: DOMNode; offset: number }) => void ) { const { posToDOM } = useContext(NodeViewPositionsContext); useLayoutGroupEffect(() => { diff --git a/src/hooks/usePosAtCoords.ts b/src/hooks/usePosAtCoords.ts deleted file mode 100644 index dbde67d8..00000000 --- a/src/hooks/usePosAtCoords.ts +++ /dev/null @@ -1,428 +0,0 @@ -// TODO: There are some things that still need to be implemented with React here! -import { EditorState } from "prosemirror-state"; -import { useContext } from "react"; - -import * as browser from "../browser.js"; -import { EditorViewContext } from "../contexts/EditorViewContext.js"; -import { NodeViewPositionsContext } from "../contexts/NodeViewPositionsContext.js"; -import { singleRect, textRange } from "../dom.js"; - -type Rect = { left: number; right: number; top: number; bottom: number }; - -export const parentNode = function (node: Node): Node | null { - const parent = (node as HTMLSlotElement).assignedSlot || node.parentNode; - return parent && parent.nodeType == 11 ? (parent as ShadowRoot).host : parent; -}; - -function targetKludge(dom: HTMLElement, coords: { top: number; left: number }) { - const parent = dom.parentNode; - if ( - parent && - /^li$/i.test(parent.nodeName) && - coords.left < dom.getBoundingClientRect().left - ) - return parent as HTMLElement; - return dom; -} - -function hasCaretPositionFromPoint(doc: Document): doc is Document & { - caretPositionFromPoint: ( - x: number, - y: number - ) => { offsetNode: Node; offset: number }; -} { - return "caretPositionFromPoint" in doc; -} - -function caretFromPoint( - doc: Document, - x: number, - y: number -): { node: Node; offset: number } | undefined { - if (hasCaretPositionFromPoint(doc)) { - try { - const pos = doc.caretPositionFromPoint(x, y); - if (pos) return { node: pos.offsetNode, offset: pos.offset }; - } catch (_) { - // Firefox throws for this call in hard-to-predict circumstances (#994) - } - } - if (doc.caretRangeFromPoint) { - const range = doc.caretRangeFromPoint(x, y); - if (range) return { node: range.startContainer, offset: range.startOffset }; - } - return; -} - -function inRect(coords: { top: number; left: number }, rect: Rect) { - return ( - coords.left >= rect.left - 1 && - coords.left <= rect.right + 1 && - coords.top >= rect.top - 1 && - coords.top <= rect.bottom + 1 - ); -} - -function elementFromPoint( - element: HTMLElement, - coords: { top: number; left: number }, - box: Rect -): HTMLElement { - const len = element.childNodes.length; - if (len && box.top < box.bottom) { - for ( - let startI = Math.max( - 0, - Math.min( - len - 1, - Math.floor( - (len * (coords.top - box.top)) / (box.bottom - box.top) - ) - 2 - ) - ), - i = startI; - ; - - ) { - const child = element.childNodes[i]!; - if (child.nodeType == 1) { - const rects = (child as HTMLElement).getClientRects(); - for (let j = 0; j < rects.length; j++) { - const rect = rects[j]!; - if (inRect(coords, rect)) - return elementFromPoint(child as HTMLElement, coords, rect); - } - } - if ((i = (i + 1) % len) == startI) break; - } - } - return element; -} - -function nearestNodeDom(start: Node, domNodes: Set) { - let current: Node | null = start; - while (current) { - if (domNodes.has(current)) { - return current; - } - current = current.parentNode; - } - return null; -} - -function findOffsetInText(node: Text, coords: { top: number; left: number }) { - const len = node.nodeValue!.length; - const range = document.createRange(); - for (let i = 0; i < len; i++) { - range.setEnd(node, i + 1); - range.setStart(node, i); - const rect = singleRect(range, 1); - if (rect.top == rect.bottom) continue; - if (inRect(coords, rect)) - return { - node, - offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0), - }; - } - return { node, offset: 0 }; -} - -function findOffsetInNode( - node: HTMLElement, - coords: { top: number; left: number } -): { node: Node; offset: number } { - let closest, - dxClosest = 2e8, - coordsClosest: { left: number; top: number } | undefined, - offset = 0; - let rowBot = coords.top, - rowTop = coords.top; - let firstBelow: Node | undefined, - coordsBelow: { left: number; top: number } | undefined; - for ( - let child = node.firstChild, childIndex = 0; - child; - child = child.nextSibling, childIndex++ - ) { - let rects; - if (child.nodeType == 1) rects = (child as HTMLElement).getClientRects(); - else if (child.nodeType == 3) - rects = textRange(child as Text).getClientRects(); - else continue; - - for (let i = 0; i < rects.length; i++) { - const rect = rects[i]!; - if (rect.top <= rowBot && rect.bottom >= rowTop) { - rowBot = Math.max(rect.bottom, rowBot); - rowTop = Math.min(rect.top, rowTop); - const dx = - rect.left > coords.left - ? rect.left - coords.left - : rect.right < coords.left - ? coords.left - rect.right - : 0; - if (dx < dxClosest) { - closest = child; - dxClosest = dx; - coordsClosest = - dx && closest.nodeType == 3 - ? { - left: rect.right < coords.left ? rect.right : rect.left, - top: coords.top, - } - : coords; - if (child.nodeType == 1 && dx) - offset = - childIndex + - (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0); - continue; - } - } else if ( - rect.top > coords.top && - !firstBelow && - rect.left <= coords.left && - rect.right >= coords.left - ) { - firstBelow = child; - coordsBelow = { - left: Math.max(rect.left, Math.min(rect.right, coords.left)), - top: rect.top, - }; - } - if ( - !closest && - ((coords.left >= rect.right && coords.top >= rect.top) || - (coords.left >= rect.left && coords.top >= rect.bottom)) - ) - offset = childIndex + 1; - } - } - if (!closest && firstBelow) { - closest = firstBelow; - coordsClosest = coordsBelow; - dxClosest = 0; - } - if (closest && closest.nodeType == 3) - return findOffsetInText(closest as Text, coordsClosest!); - if (!closest || (dxClosest && closest.nodeType == 1)) return { node, offset }; - return findOffsetInNode(closest as HTMLElement, coordsClosest!); -} - -function posFromDOM(dom: Node, offset: number, bias: number): number { - // If the DOM position is in the content, use the child desc after - // it to figure out a position. - // if ( - // this.contentDOM && - // this.contentDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode) - // ) { - // if (bias < 0) { - // let domBefore, desc: ViewDesc | undefined; - // if (dom == this.contentDOM) { - // domBefore = dom.childNodes[offset - 1]; - // } else { - // while (dom.parentNode != this.contentDOM) dom = dom.parentNode!; - // domBefore = dom.previousSibling; - // } - // while ( - // domBefore && - // !((desc = domBefore.pmViewDesc) && desc.parent == this) - // ) - // domBefore = domBefore.previousSibling; - // return domBefore - // ? this.posBeforeChild(desc!) + desc!.size - // : this.posAtStart; - // } else { - // let domAfter, desc: ViewDesc | undefined; - // if (dom == this.contentDOM) { - // domAfter = dom.childNodes[offset]; - // } else { - // while (dom.parentNode != this.contentDOM) dom = dom.parentNode!; - // domAfter = dom.nextSibling; - // } - // while (domAfter && !((desc = domAfter.pmViewDesc) && desc.parent == this)) - // domAfter = domAfter.nextSibling; - // return domAfter ? this.posBeforeChild(desc!) : this.posAtEnd; - // } - // } - // Otherwise, use various heuristics, falling back on the bias - // parameter, to determine whether to return the position at the - // start or at the end of this view desc. - let atEnd; - if (dom == this.dom && this.contentDOM) { - atEnd = offset > domIndex(this.contentDOM); - } else if ( - this.contentDOM && - this.contentDOM != this.dom && - this.dom.contains(this.contentDOM) - ) { - atEnd = dom.compareDocumentPosition(this.contentDOM) & 2; - } else if (this.dom.firstChild) { - if (offset == 0) - for (let search = dom; ; search = search.parentNode!) { - if (search == this.dom) { - atEnd = false; - break; - } - if (search.previousSibling) break; - } - if (atEnd == null && offset == dom.childNodes.length) - for (let search = dom; ; search = search.parentNode!) { - if (search == this.dom) { - atEnd = true; - break; - } - if (search.nextSibling) break; - } - } - return (atEnd == null ? bias > 0 : atEnd) ? this.posAtEnd : this.posAtStart; -} - -function posFromCaret( - mount: HTMLDivElement, - state: EditorState, - node: Node, - offset: number, - coords: { top: number; left: number }, - domToPos: Map -) { - const domNodes = new Set(domToPos.keys()); - // Browser (in caretPosition/RangeFromPoint) will agressively - // normalize towards nearby inline nodes. Since we are interested in - // positions between block nodes too, we first walk up the hierarchy - // of nodes to see if there are block nodes that the coordinates - // fall outside of. If so, we take the position before/after that - // block. If not, we call `posFromDOM` on the raw node/offset. - let outsideBlock = -1; - for (let cur = node, sawBlock = false; ; ) { - if (cur == mount) break; - const nodeDom = nearestNodeDom(cur, domNodes); - if (!nodeDom) return null; - const nodePos = domToPos.get(nodeDom)!; - const $nodePos = state.doc.resolve(nodePos); - if ( - nodeDom.nodeType == 1 && - (($nodePos.nodeAfter?.isBlock && $nodePos.depth && !sawBlock) || - $nodePos.nodeAfter?.isAtom || - $nodePos.nodeAfter?.isLeaf) - ) { - const rect = (nodeDom as HTMLElement).getBoundingClientRect(); - if ($nodePos.nodeAfter?.isBlock && $nodePos.depth && !sawBlock) { - sawBlock = true; - if (rect.left > coords.left || rect.top > coords.top) - outsideBlock = $nodePos.before(); - else if (rect.right < coords.left || rect.bottom < coords.top) - outsideBlock = $nodePos.after(); - } - if ( - ($nodePos.nodeAfter?.isAtom || $nodePos.nodeAfter?.isLeaf) && - outsideBlock < 0 && - !$nodePos.nodeAfter.isText - ) { - // If we are inside a leaf, return the side of the leaf closer to the coords - const before = $nodePos.nodeAfter.isBlock - ? coords.top < (rect.top + rect.bottom) / 2 - : coords.left < (rect.left + rect.right) / 2; - return before ? $nodePos.before() : $nodePos.after(); - } - } - cur = nodeDom.parentNode!; - } - return outsideBlock > -1 ? outsideBlock : 0; - // TODO: implement posFromDOM - // : view.docView.posFromDOM(node, offset, -1); -} - -function posFromElement( - elt: HTMLElement, - coords: { top: number; left: number } -) { - const { node, offset } = findOffsetInNode(elt, coords); - let bias = -1; - if (node.nodeType == 1 && !node.firstChild) { - const rect = (node as HTMLElement).getBoundingClientRect(); - bias = - rect.left != rect.right && coords.left > (rect.left + rect.right) / 2 - ? 1 - : -1; - } - return view.docView.posFromDOM(node, offset, bias); -} - -export function usePosAtCoords(coords: { top: number; left: number }) { - const { mount, domToPos } = useContext(NodeViewPositionsContext); - const { state } = useContext(EditorViewContext); - - if (!mount) return -1; - - const document = mount.ownerDocument; - let node: Node | undefined, - offset = 0; - const caret = caretFromPoint(document, coords.left, coords.top); - if (caret) ({ node, offset } = caret); - - let elt = document.elementFromPoint(coords.left, coords.top) as HTMLElement; - let pos; - if (!elt || !mount.contains(elt.nodeType != 1 ? elt.parentNode : elt)) { - const box = mount.getBoundingClientRect(); - if (!inRect(coords, box)) return null; - elt = elementFromPoint(mount, coords, box); - if (!elt) return null; - } - // Safari's caretRangeFromPoint returns nonsense when on a draggable element - if (browser.safari) { - for (let p: Node | null = elt; node && p; p = parentNode(p)) - if ((p as HTMLElement).draggable) node = undefined; - } - elt = targetKludge(elt, coords); - if (node) { - if (browser.gecko && node.nodeType == 1) { - // Firefox will sometimes return offsets into nodes, which - // have no actual children, from caretPositionFromPoint (#953) - offset = Math.min(offset, node.childNodes.length); - // It'll also move the returned position before image nodes, - // even if those are behind it. - if (offset < node.childNodes.length) { - const next = node.childNodes[offset]!; - let box; - if ( - next.nodeName == "IMG" && - (box = (next as HTMLElement).getBoundingClientRect()).right <= - coords.left && - box.bottom > coords.top - ) - offset++; - } - } - // Suspiciously specific kludge to work around caret*FromPoint - // never returning a position at the end of the document - if ( - node == mount && - offset == node.childNodes.length - 1 && - node.lastChild!.nodeType == 1 && - coords.top > - (node.lastChild as HTMLElement).getBoundingClientRect().bottom - ) - pos = state.doc.content.size; - // Ignore positions directly after a BR, since caret*FromPoint - // 'round up' positions that would be more accurately placed - // before the BR node. - else if ( - offset == 0 || - node.nodeType != 1 || - node.childNodes[offset - 1]!.nodeName != "BR" - ) - pos = posFromCaret(mount, state, node, offset, coords, domToPos); - } - if (pos == null) pos = posFromElement(elt, coords); - - const domNodes = new Set(domToPos.keys()); - const nodeDom = nearestNodeDom(elt, domNodes); - if (!nodeDom) { - return { pos, inside: -1 }; - } - const nodePos = domToPos.get(nodeDom)!; - const $nodePos = state.doc.resolve(nodePos); - - // TODO: 1 should actually be "border" - return { pos, inside: $nodePos.before() - 1 }; -} diff --git a/src/hooks/useSyncSelection.ts b/src/hooks/useSyncSelection.ts index d52e0204..3762853b 100644 --- a/src/hooks/useSyncSelection.ts +++ b/src/hooks/useSyncSelection.ts @@ -2,11 +2,13 @@ import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { MutableRefObject, useEffect } from "react"; +import { DOMNode } from "../dom.js"; + export function useSyncSelection( state: EditorState, dispatchTransaction: EditorView["dispatch"], - posToDOM: MutableRefObject>, - domToPos: MutableRefObject> + posToDOM: MutableRefObject>, + domToPos: MutableRefObject> ) { useEffect(() => { function onSelectionChange() { diff --git a/src/keydownHandler.tsx b/src/keydownHandler.tsx new file mode 100644 index 00000000..25107a97 --- /dev/null +++ b/src/keydownHandler.tsx @@ -0,0 +1,82 @@ +import { Command, EditorState } from "prosemirror-state"; +import { EditorView as EditorViewPM } from "prosemirror-view"; +import { base, keyName } from "w3c-keyname"; + +const mac = + typeof navigator != "undefined" + ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) + : false; +function normalizeKeyName(name: string) { + const parts = name.split(/-(?!$)/); + let result = parts[parts.length - 1]!; + if (result == "Space") result = " "; + const mods = parts.slice(0, parts.length - 1); + let alt, ctrl, shift, meta; + for (const mod of mods) { + if (/^(cmd|meta|m)$/i.test(mod)) meta = true; + else if (/^a(lt)?$/i.test(mod)) alt = true; + else if (/^(c|ctrl|control)$/i.test(mod)) ctrl = true; + else if (/^s(hift)?$/i.test(mod)) shift = true; + else if (/^mod$/i.test(mod)) { + if (mac) meta = true; + else ctrl = true; + } else throw new Error("Unrecognized modifier name: " + mod); + } + if (alt) result = "Alt-" + result; + if (ctrl) result = "Ctrl-" + result; + if (meta) result = "Meta-" + result; + if (shift) result = "Shift-" + result; + return result; +} +function normalize(map: { [key: string]: Command }) { + const copy: { [key: string]: Command } = Object.create(null); + for (const prop in map) copy[normalizeKeyName(prop)] = map[prop]!; + return copy; +} +function modifiers(name: string, event: KeyboardEvent, shift = true) { + if (event.altKey) name = "Alt-" + name; + if (event.ctrlKey) name = "Ctrl-" + name; + if (event.metaKey) name = "Meta-" + name; + if (shift && event.shiftKey) name = "Shift-" + name; + return name; +} +export function keydownHandler(bindings: { + [key: string]: Command; +}): ( + state: EditorState, + dispatch: EditorViewPM["dispatch"], + event: KeyboardEvent +) => boolean { + const map = normalize(bindings); + return function (state, dispatch, event) { + const name = keyName(event); + const direct = map[modifiers(name, event)]; + let baseName; + if (direct && direct(state, dispatch)) return true; + // A character key + if (name.length == 1 && name != " ") { + if (event.shiftKey) { + // In case the name was already modified by shift, try looking + // it up without its shift modifier + const noShift = map[modifiers(name, event, false)]; + if (noShift && noShift(state, dispatch)) return true; + } + if ( + (event.shiftKey || + event.altKey || + event.metaKey || + name.charCodeAt(0) > 127) && + (baseName = base[event.keyCode]) && + baseName != name + ) { + // Try falling back to the keyCode when there's a modifier + // active or the character produced isn't ASCII, and our table + // produces a different name from the the keyCode. See #668, + // #1060 + const fromCode = map[modifiers(baseName, event)]; + if (fromCode && fromCode(state, dispatch)) return true; + } + } + return false; + }; +} diff --git a/src/nodeViews/render.tsx b/src/nodeViews/render.tsx index b381f4cb..c0097e58 100644 --- a/src/nodeViews/render.tsx +++ b/src/nodeViews/render.tsx @@ -1,4 +1,5 @@ import { DOMOutputSpec, Mark } from "prosemirror-model"; +import { Decoration, DecorationAttrs } from "prosemirror-view"; import React, { ReactNode, cloneElement, @@ -56,7 +57,7 @@ export function wrapInMarks( element: JSX.Element, marks: readonly Mark[], isInline: boolean -) { +): JSX.Element { return marks.reduce((acc, mark) => { const outputSpec = mark.type.spec.toDOM?.(mark, isInline); if (!outputSpec) @@ -70,3 +71,27 @@ export function wrapInMarks( return cloneElement(markElement, undefined, acc); }, element); } + +export function wrapInDecorations( + element: JSX.Element, + decorations: Decoration[], + isInline: boolean +) { + return decorations.reduce((acc, deco) => { + const attrs = (deco as Decoration & { type: { attrs: DecorationAttrs } }) + .type.attrs; + + // TODO: figure out style prop + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { nodeName = "span", class: className, style, ...props } = attrs; + + if (isInline) { + return createElement(nodeName, { className, ...props }, acc); + } + + return cloneElement(acc, { + className: `${className} ${acc.props.className ?? ""}`.trim(), + ...props, + }); + }, element); +} diff --git a/yarn.lock b/yarn.lock index 5a402399..f0984c3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1190,6 +1190,7 @@ __metadata: rimraf: ^3.0.2 typescript: ^4.9.5 vite: ^4.1.5 + w3c-keyname: ^2.2.8 peerDependencies: prosemirror-state: ^1.0.0 prosemirror-view: ^1.0.0 @@ -8296,6 +8297,13 @@ __metadata: languageName: node linkType: hard +"w3c-keyname@npm:^2.2.8": + version: 2.2.8 + resolution: "w3c-keyname@npm:2.2.8" + checksum: 95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07 + languageName: node + linkType: hard + "w3c-xmlserializer@npm:^4.0.0": version: 4.0.0 resolution: "w3c-xmlserializer@npm:4.0.0"