Ver Fonte

feat(editor): add unresolved reference styling and ref click events

- Accept resolvedRefs (Set<string>) + resolvedRefsVersion props on
  Editor/EditorBlock; parent bumps version to trigger re-decoration
  across all blocks (compatible with reactive(Set) where .add/.delete
  changes .size)
- Unresolved [[Page]] and ((block-id)) get .md-ref-unresolved dashed
  border + "Unresolved reference" tooltip via decoration plugin
- Emit page-ref-clicked, block-ref-clicked, tag-clicked from
  handleDOMEvents.click — returns true to prevent cursor placement
  inside chip
- Fix stale "not yet wired" comment in operation-history.ts
Zander Hawke há 2 dias atrás
pai
commit
024566c4ec

+ 4 - 0
packages/editor/src/assets/style.css

@@ -91,6 +91,10 @@
   @apply bg-(--ui-success)/10 text-(--ui-success) border-(--ui-success)/20 hover:bg-(--ui-success)/20;
 }
 
+.md-ref-unresolved {
+  @apply border-dashed border-(--ui-text-muted)/40;
+}
+
 /* ── Properties ──────────────────────────────────────────────────── */
 
 .md-property-key {

+ 10 - 0
packages/editor/src/components/Editor.vue

@@ -50,6 +50,9 @@ const emit = defineEmits<{
   blur: []
   "selection-change": [payload: { from: number; to: number; empty: boolean }]
   error: [payload: { code: string; message?: string; blockId?: string }]
+  "page-ref-clicked": [payload: { pageName: string; alias?: string }]
+  "block-ref-clicked": [payload: { blockId: string }]
+  "tag-clicked": [payload: { tag: string }]
 }>()
 
 const props = withDefaults(
@@ -60,6 +63,8 @@ const props = withDefaults(
     theme?: ThemeInput
     keyBinding?: KeyBinding
     extraPatterns?: import("@/composables/usePatternPlugin").PatternSpec[]
+    resolvedRefs?: Set<string>
+    resolvedRefsVersion?: number
   }>(),
   {
     focused: false,
@@ -773,6 +778,8 @@ defineExpose({
           :debug="debug"
           :registry="registry"
           :extra-patterns="extraPatterns"
+          :resolved-refs="resolvedRefs"
+          :resolved-refs-version="resolvedRefsVersion"
           class="flex-1 min-w-0"
           @update:content="onBlockContentChange"
           @split="(before, after) => onSplit(i, before, after)"
@@ -790,6 +797,9 @@ defineExpose({
           @pattern-close="onPatternClose"
           @content-change-op="onBlockContentChangeOp"
           @error="emit('error', $event)"
+          @page-ref-clicked="emit('page-ref-clicked', $event)"
+          @block-ref-clicked="emit('block-ref-clicked', $event)"
+          @tag-clicked="emit('tag-clicked', $event)"
         />
       </div>
     </template>

+ 56 - 0
packages/editor/src/components/EditorBlock.vue

@@ -98,6 +98,17 @@ const props = defineProps<{
    * Useful for adding custom suggestion menu triggers like @mention.
    */
   extraPatterns?: PatternSpec[]
+  /**
+   * Known page/block IDs. References absent from this set ([[Page]] or
+   * ((block-id))) are rendered as unresolved (dashed underline + tooltip).
+   */
+  resolvedRefs?: Set<string>
+  /**
+   * Bump to trigger re-decoration with updated resolvedRefs. The parent
+   * can use e.g. `computed(() => resolvedRefs.size)` so native Set
+   * mutations (.add, .delete) automatically produce a new version.
+   */
+  resolvedRefsVersion?: number
 }>()
 
 const emit = defineEmits<{
@@ -122,6 +133,9 @@ const emit = defineEmits<{
     timestamp: number
   }]
   error: [payload: { code: string; message?: string; blockId?: string }]
+  "page-ref-clicked": [payload: { pageName: string; alias?: string }]
+  "block-ref-clicked": [payload: { blockId: string }]
+  "tag-clicked": [payload: { tag: string }]
 }>()
 
 const log = createLogger("Block", props.debug)
@@ -640,6 +654,35 @@ onMounted(() => {
           emit("blur")
           return false
         },
+        click(_evView, event) {
+          const domTarget = event.target as HTMLElement | null
+          const pageRefEl = domTarget?.closest?.(".md-page-ref")
+          if (pageRefEl instanceof HTMLElement) {
+            const pageName =
+              pageRefEl.getAttribute("data-page-name") ?? ""
+            const alias =
+              pageRefEl.getAttribute("data-alias") ?? undefined
+            event.preventDefault()
+            emit("page-ref-clicked", { pageName, alias })
+            return true
+          }
+          const blockRefEl = domTarget?.closest?.(".md-block-ref")
+          if (blockRefEl instanceof HTMLElement) {
+            const blockId =
+              blockRefEl.getAttribute("data-block-id") ?? ""
+            event.preventDefault()
+            emit("block-ref-clicked", { blockId })
+            return true
+          }
+          const tagEl = domTarget?.closest?.(".md-tag")
+          if (tagEl instanceof HTMLElement) {
+            const tag = tagEl.getAttribute("data-tag") ?? ""
+            event.preventDefault()
+            emit("tag-clicked", { tag })
+            return true
+          }
+          return false
+        },
       },
     })
 
@@ -692,6 +735,19 @@ watch(
   },
 )
 
+watch(
+  () => props.resolvedRefsVersion,
+  (version) => {
+    if (!view || version === undefined) return
+    view.dispatch(
+      view.state.tr.setMeta("decorations:resolvedRefs", {
+        refs: props.resolvedRefs ?? new Set<string>(),
+        version,
+      }),
+    )
+  },
+)
+
 watchDebounced(
   () => content.value,
   (newContent) => {

+ 46 - 8
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -218,6 +218,7 @@ function buildDecorationsFromCache(
   contentCaches: Map<string, BlockTokenCache>,
   isFocused: boolean,
   markerMode: MarkerVisibilityMode,
+  resolvedRefs: Set<string>,
 ): DecorationSet {
   const decorationSpecs: Decoration[] = []
 
@@ -283,6 +284,7 @@ function buildDecorationsFromCache(
       decorationSpecs,
       isFocused,
       markerMode,
+      resolvedRefs,
     )
   }
 
@@ -501,6 +503,7 @@ function applyInlineDecorations(
   decorationSpecs: Decoration[],
   isFocused: boolean,
   markerMode: MarkerVisibilityMode,
+  resolvedRefs: Set<string>,
 ) {
   const ranges = token.decorationRanges
   if (ranges.length === 0) return
@@ -592,10 +595,30 @@ function applyInlineDecorations(
         }
       }
 
+      let extraAttrs: Record<string, string> = {}
+
+      // Unresolved reference detection: if a page-ref or block-ref
+      // chip's target is absent from resolvedRefs, style it with a
+      // dashed underline and title tooltip.
+      if (
+        (token.type === "page-ref" || token.type === "block-ref") &&
+        !isHiddenDecorator
+      ) {
+        const refName =
+          token.type === "page-ref"
+            ? token.wrapperAttrs?.["data-page-name"]
+            : token.wrapperAttrs?.["data-block-id"]
+        if (refName && !resolvedRefs.has(refName)) {
+          rangeClass = `${rangeClass} md-ref-unresolved`
+          extraAttrs = { title: "Unresolved reference" }
+        }
+      }
+
       decorationSpecs.push(
         Decoration.inline(spec.from, spec.to, {
           nodeName: "span",
           class: rangeClass,
+          ...extraAttrs,
           ...(token.wrapperAttrs ?? {}),
         }),
       )
@@ -858,6 +881,8 @@ interface DecorationState {
   hasFocus: boolean
   markerMode: MarkerVisibilityMode
   decorationVersion: number
+  resolvedRefs: Set<string>
+  resolvedRefsVersion: number
 }
 
 const markdownDecorationsKey = new PluginKey<DecorationState>(
@@ -914,8 +939,6 @@ export function createMarkdownDecorationsPlugin(
 ) {
   let cachedSet: DecorationSet | null = null
   let cachedVersion = -1
-  let cachedFocus = false
-  let cachedMode = initialMarkerMode
 
   const plugin = new Plugin<DecorationState>({
     key: markdownDecorationsKey,
@@ -941,6 +964,8 @@ export function createMarkdownDecorationsPlugin(
           hasFocus: false,
           markerMode: initialMarkerMode,
           decorationVersion: 0,
+          resolvedRefs: new Set<string>(),
+          resolvedRefsVersion: 0,
         }
       },
 
@@ -949,6 +974,8 @@ export function createMarkdownDecorationsPlugin(
         let markerMode = prevState.markerMode
         let decorationVersion = prevState.decorationVersion
         let contentCaches = prevState.contentCaches
+        let resolvedRefs = prevState.resolvedRefs
+        let resolvedRefsVersion = prevState.resolvedRefsVersion
         let docChanged = false
 
         const focusMeta = tr.getMeta("decorations:focus")
@@ -963,6 +990,17 @@ export function createMarkdownDecorationsPlugin(
           decorationVersion++
         }
 
+        const refsMeta = tr.getMeta("decorations:resolvedRefs")
+        if (refsMeta !== undefined) {
+          const payload = refsMeta as {
+            refs: Set<string>
+            version: number
+          }
+          resolvedRefs = payload.refs
+          resolvedRefsVersion = payload.version
+          decorationVersion++
+        }
+
         if (tr.docChanged) {
           contentCaches = invalidateChangedCaches(
             newState.doc,
@@ -977,7 +1015,8 @@ export function createMarkdownDecorationsPlugin(
         if (
           docChanged ||
           hasFocus !== prevState.hasFocus ||
-          markerMode !== prevState.markerMode
+          markerMode !== prevState.markerMode ||
+          decorationVersion !== prevState.decorationVersion
         ) {
           return {
             engine: prevState.engine,
@@ -985,6 +1024,8 @@ export function createMarkdownDecorationsPlugin(
             hasFocus,
             markerMode,
             decorationVersion,
+            resolvedRefs,
+            resolvedRefsVersion,
           }
         }
 
@@ -999,9 +1040,7 @@ export function createMarkdownDecorationsPlugin(
 
         if (
           cachedSet &&
-          cachedVersion === pluginState.decorationVersion &&
-          cachedFocus === pluginState.hasFocus &&
-          cachedMode === pluginState.markerMode
+          cachedVersion === pluginState.decorationVersion
         ) {
           return cachedSet
         }
@@ -1011,10 +1050,9 @@ export function createMarkdownDecorationsPlugin(
           pluginState.contentCaches,
           pluginState.hasFocus,
           pluginState.markerMode,
+          pluginState.resolvedRefs,
         )
         cachedVersion = pluginState.decorationVersion
-        cachedFocus = pluginState.hasFocus
-        cachedMode = pluginState.markerMode
 
         return cachedSet
       },

+ 2 - 2
packages/editor/src/lib/operation-history.ts

@@ -3,8 +3,8 @@
  *
  * Defines the discriminated union of all possible block-level operations
  * and a `OperationHistory` class that stores operations and computes their
- * inverses. This is the contract for undo/redo — not yet wired into
- * `Editor.vue`.
+ * inverses. This is the contract for undo/redo, wired into `Editor.vue` via
+ * `OperationShell`.
  */
 
 // ── Time source (injectable for testing) ─────────────────────────────