From 6b21f7591b01e0624a12a0dc3070075132f7ec21 Mon Sep 17 00:00:00 2001 From: Kalil Smith-Nuevelle Date: Mon, 7 Apr 2025 15:50:57 -0500 Subject: [PATCH] Refactor to use prosemirror-adapter-react for attribute menu --- core/package.json | 2 +- packages/context-editor/package.json | 5 + packages/context-editor/src/ContextEditor.tsx | 52 +++- .../src/components/AttributePanel.tsx | 291 +++++++++--------- .../components/AttributePanelToggleWidget.tsx | 102 ++++++ .../src/components/InlineLinkMenu.tsx | 169 ++++++++++ .../src/plugins/attributePanel.ts | 198 +++++++++++- packages/context-editor/src/plugins/index.ts | 36 ++- .../src/plugins/structureDecorations.ts | 100 ------ packages/context-editor/src/style.css | 11 +- packages/ui/src/icon.tsx | 3 +- pnpm-lock.yaml | 20 +- pnpm-workspace.yaml | 1 + 13 files changed, 694 insertions(+), 296 deletions(-) create mode 100644 packages/context-editor/src/components/AttributePanelToggleWidget.tsx create mode 100644 packages/context-editor/src/components/InlineLinkMenu.tsx delete mode 100644 packages/context-editor/src/plugins/structureDecorations.ts diff --git a/core/package.json b/core/package.json index d0dc92cda..497356361 100644 --- a/core/package.json +++ b/core/package.json @@ -143,7 +143,7 @@ "unified": "^11.0.4", "unist-util-filter": "^5.0.1", "unist-util-visit": "^5.0.0", - "use-debounce": "^10.0.0", + "use-debounce": "catalog:", "utils": "workspace:*", "uuid": "^9.0.0", "zod": "catalog:" diff --git a/packages/context-editor/package.json b/packages/context-editor/package.json index 90395f4cd..74dcfac84 100644 --- a/packages/context-editor/package.json +++ b/packages/context-editor/package.json @@ -76,6 +76,7 @@ "@codemirror/search": "^6.5.10", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.36.4", + "@hookform/resolvers": "catalog:", "@lezer/cpp": "^1.1.2", "@lezer/css": "^1.1.10", "@lezer/html": "^1.3.10", @@ -88,7 +89,9 @@ "@lezer/rust": "^1.0.2", "@lezer/xml": "^1.0.6", "@nytimes/react-prosemirror": "^1.0.0", + "@prosemirror-adapter/core": "^0.4.0", "@prosemirror-adapter/react": "^0.4.0", + "@sinclair/typebox": "catalog:", "deepmerge": "^4.3.1", "fuzzy": "^0.1.3", "install": "^0.13.0", @@ -111,7 +114,9 @@ "react": "catalog:react19", "react-csv-to-table": "^0.0.4", "react-dom": "catalog:react19", + "react-hook-form": "catalog:", "ui": "workspace:*", + "use-debounce": "catalog:", "utils": "workspace:*", "uuid": "^11.0.4" }, diff --git a/packages/context-editor/src/ContextEditor.tsx b/packages/context-editor/src/ContextEditor.tsx index 76f9c8e61..01503413f 100644 --- a/packages/context-editor/src/ContextEditor.tsx +++ b/packages/context-editor/src/ContextEditor.tsx @@ -5,12 +5,12 @@ import { ProsemirrorAdapterProvider, useNodeViewFactory, usePluginViewFactory, + useWidgetViewFactory, } from "@prosemirror-adapter/react"; import { Node } from "prosemirror-model"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { AttributePanel } from "./components/AttributePanel"; import { MenuBar } from "./components/MenuBar"; import { basePlugins } from "./plugins"; import { attributePanelKey } from "./plugins/attributePanel"; @@ -25,6 +25,7 @@ import "katex/dist/katex.min.css"; import { cn } from "utils"; +import { AttributePanelToggleWidget } from "./components/AttributePanelToggleWidget"; import SuggestPanel from "./components/SuggestPanel"; const MENU_BAR_ID = "context-editor-menu-container"; @@ -53,19 +54,23 @@ export interface ContextEditorProps { export interface PanelProps { top: number; left: number; - right: number | string; + right: number; bottom: number; pos: number; + // isLink: boolean; + isOpen: boolean; node?: Partial; } const initPanelProps: PanelProps = { top: 0, left: 0, - right: "100%", + right: 0, bottom: 0, pos: 0, node: undefined, + isOpen: false, + // isLink: false, }; export interface SuggestProps { @@ -96,8 +101,11 @@ function UnwrappedEditor(props: ContextEditorProps) { return ; }; }, [props.atomRenderingComponent]); + + const ToggleWidget = useMemo(() => AttributePanelToggleWidget, []); const nodeViewFactory = useNodeViewFactory(); const pluginViewFactory = usePluginViewFactory(); + const widgetViewFactory = useWidgetViewFactory(); const viewHost = useRef(null); const view = useRef(null); const [panelPosition, setPanelPosition] = useState(initPanelProps); @@ -107,18 +115,24 @@ function UnwrappedEditor(props: ContextEditorProps) { /* plugins from: https://discuss.prosemirror.net/t/lightweight-react-integration-example/2680 */ useEffect(() => { /* Initial Render */ + const getToggleWidget = widgetViewFactory({ + component: ToggleWidget, + as: "span", + }); const state = EditorState.create({ doc: props.initialDoc ? baseSchema.nodeFromJSON(props.initialDoc) : undefined, schema: baseSchema, plugins: [ - ...basePlugins( - baseSchema, + ...basePlugins({ + schema: baseSchema, props, panelPosition, setPanelPosition, suggestData, - setSuggestData - ), + setSuggestData, + getToggleWidget, + pluginViewFactory, + }), ...(props.hideMenu ? [] : [ @@ -144,6 +158,7 @@ function UnwrappedEditor(props: ContextEditorProps) { handleDOMEvents: { focus: () => { /* Reset the panelProps when the editor is focused */ + const { pos, ...props } = initPanelProps; setPanelPosition(initPanelProps); }, }, @@ -155,15 +170,19 @@ function UnwrappedEditor(props: ContextEditorProps) { useEffect(() => { /* Every Render */ - if (view.current) { - view.current.setProps({ - editable: () => !props.disabled, - }); - const tr = view.current.state.tr - .setMeta(reactPropsKey, { ...props, suggestData, setSuggestData }) - .setMeta(attributePanelKey, { panelPosition, setPanelPosition }); - view.current?.dispatch(tr); - } + + setTimeout(() => { + if (view.current) { + view.current.setProps({ + editable: () => !props.disabled, + }); + const tr = view.current.state.tr + .setMeta(reactPropsKey, { ...props, suggestData, setSuggestData }) + .setMeta(attributePanelKey, { panelPosition, setPanelPosition }); + view.current?.dispatch(tr); + } + }, 0); + /* It's not clear to me that any of the props need to trigger this to re-render. */ /* Doing so in some cases (onChange for the EditorDash) cause an infinite re-render loop */ /* Figure out what I actually need to render on, and then clean up any useMemo calls if necessary */ @@ -176,7 +195,6 @@ function UnwrappedEditor(props: ContextEditorProps) { >
-
); diff --git a/packages/context-editor/src/components/AttributePanel.tsx b/packages/context-editor/src/components/AttributePanel.tsx index 49df22205..93db19403 100644 --- a/packages/context-editor/src/components/AttributePanel.tsx +++ b/packages/context-editor/src/components/AttributePanel.tsx @@ -1,110 +1,93 @@ import type { Mark } from "prosemirror-model"; -import React, { useEffect, useState } from "react"; -import { EditorView } from "prosemirror-view"; +import React from "react"; +import { usePluginViewContext } from "@prosemirror-adapter/react"; +import { useDebouncedCallback } from "use-debounce"; import { Input } from "ui/input"; import { Label } from "ui/label"; -import type { PanelProps } from "../ContextEditor"; +import { attributePanelKey } from "../plugins/attributePanel"; +import { baseSchema } from "../schemas"; +import { InlineLinkMenu } from "./InlineLinkMenu"; const animationTimeMS = 150; const animationHeightMS = 100; -export interface AttributePanelProps { - panelPosition: PanelProps; - viewRef: React.RefObject; -} +export function AttributePanel() { + const { view } = usePluginViewContext(); + + const attributePanelPluginState = attributePanelKey.getState(view.state); + if (!attributePanelPluginState) { + return null; + } + const { panelPosition: panelProps, setPanelPosition: setPanelProps } = + attributePanelPluginState; -export function AttributePanel({ panelPosition, viewRef }: AttributePanelProps) { - const [position, setPosition] = useState(panelPosition); - /* Set as init position and then keep track of state here, while syncing - so the panel doesn't become out of sync with doc (only an issue if values are shown - and edited elsewhere */ - const [height, setHeight] = useState(0); - useEffect(() => { - if (panelPosition.top === 0) { - setPosition(panelPosition); - setHeight(0); - } else { - const newPosition = { ...position }; - newPosition.top = panelPosition.top; - newPosition.left = panelPosition.left; - setPosition(newPosition); - setTimeout(() => { - setPosition({ ...panelPosition }); - }, 0); - setTimeout(() => { - setHeight(300); - }, animationTimeMS); - } - }, [panelPosition]); const labelClass = "font-normal text-xs"; const inputClass = "h-8 text-xs rounded-sm border-neutral-300"; - const node = position.node; - if (!node) { + const { node, isOpen } = panelProps; + + const isLink = node?.marks && Boolean(baseSchema.marks.link.isInSet(node.marks)); + + if (!node || !isOpen) { return null; } const nodeAttrs = node.attrs || {}; const nodeMarks = node.marks || []; const updateAttr = (attrKey: string, value: string) => { - setPosition({ - ...position, + setPanelProps({ + ...panelProps, node: { ...node, attrs: { ...node.attrs, [attrKey]: value }, }, }); - viewRef.current?.dispatch( - viewRef.current.state.tr.setNodeMarkup( - panelPosition.pos, + view.dispatch( + view.state.tr.setNodeMarkup( + panelProps.pos, node.type, { ...node.attrs, [attrKey]: value }, node.marks ) ); }; - const updateMarkAttr = (index: number, attrKey: string, value: string) => { - const markToReplace = nodeMarks[index]; - const newMarks: Array & { [attr: string]: any }> = [ - ...(node?.marks ?? []), - ]; - newMarks[index].attrs[attrKey] = value; - setPosition({ - ...position, - node: { - ...node, - marks: newMarks as Mark[], - }, - }); - const newMark = viewRef.current?.state.schema.marks[markToReplace.type.name].create({ - ...markToReplace.attrs, + const updateMarkAttr = (mark: Mark, attrKey: string, value: string) => { + const oldMarks = node.marks || []; + const oldMark = oldMarks.find((m) => m.type === mark.type); + const newMark = view.state.schema.marks[mark.type.name].create({ + ...mark.attrs, + ...oldMark?.attrs, [attrKey]: value, }); if (!newMark) { return null; } + setPanelProps({ + ...panelProps, + node: { + ...node, + marks: [...oldMarks.filter((m) => m.type !== mark.type), newMark], + }, + }); - viewRef.current?.dispatch( - viewRef.current.state.tr.addMark( - panelPosition.pos, - panelPosition.pos + (node.nodeSize || 0), - newMark - ) + //TODO: do we need to delete the mark first? + view.dispatch( + view.state.tr.addMark(panelProps.pos, panelProps.pos + (node.nodeSize || 0), newMark) ); }; const updateData = (attrKey: string, value: string) => { - setPosition({ - ...position, + setPanelProps({ + ...panelProps, node: { ...node, attrs: { ...nodeAttrs, data: { ...nodeAttrs.data, [attrKey]: value } }, }, }); - viewRef.current?.dispatch( - viewRef.current.state.tr.setNodeMarkup( - panelPosition.pos, + view.dispatch( + view.state.tr.setNodeMarkup( + panelProps.pos, node.type, { ...node.attrs, data: { ...nodeAttrs.data, [attrKey]: value } }, node.marks @@ -115,119 +98,125 @@ export function AttributePanel({ panelPosition, viewRef }: AttributePanelProps) // that are not marks that need to be specifically rendered const showName = node.type?.name === "math_inline"; + const attrInputs = Object.keys(nodeAttrs).map((attrKey) => { + if (attrKey === "data") { + return null; + } + return ( +
+ + { + updateAttr(attrKey, evt.target.value); + }, 200)} + /> +
+ ); + }); + const coords = view.coordsAtPos(panelProps.pos, -1); + const container = document.getElementById("context-editor-container"); + let topOffset = 0; + let leftOffset = 0; + if (container) { + const containerRect = container.getBoundingClientRect(); + topOffset = -1 * containerRect.top + container.scrollTop; + leftOffset = -1 * containerRect.left + container.scrollLeft; + console.log({ topOffset, leftOffset, containerRect, coords }); + } + + if (node.isBlock) { + topOffset += 30; + } + return ( <> {node && (
-
Attributes
- {showName ? ( -
{node.type?.name}
- ) : null} - {Object.keys(nodeAttrs).map((attrKey) => { - if (attrKey === "data") { - return null; - } - return ( -
- - { - updateAttr(attrKey, evt.target.value); - }} - /> -
- ); - })} - {!!nodeMarks.length && - nodeMarks.map((mark, index) => { - return ( -
-
{mark.type.name}
- {Object.keys(mark.attrs).map((attrKey) => { - if (attrKey === "data") { - return null; - } + {isLink ? ( + {attrInputs} + ) : ( + <> +
Attributes
+ {showName ? ( +
{node.type?.name}
+ ) : null} + {attrInputs} + {!!nodeMarks.length && + nodeMarks.map((mark, index) => { + return ( +
+
+ {mark.type.name} +
+ {Object.keys(mark.attrs).map((attrKey) => { + if (attrKey === "data") { + return null; + } + return ( +
+ + { + updateMarkAttr( + nodeMarks[index], + attrKey, + evt.target.value + ); + }, + 200 + )} + /> +
+ ); + })} +
+ ); + })} + + {nodeAttrs.data && ( + <> +
Data
+ {Object.keys(nodeAttrs.data).map((attrKey) => { return (
{ - updateMarkAttr( - index, - attrKey, - evt.target.value - ); - }} + value={nodeAttrs.data[attrKey] || ""} + onChange={useDebouncedCallback((evt) => { + updateData(attrKey, evt.target.value); + }, 200)} />
); })} -
- ); - })} - - {nodeAttrs.data && ( - <> -
Data
- {Object.keys(nodeAttrs.data).map((attrKey) => { - return ( -
- - { - updateData(attrKey, evt.target.value); - }} - /> -
- ); - })} + + )} )}
)} -
); } diff --git a/packages/context-editor/src/components/AttributePanelToggleWidget.tsx b/packages/context-editor/src/components/AttributePanelToggleWidget.tsx new file mode 100644 index 000000000..cf5016518 --- /dev/null +++ b/packages/context-editor/src/components/AttributePanelToggleWidget.tsx @@ -0,0 +1,102 @@ +import type { Node } from "prosemirror-model"; +import type { EditorView } from "prosemirror-view"; + +import React from "react"; +import { useWidgetViewContext } from "@prosemirror-adapter/react"; +import { TextSelection } from "prosemirror-state"; + +import { attributePanelKey } from "../plugins/attributePanel"; +import { reactPropsKey } from "../plugins/reactProps"; + +export const AttributePanelToggleWidget = () => { + const { view, getPos, spec } = useWidgetViewContext(); + if (!spec || !("node" in spec)) { + return null; + } + const attributePanelPluginState = attributePanelKey.getState(view.state); + if (!attributePanelPluginState) { + return null; + } + const { panelPosition, setPanelPosition } = attributePanelPluginState; + + const onClick = () => { + const pos = getPos(); + if (pos === undefined) { + return; + } + view.dispatch( + view.state.tr.setSelection( + new TextSelection( + view.state.doc.resolve(pos) + // view.state.doc.resolve(getPos() + node.nodeSize) + ) + ) + ); + setPanelPosition({ + ...panelPosition, + isOpen: !panelPosition.isOpen, + node, + pos, + }); + }; + + const node: Node = spec.node; + + const isBlock = node.isBlock; + + return isBlock ? ( + + ) : ( + + ); +}; + +type WidgetProps = React.HTMLAttributes & { + node: Node; + view: EditorView; +}; + +const BlockWidget = ({ node, view, ...props }: WidgetProps) => { + const { pubTypes, pubId } = reactPropsKey.getState(view.state); + let buttonText = ""; + if (node.type.name.includes("context")) { + const currentPubId = node.attrs.pubId; + const currentPubTypeId = node.attrs.pubTypeId; + const currentPubType = pubTypes.find((pubType: any) => { + return pubType.id === currentPubTypeId; + }); + + const currentFieldSlug = node.attrs.fieldSlug || "rd:content"; + const currentField = currentPubType.fields.find((field: any) => { + return field.slug === currentFieldSlug; + }); + + /* TODO: Look up the field name, and figure out if it's local to this doc or not. */ + /* Need to find the pubType and use that name for atoms without fieldSlug */ + + const currentTypeName = currentPubType.name; + if (currentPubId === pubId) { + buttonText = `~${currentField.name}`; + } else { + buttonText = `/${currentTypeName}`; + } + } else { + buttonText = `${node.type.name}${node.type.name === "heading" ? ` ${node.attrs.level}` : ""}`; + } + + return ( +
+ + +
+ ); +}; + +const InlineWidget = ({ node, ...props }: WidgetProps) => { + return ( + + + + + )} + /> +
+ ( + + Open in new tab + + { + updateLinkAttr("target", field.value ? "_blank" : null); + return field.onChange(event); + }} + /> + + + + )} + /> +
+ {children} + + + + ); +}; diff --git a/packages/context-editor/src/plugins/attributePanel.ts b/packages/context-editor/src/plugins/attributePanel.ts index 2392b919e..8929cf693 100644 --- a/packages/context-editor/src/plugins/attributePanel.ts +++ b/packages/context-editor/src/plugins/attributePanel.ts @@ -1,19 +1,195 @@ -import { Plugin, PluginKey } from "prosemirror-state"; +import type { PluginViewSpec, WidgetDecorationFactory } from "@prosemirror-adapter/core"; +import type { ReactPluginViewUserOptions } from "@prosemirror-adapter/react"; +import type { Transaction } from "prosemirror-state"; +import type { Decoration } from "prosemirror-view"; + +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { DecorationSet } from "prosemirror-view"; import type { PanelProps } from "../ContextEditor"; +import { AttributePanel } from "../components/AttributePanel"; + +export const ATTRIBUTE_PANEL_ID = "context-editor-attribute-panel-container"; + +export const attributePanelKey = new PluginKey<{ + panelPosition: PanelProps; + setPanelPosition: React.Dispatch>; +}>("panel"); + +type AttributePanelState = { + panelPosition: PanelProps; + setPanelPosition: React.Dispatch>; + decorations?: DecorationSet; +}; -export const attributePanelKey = new PluginKey("panel"); export default ( panelPosition: PanelProps, - setPanelPosition: React.Dispatch> + setPanelPosition: React.Dispatch>, + getToggleWidget: WidgetDecorationFactory, + pluginViewFactory: (options: ReactPluginViewUserOptions) => PluginViewSpec ) => { - return new Plugin({ - key: attributePanelKey, - state: { - init: () => { - return { panelPosition, setPanelPosition }; + return [ + new Plugin({ + key: attributePanelKey, + state: { + init: () => { + return { panelPosition, setPanelPosition }; + }, + apply: (tr, pluginState, prevEditorState, editorState) => { + return { + ...(tr.getMeta(attributePanelKey) || pluginState), + decorations: getUpdatedDecorations( + tr, + pluginState, + prevEditorState, + editorState, + getToggleWidget + ), + }; + }, + }, + props: { + decorations(state) { + // return this.getState(state)?.decorations; + const decorations: Decoration[] = []; + + state.doc.descendants((node, pos) => { + if (node.type.isBlock) { + decorations.push(getToggleWidget(pos, { node })); + } + const isInline = !node.type.isBlock; + const hasMarks = !!node.marks.length; + const isMath = node.type.name === "math_inline"; + if (isInline && (hasMarks || isMath)) { + /* If it's an inline node with marks OR is inline math */ + decorations.push(getToggleWidget(pos, { node })); + } + }); + return DecorationSet.create(state.doc, decorations); + }, + + // handleClick: (view, pos, event) => { + // const $pos = view.state.doc.resolve(pos); + + // const mark = $pos.marks()[0]; + + // const range = getMarkRange($pos, mark.type); + + // const node = $pos.parent; + + // const isLink = Boolean( + // baseSchema.marks.link.isInSet([ + // ...(view.state.storedMarks || []), + // ...($pos.marks() || []), + // ]) + // ); + // console.log({ pos, node, event }); + // setPanelPosition({ + // ...panelPosition, + // isOpen: !panelPosition.isOpen, + // isLink, + // pos: range?.from || pos, + // node, + // }); + + // return true; + // }, }, - apply: (tr, prev) => tr.getMeta(attributePanelKey) || prev, - }, - }); + // view: () => { + // return { + // update: (editorView, prev) => { + // const pos = editorView.state.selection.$from.pos; + // const prevPos = prev.selection.$from.pos; + + // if (pos !== undefined && prevPos !== pos) { + // console.log({ + // msg: "updating panel pos", + // pos, + // prevPos, + // }); + // const isLink = Boolean( + // baseSchema.marks.link.isInSet([ + // ...(editorView.state.storedMarks || []), + // ...(editorView.state.selection.$from.marks() || []), + // ]) + // ); + // const coords = editorView.coordsAtPos(pos, 1); + // let isOpen = panelPosition.isOpen; + // if (isLink) { + // isOpen = true; + // } + // const node = + // editorView.state.selection.$from.nodeAfter || panelPosition.node; + // if (node?.isText && node.marks?.length === 0) { + // isOpen = false; + // } + // setPanelPosition({ + // ...panelPosition, + // selection: editorView.state.selection, + // isOpen, + // isLink, + // pos, + // }); + // } + // }, + // }; + // }, + }), + new Plugin({ + view: pluginViewFactory({ + component: () => AttributePanel(), + root: (viewDom) => + document.getElementById("context-editor-container") as HTMLElement, + }), + }), + ]; }; + +function getUpdatedDecorations( + tr: Transaction, + pluginState: AttributePanelState, + prevEditorState: EditorState, + editorState: EditorState, + getToggleWidget: WidgetDecorationFactory +) { + let prevDecorationSet = pluginState.decorations; + const decorations: Decoration[] = []; + if (!prevDecorationSet) { + console.log("generating initial decoration set"); + + editorState.doc.descendants((node, pos) => { + if (node.type.isBlock) { + decorations.push(getToggleWidget(pos, { node })); + } + const isInline = !node.type.isBlock; + const hasMarks = !!node.marks.length; + const isMath = node.type.name === "math_inline"; + if (isInline && (hasMarks || isMath)) { + /* If it's an inline node with marks OR is inline math */ + decorations.push(getToggleWidget(pos, { node })); + } + }); + return DecorationSet.create(editorState.doc, decorations); + } + let mappedDecorations = prevDecorationSet.map(tr.mapping, editorState.doc); + + for (const stepMap of tr.mapping.maps) { + stepMap.forEach((oldStart, oldEnd, newStart, newEnd) => { + mappedDecorations = mappedDecorations.remove(mappedDecorations.find(newStart, newEnd)); + editorState.doc.nodesBetween(newStart, newEnd, (node, pos, parent, index) => { + if (node.type.isBlock) { + decorations.push(getToggleWidget(pos, { node })); + } + const isInline = !node.type.isBlock; + const hasMarks = !!node.marks.length; + const isMath = node.type.name === "math_inline"; + if (isInline && (hasMarks || isMath)) { + /* If it's an inline node with marks OR is inline math */ + decorations.push(getToggleWidget(pos, { node })); + } + }); + }); + } + + return prevDecorationSet.map(tr.mapping, editorState.doc).add(editorState.doc, decorations); +} diff --git a/packages/context-editor/src/plugins/index.ts b/packages/context-editor/src/plugins/index.ts index 2e07451fc..31a398e0c 100644 --- a/packages/context-editor/src/plugins/index.ts +++ b/packages/context-editor/src/plugins/index.ts @@ -1,3 +1,6 @@ +import type { PluginViewSpec, WidgetDecorationFactory } from "@prosemirror-adapter/core"; +import type { ReactPluginViewUserOptions } from "@prosemirror-adapter/react"; + import { mathPlugin } from "@benrbray/prosemirror-math"; import { exampleSetup } from "prosemirror-example-setup"; import { Schema } from "prosemirror-model"; @@ -11,29 +14,38 @@ import keymap from "./keymap"; import onChange from "./onChange"; import pasteRules from "./pasteRules"; import reactProps from "./reactProps"; -import structureDecorations from "./structureDecorations"; -export const basePlugins = ( - schema: Schema, - props: ContextEditorProps, - panelPosition: PanelProps, - setPanelPosition: React.Dispatch>, - suggestData: any, - setSuggestData: any -) => { +export const basePlugins = ({ + schema, + props, + panelPosition, + setPanelPosition, + suggestData, + setSuggestData, + getToggleWidget, + pluginViewFactory, +}: { + schema: Schema; + props: ContextEditorProps; + panelPosition: PanelProps; + setPanelPosition: React.Dispatch>; + suggestData: any; + setSuggestData: any; + getToggleWidget: WidgetDecorationFactory; + pluginViewFactory: (options: ReactPluginViewUserOptions) => PluginViewSpec; +}) => { return [ - keymap(schema), ...contextSuggest(suggestData, setSuggestData), // Example setup includes inputRules for headers, blockquotes, codeblock, lists // https://github.com/ProseMirror/prosemirror-example-setup/blob/master/src/inputrules.ts ...exampleSetup({ schema, menuBar: false }), reactProps(props), - structureDecorations(), - attributePanel(panelPosition, setPanelPosition), + ...attributePanel(panelPosition, setPanelPosition, getToggleWidget, pluginViewFactory), onChange(), pasteRules(schema), inputRules(schema), mathPlugin, ...code(schema, {}), + keymap(schema), ]; }; diff --git a/packages/context-editor/src/plugins/structureDecorations.ts b/packages/context-editor/src/plugins/structureDecorations.ts deleted file mode 100644 index 1c32a550a..000000000 --- a/packages/context-editor/src/plugins/structureDecorations.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Node } from "prosemirror-model"; -import { EditorState, Plugin } from "prosemirror-state"; -import { Decoration, DecorationSet } from "prosemirror-view"; - -import type { PanelProps } from "../ContextEditor"; -import { attributePanelKey } from "./attributePanel"; -import { reactPropsKey } from "./reactProps"; - -function wrapWidget( - state: EditorState, - node: Node, - pos: number, - setPanelPosition: React.Dispatch> -) { - return () => { - const { pubTypes, pubId, pubTypeId, disabled } = reactPropsKey.getState(state); - const isBlock = node.isBlock; - const widget = document.createElement(isBlock ? "div" : "span"); - widget.className = isBlock ? "wrap-widget" : "inline-wrap-widget"; - const widgetLineChild = document.createElement("span"); - widget.appendChild(widgetLineChild); - const widgetButtonChild = document.createElement("button"); - widget.appendChild(widgetButtonChild); - if (isBlock) { - widgetButtonChild.innerHTML = `${node.type.name}${node.type.name === "heading" ? ` ${node.attrs.level}` : ""}`; - if (node.type.name.includes("context")) { - const currentPubId = node.attrs.pubId; - const currentPubTypeId = node.attrs.pubTypeId; - const currentPubType = pubTypes.find((pubType: any) => { - return pubType.id === currentPubTypeId; - }); - - const currentFieldSlug = node.attrs.fieldSlug || "rd:content"; - const currentField = currentPubType.fields.find((field: any) => { - return field.slug === currentFieldSlug; - }); - const currentTypeName = currentPubType.name; - let label; - if (currentPubId === pubId) { - label = `~${currentField.name}`; - } else { - label = `/${currentTypeName}`; - } - /* TODO: Look up the field name, and figure out if it's local to this doc or not. */ - /* Need to find the pubType and use that name for atoms without fieldSlug */ - widgetButtonChild.innerHTML = label; - } - widgetButtonChild.className = node.type.name; - } - if (!disabled) { - widget.addEventListener("click", (evt) => { - if (evt.target instanceof Element) { - const rect = evt.target.getBoundingClientRect(); - const container = document.getElementById("context-editor-container"); - if (container) { - const topOffset = - -1 * container.getBoundingClientRect().top + container.scrollTop + 16; - setPanelPosition({ - top: isBlock ? rect.top + 4 + topOffset : rect.top - 17 + topOffset, - left: rect.left, - bottom: rect.bottom, - right: -250, - pos, - node, - }); - } - } - }); - } - return widget; - }; -} - -export default () => { - return new Plugin({ - props: { - decorations: (state) => { - const decorations: Decoration[] = []; - const { setPanelPosition } = attributePanelKey.getState(state); - state.doc.descendants((node, pos) => { - if (node.type.isBlock) { - decorations.push( - Decoration.widget(pos, wrapWidget(state, node, pos, setPanelPosition)) - ); - } - const isInline = !node.type.isBlock; - const hasMarks = !!node.marks.length; - const isMath = node.type.name === "math_inline"; - if (isInline && (hasMarks || isMath)) { - /* If it's an inline node with marks OR is inline math */ - decorations.push( - Decoration.widget(pos, wrapWidget(state, node, pos, setPanelPosition)) - ); - } - }); - return DecorationSet.create(state.doc, decorations); - }, - }, - }); -}; diff --git a/packages/context-editor/src/style.css b/packages/context-editor/src/style.css index 346fdd3a9..60e8a93db 100644 --- a/packages/context-editor/src/style.css +++ b/packages/context-editor/src/style.css @@ -100,7 +100,7 @@ .ProseMirror ol, .ProseMirror li, .ProseMirror section { - border-left: 1px solid #777; + /* border-left: 1px solid #777; */ padding-left: 5px; padding-top: 5px; margin-top: 16px; @@ -119,9 +119,16 @@ .ProseMirror sub, .ProseMirror u, .ProseMirror em { - border-top: 1px solid #777; padding-left: 1px; margin-left: -1px; + &:hover { + background-color: theme(colors.blue.200); + } +} + +.ProseMirror a { + text-decoration: underline; + font-size: 1.1rem; } .ProseMirror h1 { diff --git a/packages/ui/src/icon.tsx b/packages/ui/src/icon.tsx index 22652f634..0ff16cd6a 100644 --- a/packages/ui/src/icon.tsx +++ b/packages/ui/src/icon.tsx @@ -1,4 +1,4 @@ -import type { LucideIcon, LucideProps } from "lucide-react"; +import type { LucideProps } from "lucide-react"; import React from "react"; @@ -41,6 +41,7 @@ export { CurlyBraces, Download, Ellipsis, + ExternalLink, FileText, FlagTriangleRightIcon, FormInput, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c83ca6ad4..ce9e4953e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ catalogs: typescript: specifier: 5.7.2 version: 5.7.2 + use-debounce: + specifier: ^10.0.0 + version: 10.0.3 vitest: specifier: ^3.0.5 version: 3.0.5 @@ -512,7 +515,7 @@ importers: specifier: ^5.0.0 version: 5.0.0 use-debounce: - specifier: ^10.0.0 + specifier: 'catalog:' version: 10.0.3(react@19.0.0) utils: specifier: workspace:* @@ -844,6 +847,9 @@ importers: '@codemirror/view': specifier: ^6.36.4 version: 6.36.4 + '@hookform/resolvers': + specifier: 'catalog:' + version: 3.10.0(react-hook-form@7.54.2(react@19.0.0)) '@lezer/cpp': specifier: ^1.1.2 version: 1.1.2 @@ -880,9 +886,15 @@ importers: '@nytimes/react-prosemirror': specifier: ^1.0.0 version: 1.0.0(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.34.3)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@prosemirror-adapter/core': + specifier: ^0.4.0 + version: 0.4.0 '@prosemirror-adapter/react': specifier: ^0.4.0 version: 0.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@sinclair/typebox': + specifier: 'catalog:' + version: 0.34.30 deepmerge: specifier: ^4.3.1 version: 4.3.1 @@ -949,9 +961,15 @@ importers: react-dom: specifier: catalog:react19 version: 19.0.0(react@19.0.0) + react-hook-form: + specifier: 'catalog:' + version: 7.54.2(react@19.0.0) ui: specifier: workspace:* version: link:../ui + use-debounce: + specifier: 'catalog:' + version: 10.0.3(react@19.0.0) utils: specifier: workspace:* version: link:../utils diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a85fe4cf..6783bfe7d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ catalog: nuqs: "^2.4.1" tsx: ^4.19.0 typescript: 5.7.2 + use-debounce: ^10.0.0 vitest: ^3.0.5 zod: ^3.23.8 react-hook-form: ^7.54.2