Repository: Thinkmill/keystatic
Pull request #353 (2023-06-21 01:02:14)

Fix link popover and tag/attributes pasting
Fix link popover and tag/attributes pasting


vercel[bot]: [vc]: #HNx22z1ZGUVYpBMmPviIo7YWL7jNYJKB42FGHhuCwZI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMtc2l0ZSIsInJvb3REaXJlY3RvcnkiOiJkb2NzIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpbmttaWxsLWxhYnMva2V5c3RhdGljLXNpdGUvQTJ4QzFmOHB3VWhSQ2NncjlKYlF5ckQyd3JGSyIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **keystatic-site** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/keystatic-site/A2xC1f8pwUhRCcgr9JbQyrD2wrFK)) |  |  | Jun 21, 2023 1:02am |


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 324ed2e5cc677cf89db6b039d89f5bc58d989750

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Thinkmill/keystatic/new/pr353?filename=.changeset/thin-lies-do.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0AFix%20link%20popover%20and%20tag%2Fattributes%20pasting%0A)



Patch:
diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json
index 569b4d11..77432b50 100644
--- a/packages/keystatic/package.json
+++ b/packages/keystatic/package.json
@@ -208,6 +208,7 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-element-to-jsx-string": "^15.0.0",
+    "resize-observer-polyfill": "^1.5.1",
     "signal-exit": "^3.0.7",
     "tsx": "^3.8.0",
     "typescript": "^5.1.3"
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/attributes/index.ts b/packages/keystatic/src/form/fields/markdoc/editor/attributes/index.ts
index abb7a26d..3f8cae39 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/attributes/index.ts
+++ b/packages/keystatic/src/form/fields/markdoc/editor/attributes/index.ts
@@ -53,7 +53,7 @@ const attributeNodeView: NodeViewConstructor = (node, outerView, getPos) => {
       verticalAlign: 'middle',
       '::before': {
         paddingInline: tokenSchema.size.space.small,
-        content: 'attr(data-name)',
+        content: 'attr(data-markdoc-attribute)',
         display: 'inline-block',
         backgroundColor: tokenSchema.color.background.surfaceTertiary,
         borderRight: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.alias.borderIdle}`,
@@ -61,7 +61,7 @@ const attributeNodeView: NodeViewConstructor = (node, outerView, getPos) => {
     })
   );
   dom.classList.add(nodeWithBorder);
-  dom.dataset.name =
+  dom.dataset.markdocAttribute =
     node.attrs.name === 'id'
       ? '#'
       : node.attrs.name === 'class'
@@ -75,6 +75,8 @@ const attributeNodeView: NodeViewConstructor = (node, outerView, getPos) => {
     outline: 'none',
     backgroundColor: tokenSchema.color.background.canvas,
   });
+  // to enter this via keyboard, users can press enter while in a node selection on the attribute in the parent document
+  inner.tabIndex = -1;
   dom.appendChild(inner);
 
   const innerView = new EditorView(
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/attributes/schema.ts b/packages/keystatic/src/form/fields/markdoc/editor/attributes/schema.ts
index 1ca29b21..e2256450 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/attributes/schema.ts
+++ b/packages/keystatic/src/form/fields/markdoc/editor/attributes/schema.ts
@@ -1,7 +1,7 @@
 import { css } from '@voussoir/style';
 import { EditorNodeSpec } from '../schema';
 import { Schema } from 'prosemirror-model';
-import { weakMemoize } from '../utils';
+import { markdocIdentifierPattern, weakMemoize } from '../utils';
 
 export const getAttributeType = weakMemoize(function getAttributesType(
   schema: Schema
@@ -22,11 +22,26 @@ export const attributeSchema = {
     content: 'attribute_expression',
     inline: true,
     atom: true,
+    parseDOM: [
+      {
+        tag: '[data-markdoc-attribute]',
+        getAttrs(node) {
+          if (typeof node === 'string') return false;
+          let name = node.getAttribute('data-markdoc-attribute');
+          if (name === '#') name = 'id';
+          if (name === '.') name = 'class';
+          if (name === null || !markdocIdentifierPattern.test(name)) {
+            return false;
+          }
+          return { name };
+        },
+      },
+    ],
     toDOM(node) {
       return [
         'span',
         {
-          'data-attr':
+          'data-markdoc-attribute':
             node.attrs.name === 'id'
               ? '#'
               : node.attrs.name === 'class'
@@ -41,8 +56,9 @@ export const attributeSchema = {
     group: 'attribute_expression',
     content: 'text*',
     marks: '',
+    parseDOM: [{ tag: '[data-markdoc-expression="string"]' }],
     toDOM() {
-      return ['span', { class: css({ color: 'green' }) }, 0];
+      return ['span', { 'data-markdoc-expression': 'string' }, 0];
     },
   },
   attribute_null: {
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/autocomplete/autocomplete.tsx b/packages/keystatic/src/form/fields/markdoc/editor/autocomplete/autocomplete.tsx
index 65bcdb75..43907648 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/autocomplete/autocomplete.tsx
+++ b/packages/keystatic/src/form/fields/markdoc/editor/autocomplete/autocomplete.tsx
@@ -1,15 +1,12 @@
 import {
   EditorListboxProps,
   EditorPopover,
-  VirtualElement,
   useEditorListbox,
 } from '../new-primitives';
-import { useMemo, useState } from 'react';
-import {
-  useEditorViewRef,
-  useLayoutEffectWithEditorUpdated,
-} from '../editor-view';
+import { useMemo } from 'react';
+import { useEditorViewRef } from '../editor-view';
 import { useEditorKeydownListener } from '../keydown';
+import { useEditorReferenceElement } from '../popovers/reference';
 
 export function EditorAutocomplete<Item extends object>(
   props: Omit<EditorListboxProps<Item>, 'listenerRef'> & {
@@ -18,33 +15,7 @@ export function EditorAutocomplete<Item extends object>(
   }
 ) {
   const viewRef = useEditorViewRef();
-  const [referenceElement, setReferenceElement] = useState<VirtualElement>({
-    getBoundingClientRect() {
-      const coords = viewRef.current!.coordsAtPos(props.from, props.to);
-      return {
-        x: coords.left,
-        y: coords.top,
-        width: coords.right - coords.left,
-        height: coords.bottom - coords.top,
-        top: coords.top,
-        right: coords.right,
-        bottom: coords.bottom,
-        left: coords.left,
-      };
-    },
-  });
-  useLayoutEffectWithEditorUpdated(() => {
-    const node = viewRef.current!.domAtPos(props.from).node;
-    const contextElement = node instanceof Element ? node : node.parentElement;
-    setReferenceElement(prevState => {
-      return prevState.contextElement === contextElement
-        ? prevState
-        : {
-            getBoundingClientRect: prevState.getBoundingClientRect,
-            contextElement: contextElement ?? undefined,
-          };
-    });
-  });
+  const referenceElement = useEditorReferenceElement(props.from, props.to);
   const listenerRef = useMemo(() => {
     return {
       get current() {
@@ -62,13 +33,15 @@ export function EditorAutocomplete<Item extends object>(
     return event.defaultPrevented;
   });
   return (
-    <EditorPopover
-      reference={referenceElement}
-      placement="bottom-start"
-      adaptToViewport="stretch"
-      minWidth="element.medium"
-    >
-      {listbox}
-    </EditorPopover>
+    referenceElement && (
+      <EditorPopover
+        reference={referenceElement}
+        placement="bottom-start"
+        adaptToViewport="stretch"
+        minWidth="element.medium"
+      >
+        {listbox}
+      </EditorPopover>
+    )
   );
 }
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/editor-view.tsx b/packages/keystatic/src/form/fields/markdoc/editor/editor-view.tsx
index 91d9aec0..a77a2bc4 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/editor-view.tsx
+++ b/packages/keystatic/src/form/fields/markdoc/editor/editor-view.tsx
@@ -5,6 +5,7 @@ import React, {
   MutableRefObject,
   ReactNode,
   forwardRef,
+  useCallback,
   useContext,
   useImperativeHandle,
   useLayoutEffect,
@@ -36,6 +37,17 @@ export function useEditorViewRef() {
   return useStableEditorContext().view;
 }
 
+export function useEditorViewInEffect() {
+  const editorViewRef = useEditorViewRef();
+  const state = useEditorState();
+  return useCallback(() => {
+    if (editorViewRef.current && editorViewRef.current.state !== state) {
+      editorViewRef.current?.updateState(state);
+    }
+    return editorViewRef.current;
+  }, [editorViewRef, state]);
+}
+
 export function useLayoutEffectWithEditorUpdated(effect: () => void) {
   const editorView = useEditorViewRef();
   const state = useEditorState();
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/index.tsx b/packages/keystatic/src/form/fields/markdoc/editor/index.tsx
index 4a26dd2b..3fb55d15 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/index.tsx
+++ b/packages/keystatic/src/form/fields/markdoc/editor/index.tsx
@@ -6,7 +6,7 @@ import { css } from '@emotion/css';
 import { tokenSchema } from '@voussoir/style';
 import { Toolbar } from './Toolbar';
 import { prosemirrorStyles } from './utils';
-import { EditorPopover } from './popovers';
+import { EditorPopoverDecoration } from './popovers';
 import { ProseMirrorEditable, ProseMirrorEditor } from './editor-view';
 import { AutocompleteDecoration } from './autocomplete/decoration';
 
@@ -43,6 +43,7 @@ const editableStyles = css({
   a: {
     color: tokenSchema.color.foreground.accent,
   },
+  color: tokenSchema.color.foreground.neutral,
 });
 
 export const Editor = forwardRef(function Editor(
@@ -64,7 +65,7 @@ export const Editor = forwardRef(function Editor(
         <Toolbar />
         <ProseMirrorEditable className={editableStyles} />
       </Box>
-      <EditorPopover state={props.value} />
+      <EditorPopoverDecoration state={props.value} />
       <AutocompleteDecoration />
     </ProseMirrorEditor>
   );
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/popovers/index.tsx b/packages/keystatic/src/form/fields/markdoc/editor/popovers/index.tsx
index acf72d60..ac5ffaf9 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/popovers/index.tsx
+++ b/packages/keystatic/src/form/fields/markdoc/editor/popovers/index.tsx
@@ -1,190 +1,173 @@
-import { Node } from 'prosemirror-model';
+import { Mark, MarkType, Node, ResolvedPos } from 'prosemirror-model';
 import { EditorState, TextSelection } from 'prosemirror-state';
-import {
-  ReactElement,
-  RefObject,
-  useCallback,
-  useLayoutEffect,
-  useMemo,
-  useRef,
-} from 'react';
+import { ReactElement, useMemo } from 'react';
 import { EditorSchema, getEditorSchema } from '../schema';
-import { BlockPopover } from '../primitives/BlockPopover';
 import { ActionButton } from '@voussoir/button';
 import { Icon } from '@voussoir/icon';
 import { trash2Icon } from '@voussoir/icon/icons/trash2Icon';
 import { Flex } from '@voussoir/layout';
 import { TooltipTrigger, Tooltip } from '@voussoir/tooltip';
-import {
-  useEditorViewRef,
-  useEditorDispatchCommand,
-  useLayoutEffectWithEditorUpdated,
-} from '../editor-view';
+import { useEditorDispatchCommand } from '../editor-view';
 import { LinkToolbar } from './link-toolbar';
 import { ToolbarSeparator } from '../primitives';
 import { CodeBlockLanguageCombobox } from './code-block-language';
+import { useEditorReferenceElement } from './reference';
+import { EditorPopover } from '../new-primitives';
 
-type PopoverRenderer = (props: {
+type NodePopoverRenderer = (props: {
   node: Node;
   state: EditorState;
   pos: number;
 }) => ReactElement | null;
 
-const popoverComponents: Record<string, PopoverRenderer> = {
+const popoverComponents: Record<string, NodePopoverRenderer> = {
   code_block: function CodeBlockPopover(props) {
-    const viewRef = useEditorViewRef();
     const dispatchCommand = useEditorDispatchCommand();
-    const triggerRef = useMemo((): RefObject<HTMLElement | null> => {
-      return {
-        get current() {
-          if (!viewRef.current) return null;
-          const { node } = viewRef.current.domAtPos(props.pos + 1);
-          if (!(node instanceof HTMLElement)) return null;
-          return node.parentElement;
-        },
-      };
-    }, [props.pos, viewRef]);
-    const updatePositionRef = useRef<() => void>(() => {});
-    useLayoutEffectWithEditorUpdated(
-      useCallback(() => {
-        // just to indicate that this is a dependency of the effect
-        // because we want to update the position when the state changes
-        // eslint-disable-next-line no-unused-expressions
-        props.state;
-        updatePositionRef.current();
-      }, [props.state, updatePositionRef])
-    );
     return (
-      <BlockPopover triggerRef={triggerRef} ref={updatePositionRef}>
-        <Flex gap="regular" padding="regular">
-          <CodeBlockLanguageCombobox
-            value={props.node.attrs.language}
-            onChange={val => {
+      <Flex gap="regular" padding="regular">
+        <CodeBlockLanguageCombobox
+          value={props.node.attrs.language}
+          onChange={val => {
+            dispatchCommand((state, dispatch) => {
+              if (dispatch) {
+                dispatch(
+                  state.tr.setNodeMarkup(props.pos, undefined, {
+                    ...props.node.attrs,
+                    language: val,
+                  })
+                );
+              }
+              return true;
+            });
+          }}
+        />
+        <ToolbarSeparator />
+        <TooltipTrigger>
+          <ActionButton
+            prominence="low"
+            onPress={() => {
               dispatchCommand((state, dispatch) => {
                 if (dispatch) {
                   dispatch(
-                    state.tr.setNodeMarkup(props.pos, undefined, {
-                      ...props.node.attrs,
-                      language: val,
-                    })
+                    state.tr.delete(props.pos, props.pos + props.node.nodeSize)
                   );
                 }
                 return true;
               });
             }}
-          />
-          <ToolbarSeparator />
-          <TooltipTrigger>
-            <ActionButton
-              prominence="low"
-              onPress={() => {
-                dispatchCommand((state, dispatch) => {
-                  if (dispatch) {
-                    dispatch(
-                      state.tr.delete(
-                        props.pos,
-                        props.pos + props.node.nodeSize
-                      )
-                    );
-                  }
-                  return true;
-                });
-              }}
-            >
-              <Icon src={trash2Icon} />
-            </ActionButton>
-            <Tooltip tone="critical">Remove</Tooltip>
-          </TooltipTrigger>
-        </Flex>
-      </BlockPopover>
+          >
+            <Icon src={trash2Icon} />
+          </ActionButton>
+          <Tooltip tone="critical">Remove</Tooltip>
+        </TooltipTrigger>
+      </Flex>
     );
   },
-} satisfies Partial<Record<keyof EditorSchema['nodes'], PopoverRenderer>>;
+} satisfies Partial<Record<keyof EditorSchema['nodes'], NodePopoverRenderer>>;
+
+function markAround($pos: ResolvedPos, markType: MarkType) {
+  const { parent, parentOffset } = $pos;
+  const start = parent.childAfter(parentOffset);
+  if (!start.node) return null;
+
+  const mark = start.node.marks.find(mark => mark.type === markType);
+  if (!mark) return null;
+
+  let startIndex = $pos.index();
+  let startPos = $pos.start() + start.offset;
+  let endIndex = startIndex + 1;
+  let endPos = startPos + start.node.nodeSize;
+  while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) {
+    startIndex -= 1;
+    startPos -= parent.child(startIndex).nodeSize;
+  }
+  while (
+    endIndex < parent.childCount &&
+    mark.isInSet(parent.child(endIndex).marks)
+  ) {
+    endPos += parent.child(endIndex).nodeSize;
+    endIndex += 1;
+  }
+  return { from: startPos, to: endPos, mark };
+}
+
+type MarkPopoverRenderer = (props: {
+  mark: Mark;
+  state: EditorState;
+  from: number;
+  to: number;
+}) => ReactElement | null;
 
-// TODO: this is broken
-const LinkPopover: PopoverRenderer = props => {
-  const viewRef = useEditorViewRef();
+const LinkPopover: MarkPopoverRenderer = props => {
   const dispatchCommand = useEditorDispatchCommand();
-  const triggerRef = useMemo((): RefObject<HTMLElement | null> => {
-    return {
-      get current() {
-        if (!viewRef.current) return null;
-        const { node } = viewRef.current.domAtPos(props.pos + 1);
-        if (!(node instanceof HTMLElement)) return null;
-        return node;
-      },
-    };
-  }, [props.pos, viewRef]);
-  const updatePositionRef = useRef<() => void>(() => {});
-  useLayoutEffect(() => {
-    viewRef.current?.updateState(props.state);
-    updatePositionRef.current?.();
-  }, [props.pos, props.state, triggerRef, viewRef, updatePositionRef]);
-  const schema = getEditorSchema(props.state.schema);
-  if (!schema.marks.link) return null;
-  const href = schema.marks.link.isInSet(props.node.marks)?.attrs.href;
+  const href = props.mark.attrs.href;
   if (typeof href !== 'string') {
     return null;
   }
   return (
-    <BlockPopover triggerRef={triggerRef} ref={updatePositionRef}>
-      <LinkToolbar
-        href={href}
-        onUnlink={() => {
-          dispatchCommand((state, dispatch) => {
-            if (dispatch) {
-              dispatch(
-                state.tr.removeMark(
-                  props.pos,
-                  props.pos + props.node.nodeSize,
-                  state.schema.marks.link
+    <LinkToolbar
+      href={href}
+      onUnlink={() => {
+        dispatchCommand((state, dispatch) => {
+          if (dispatch) {
+            dispatch(
+              state.tr.removeMark(props.from, props.to, state.schema.marks.link)
+            );
+          }
+          return true;
+        });
+      }}
+      onHrefChange={href => {
+        dispatchCommand((state, dispatch) => {
+          if (dispatch) {
+            dispatch(
+              state.tr
+                .removeMark(props.from, props.to, state.schema.marks.link)
+                .addMark(
+                  props.from,
+                  props.to,
+                  state.schema.marks.link.create({ href })
                 )
-              );
-            }
-            return true;
-          });
-        }}
-        onHrefChange={href => {
-          dispatchCommand((state, dispatch) => {
-            if (dispatch) {
-              dispatch(
-                state.tr
-                  .removeMark(
-                    props.pos,
-                    props.pos + props.node.nodeSize,
-                    state.schema.marks.link
-                  )
-                  .addMark(
-                    props.pos,
-                    props.pos + props.node.nodeSize,
-                    state.schema.marks.link.create({ href })
-                  )
-              );
-            }
-            return true;
-          });
-        }}
-      />
-    </BlockPopover>
+            );
+          }
+          return true;
+        });
+      }}
+    />
   );
 };
 
-function getPopoverDecoration(
-  state: EditorState
-): { node: Node; component: PopoverRenderer; pos: number } | null {
+type PopoverDecoration =
+  | {
+      kind: 'node';
+      component: NodePopoverRenderer;
+      node: Node;
+      pos: number;
+    }
+  | {
+      kind: 'mark';
+      component: MarkPopoverRenderer;
+      mark: Mark;
+      from: number;
+      to: number;
+    };
+function getPopoverDecoration(state: EditorState): PopoverDecoration | null {
   if (state.selection instanceof TextSelection) {
     const schema = getEditorSchema(state.schema);
-    if (schema.marks.link) {
-      const { $from, $to } = state.selection;
-      const nodeAfterFrom = $from.nodeAfter;
-      const nodeAfterTo = $to.nodeAfter;
-      if (nodeAfterFrom && nodeAfterTo) {
-        const linkMarkInFrom = schema.marks.link.isInSet(nodeAfterFrom.marks);
-        const linkMarkInTo = schema.marks.link.isInSet(nodeAfterTo.marks);
-        if (linkMarkInFrom && linkMarkInTo === linkMarkInTo) {
-          return { node: nodeAfterTo, component: LinkPopover, pos: $from.pos };
-        }
-      }
+    const linkAroundFrom = markAround(state.selection.$from, schema.marks.link);
+    const linkAroundTo = markAround(state.selection.$to, schema.marks.link);
+    if (
+      linkAroundFrom &&
+      linkAroundFrom.from === linkAroundTo?.from &&
+      linkAroundFrom.to === linkAroundTo.to
+    ) {
+      return {
+        kind: 'mark',
+        component: LinkPopover,
+        mark: linkAroundFrom.mark,
+        from: linkAroundFrom.from,
+        to: linkAroundFrom.to,
+      };
     }
   }
   const commonAncestorPos = state.selection.$from.start(
@@ -196,25 +179,60 @@ function getPopoverDecoration(
     if (!node) break;
     const renderer = popoverComponents[node.type.name];
     if (renderer !== undefined) {
-      return { node, component: renderer, pos: $pos.start(i) - 1 };
+      return {
+        kind: 'node',
+        node,
+        component: renderer,
+        pos: $pos.start(i) - 1,
+      };
     }
   }
 
   return null;
 }
 
-export function EditorPopover(props: { state: EditorState }) {
+function PopoverInner(props: {
+  decoration: PopoverDecoration;
+  state: EditorState;
+}) {
+  const from =
+    props.decoration.kind === 'node'
+      ? props.decoration.pos
+      : props.decoration.from;
+  const to =
+    props.decoration.kind === 'node'
+      ? props.decoration.pos + props.decoration.node.nodeSize
+      : props.decoration.to;
+  const reference = useEditorReferenceElement(from, to);
+  return (
+    reference && (
+      <EditorPopover
+        reference={reference}
+        placement="bottom"
+        adaptToViewport="stretch"
+        minWidth="element.medium"
+      >
+        {props.decoration.kind === 'node' ? (
+          <props.decoration.component
+            {...props.decoration}
+            state={props.state}
+          />
+        ) : (
+          <props.decoration.component
+            {...props.decoration}
+            state={props.state}
+          />
+        )}
+      </EditorPopover>
+    )
+  );
+}
+
+export function EditorPopoverDecoration(props: { state: EditorState }) {
   const popoverDecoration = useMemo(
     () => getPopoverDecoration(props.state),
     [props.state]
   );
   if (!popoverDecoration) return null;
-  return (
-    <popoverDecoration.component
-      key={popoverDecoration.node.type.name}
-      node={popoverDecoration.node}
-      pos={popoverDecoration.pos}
-      state={props.state}
-    />
-  );
+  return <PopoverInner decoration={popoverDecoration} state={props.state} />;
 }
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/popovers/reference.tsx b/packages/keystatic/src/form/fields/markdoc/editor/popovers/reference.tsx
new file mode 100644
index 00000000..7a6e9ec6
--- /dev/null
+++ b/packages/keystatic/src/form/fields/markdoc/editor/popovers/reference.tsx
@@ -0,0 +1,42 @@
+import { ReferenceElement } from '@floating-ui/react';
+import { useLayoutEffect, useState } from 'react';
+import { useEditorViewInEffect } from '../editor-view';
+import { EditorView } from 'prosemirror-view';
+
+function getReferenceElementForRange(
+  view: EditorView,
+  from: number,
+  to: number
+): ReferenceElement | null {
+  const nodeAtFrom = view.state.doc.nodeAt(from);
+  if (nodeAtFrom !== null && to === from + nodeAtFrom.nodeSize) {
+    const node = view.nodeDOM(from);
+    if (node instanceof Element) {
+      return node;
+    }
+  }
+  const fromDom = view.domAtPos(from);
+  const toDom = view.domAtPos(to);
+  const range = document.createRange();
+  range.setStart(fromDom.node, fromDom.offset);
+  range.setEnd(toDom.node, toDom.offset);
+  return range;
+}
+
+export function useEditorReferenceElement(
+  from: number,
+  to: number
+): ReferenceElement | null {
+  const [referenceElement, setReferenceElement] =
+    useState<ReferenceElement | null>(null);
+  const getEditorView = useEditorViewInEffect();
+  useLayoutEffect(() => {
+    const view = getEditorView();
+    if (!view) {
+      setReferenceElement(null);
+      return;
+    }
+    setReferenceElement(getReferenceElementForRange(view, from, to));
+  }, [getEditorView, from, to]);
+  return referenceElement;
+}
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx b/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx
index 125a236d..dcdb7f02 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx
+++ b/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx
@@ -11,7 +11,7 @@ import {
   Slice,
   Fragment,
 } from 'prosemirror-model';
-import { classes, nodeWithBorder } from './utils';
+import { classes, markdocIdentifierPattern, nodeWithBorder } from './utils';
 import {
   InsertMenuItem,
   WithInsertMenuNodeSpec,
@@ -66,6 +66,7 @@ const dividerDOM: DOMOutputSpec = [
 const codeDOM: DOMOutputSpec = [
   'pre',
   {
+    spellcheck: 'false',
     class: css({
       backgroundColor: tokenSchema.color.background.surface,
       borderRadius: tokenSchema.size.radius.medium,
@@ -127,10 +128,12 @@ const nodeSpecs = {
     content: 'attribute*',
     selectable: false,
     defining: true,
+    parseDOM: [{ tag: '[data-markdoc-attributes]' }],
     toDOM() {
       return [
         'div',
         {
+          'data-markdoc-attributes': '1',
           class: css({
             display: 'block',
             backgroundColor: tokenSchema.color.background.surface,
@@ -212,9 +215,22 @@ const nodeSpecs = {
     defining: true,
     [independentForGapCursor]: true,
     content: 'tag_attributes tag_slot* block*',
+    parseDOM: [
+      {
+        tag: '[data-markdoc-tag]',
+        getAttrs(node) {
+          if (typeof node === 'string') return false;
+          let name = node.getAttribute('data-markdoc-tag');
+          if (name === null || !markdocIdentifierPattern.test(name)) {
+            return false;
+          }
+          return { name };
+        },
+      },
+    ],
     toDOM(node) {
       const element = document.createElement('div');
-      element.dataset.tag = node.attrs.name;
+      element.dataset.markdocTag = node.attrs.name;
       element.style.setProperty('--tag-name', JSON.stringify(node.attrs.name));
       element.classList.add(
         css({
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/utils.ts b/packages/keystatic/src/form/fields/markdoc/editor/utils.ts
index 0e478ad0..2f9a7b16 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/utils.ts
+++ b/packages/keystatic/src/form/fields/markdoc/editor/utils.ts
@@ -8,6 +8,8 @@ export const classes = {
   focused: 'ProseMirror-focused',
 };
 
+export const markdocIdentifierPattern = /^[a-zA-Z][-_a-zA-Z0-9]*$/;
+
 export function weakMemoize<Arg extends object, Return>(
   func: (arg: Arg) => Return
 ): (arg: Arg) => Return {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7fcbc79a..05c3eac8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2421,6 +2421,7 @@ importers:
       react: ^18.2.0
       react-dom: ^18.2.0
       react-element-to-jsx-string: ^15.0.0
+      resize-observer-polyfill: ^1.5.1
       scroll-into-view-if-needed: ^3.0.3
       signal-exit: ^3.0.7
       slate: ^0.91.4
@@ -2549,6 +2550,7 @@ importers:
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
       react-element-to-jsx-string: 15.0.0_biqbaboplfbrettd7655fr4n2y
+      resize-observer-polyfill: 1.5.1
       signal-exit: 3.0.7
       tsx: 3.12.3
       typescript: 5.1.3


Test patch:
diff --git a/packages/keystatic/src/form/fields/markdoc/editor/tests/utils.tsx b/packages/keystatic/src/form/fields/markdoc/editor/tests/utils.tsx
index f7f8c91c..633c4d8f 100644
--- a/packages/keystatic/src/form/fields/markdoc/editor/tests/utils.tsx
+++ b/packages/keystatic/src/form/fields/markdoc/editor/tests/utils.tsx
@@ -1,3 +1,4 @@
+import ResizeObserver from 'resize-observer-polyfill';
 import { createEditorState } from '../editor-state';
 import { createEditorSchema, EditorSchema } from '../schema';
 import { Attrs, Mark, Node, NodeType, Schema } from 'prosemirror-model';
@@ -12,6 +13,9 @@ import { plugins, format, NewPlugin } from 'pretty-format';
 import { EditorView } from 'prosemirror-view';
 import { editorStateToReactNode } from './editor-state-to-react-element';
 
+// this polyfill is because jsdom doesn't have it and @floating-ui/react uses it
+globalThis.ResizeObserver = ResizeObserver;
+
 type SelectionConfig =
   | {
       kind: 'text';