浏览代码

feat(editor): add image rendering, file drop/paste, and resolveAsset prop

- Add createImageRule() — parses ![](url) via Lezer Image nodes,
  renders <img> widget when blurred, raw source when focused
- Add ImageDecoration type, exported through markdown-parser
- Add safeImageSrc() URL sanitizer with 9 tests
- Add handleDrop and handlePaste (clipboard images) in EditorBlock
- Add resolveAsset prop and asset-dropped emit on Editor + EditorBlock
- Non-image files drop as [filename](url), images as ![](url)
- Fix unresolved refs — dispatch resolvedRefs on mount (not just on change)
- Add image styles (md-image-hidden, md-image-wrapper, md-image-rendered)
- Update dev app pages to demo images, resolvedRefs, and reference events
Zander Hawke 2 天之前
父节点
当前提交
c1260ed94d

+ 31 - 2
apps/dev/src/pages/editor-block.vue

@@ -14,6 +14,8 @@ Paragraph with **bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, $
 TODO Task states are supported
 DONE Completed tasks
 
+![Diagram](https://picsum.photos/seed/editor/400/200)
+
 | Col A | Col B |
 |---|---|
 | Data | More data |
@@ -23,10 +25,26 @@ def hello():
     print("Hello!")
 \`\`\`
 
-[[Page Reference]] and #tag
+[[Resolved Page]] and [[Unresolved Reference]] and #tag
+
+((unknown-block-id))
 
 author:: Editor Block Demo
 `)
+
+const resolvedRefs = new Set(["Resolved Page", "known-block-id"])
+
+function onPageRefClick({ pageName, alias }: { pageName: string; alias?: string }) {
+  console.log("page-ref-clicked", pageName, alias)
+}
+
+function onBlockRefClick({ blockId }: { blockId: string }) {
+  console.log("block-ref-clicked", blockId)
+}
+
+function onTagClick({ tag }: { tag: string }) {
+  console.log("tag-clicked", tag)
+}
 </script>
 
 <template>
@@ -49,6 +67,11 @@ author:: Editor Block Demo
               v-model:content="content"
               cursor-position="start"
               marker-mode="always-visible"
+              :resolved-refs="resolvedRefs"
+              :resolved-refs-version="resolvedRefs.size"
+              @page-ref-clicked="onPageRefClick"
+              @block-ref-clicked="onBlockRefClick"
+              @tag-clicked="onTagClick"
             />
           </div>
         </section>
@@ -72,6 +95,8 @@ author:: Editor Block Demo
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">markerMode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview' \| 'always-visible'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Decorator visibility mode</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">debug</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Debug namespace filter</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">extraPatterns</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternSpec[]</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Custom suggestion triggers</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefs</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">Set&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Known refs — missing entries render with dashed underline</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefsVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Bump to re-check refs across all blocks</td></tr>
               </tbody>
             </table>
           </div>
@@ -101,7 +126,11 @@ author:: Editor Block Demo
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">arrow-down-from-end</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number?</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor at last line</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-open</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternOpenPayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Trigger detected (/, [[, \#)</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-close</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternClosePayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Pattern session ended</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">error</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ code, message?, blockId? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Mount failure, KaTeX error, parser crash</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">page-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ pageName, alias? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a [[page]] chip</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">block-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ blockId }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a ((block)) chip</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">tag-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ tag }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a #tag chip</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">error</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ code, message?, blockId? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Mount failure, KaTeX error, parser crash</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">asset-dropped</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ file, pos }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">File dropped/pasted, no resolveAsset prop</td></tr>
               </tbody>
             </table>
           </div>

+ 35 - 3
apps/dev/src/pages/editor.vue

@@ -12,7 +12,7 @@ You can use **bold**, *italic*, \`code\`, ~~strikethrough~~, and ^^highlight^^.
 
 ## Links & References
 
-Visit [Nuxt UI](https://ui.nuxt.com) for documentation. Reference [[other pages]] and use #tags for categorization.
+Visit [Nuxt UI](https://ui.nuxt.com) for documentation. Reference [[Known Page]] and [[Missing Page]] for comparison. Use #tags for categorization.
 
 ## Tasks & Tables
 
@@ -33,10 +33,26 @@ function greet(name: string): string {
 
 > [!NOTE] Use callouts for important information
 
+![Diagram](https://picsum.photos/seed/kitchen/400/200)
+
 ## Properties
 
 author:: Editor Demo
 status:: published`)
+
+const resolvedRefs = new Set(["Known Page", "other-block-id"])
+
+function onPageRefClick({ pageName }: { pageName: string }) {
+  console.log("page-ref-clicked", pageName)
+}
+
+function onBlockRefClick({ blockId }: { blockId: string }) {
+  console.log("block-ref-clicked", blockId)
+}
+
+function onTagClick({ tag }: { tag: string }) {
+  console.log("tag-clicked", tag)
+}
 </script>
 
 <template>
@@ -56,7 +72,16 @@ status:: published`)
           </p>
 
           <div class="rounded-lg border border-default dark:bg-neutral-950/50 p-4 sm:p-8 rounded-t-md overflow-hidden">
-            <Editor v-model:content="content" :focused="true" marker-mode="always-visible">
+            <Editor
+              v-model:content="content"
+              :focused="true"
+              marker-mode="always-visible"
+              :resolved-refs="resolvedRefs"
+              :resolved-refs-version="resolvedRefs.size"
+              @page-ref-clicked="onPageRefClick"
+              @block-ref-clicked="onBlockRefClick"
+              @tag-clicked="onTagClick"
+            >
               <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
                 <EditorToolbar
                   :handlers="handlers"
@@ -90,6 +115,9 @@ status:: published`)
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">theme</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">ThemeInput</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">CSS-variable theme preset</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">keyBinding</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">KeyBinding</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">default</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Swappable keyboard handler</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">extraPatterns</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternSpec[]</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Custom suggestion triggers</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefs</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">Set&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Known refs — missing entries render dashed</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefsVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Bump to re-check refs across blocks</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolveAsset</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">(file) => Promise&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Upload handler — drops/pastes insert as ![]()</td></tr>
               </tbody>
             </table>
           </div>
@@ -131,7 +159,11 @@ status:: published`)
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focus</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ view, handlers }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Block gained focus</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">blur</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">All blocks lost focus</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">selection-change</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ from, to, empty }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Selection changed</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">error</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ code, message?, blockId? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Mount failure or KaTeX error</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">error</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ code, message?, blockId? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Mount failure or KaTeX error</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">page-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ pageName, alias? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked [[page]] chip</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">block-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ blockId }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked ((block)) chip</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">tag-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ tag }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked #tag chip</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">asset-dropped</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ file, pos }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">File dropped/pasted, no resolveAsset</td></tr>
               </tbody>
             </table>
           </div>

+ 2 - 0
apps/dev/src/pages/index.vue

@@ -9,6 +9,8 @@ This is a **block-based markdown editor** built with Vue 3, TypeScript, and Pros
 > [!NOTE] Every paragraph is its own Block component.
 > Blocks are independent editors with their own state.
 
+![Preview](https://picsum.photos/seed/hero/600/200)
+
 Try the examples under each component page.`)
 </script>
 

+ 12 - 5
apps/dev/src/pages/suggestion-menu.vue

@@ -49,7 +49,7 @@ Type a hash sign to search for tags.`)
             </div>
             <div class="p-4">
               <EditorBlock v-model:content="mentionContent" :focused="true" cursor-position="end" marker-mode="always-visible" />
-            </div>
+              </div>
           </div>
         </section>
 
@@ -65,8 +65,8 @@ Type a hash sign to search for tags.`)
                 </tr>
               </thead>
               <tbody>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">/</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">command</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Slash command palette (headings, formatting, blocks, insert)</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">[[</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">reference</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Page reference completion → [[Page Name]]</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">/</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">command</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Slash command palette (headings, formatting, blocks, insert)</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">[[</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">reference</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Page reference completion → [[Page Name]]</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">((</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">embed</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Block reference completion → ((block-id))</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">#</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">tag</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Tag completion → #tagname</td></tr>
               </tbody>
@@ -79,11 +79,18 @@ Type a hash sign to search for tags.`)
           <p class="text-sm text-muted leading-relaxed">
             Additional triggers can be registered via the <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">extraPatterns</code> prop on <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">EditorBlock</code> or <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Editor</code>:
           </p>
-          <pre class="rounded-lg border border-default bg-muted p-4 text-xs font-mono overflow-x-auto">import type { PatternSpec } from "@enesis/editor"
+          <pre class="rounded-lg border border-default bg-muted p-4 text-xs font-mono overflow-x-auto">&lt;EditorBlock
+  v-model:content="content"
+  :extra-patterns="extraPatterns"
+/&gt;
+
+&lt;script setup lang="ts"&gt;
+import type { PatternSpec } from "@enesis/editor"
 
 const extraPatterns: PatternSpec[] = [
   { start: "@", kind: "command", termination: "boundary", priority: 2 },
-]</pre>
+]
+&lt;/script&gt;</pre>
         </section>
       </div>
     </UPageBody>

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

@@ -489,6 +489,29 @@
   @apply text-sm;
 }
 
+/* ── Image ──────────────────────────────────────────────── */
+
+.md-image-hidden {
+  display: none !important;
+}
+
+.md-image-wrapper {
+  @apply block my-2;
+  cursor: pointer;
+}
+
+.md-image-rendered {
+  @apply max-w-full h-auto rounded;
+  max-height: 60vh;
+  display: block;
+}
+
+@media (forced-colors: active) {
+  .md-image-rendered {
+    border: 1px solid ButtonText;
+  }
+}
+
 /* ── Math Block ───────────────────────────────────────────── */
 
 .math-block {

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

@@ -53,6 +53,7 @@ const emit = defineEmits<{
   "page-ref-clicked": [payload: { pageName: string; alias?: string }]
   "block-ref-clicked": [payload: { blockId: string }]
   "tag-clicked": [payload: { tag: string }]
+  "asset-dropped": [payload: { file: File; pos: number }]
 }>()
 
 const props = withDefaults(
@@ -65,6 +66,7 @@ const props = withDefaults(
     extraPatterns?: import("@/composables/usePatternPlugin").PatternSpec[]
     resolvedRefs?: Set<string>
     resolvedRefsVersion?: number
+    resolveAsset?: (file: File) => Promise<string>
   }>(),
   {
     focused: false,
@@ -814,6 +816,7 @@ defineExpose({
           :extra-patterns="extraPatterns"
           :resolved-refs="resolvedRefs"
           :resolved-refs-version="resolvedRefsVersion"
+          :resolve-asset="resolveAsset"
           class="flex-1 min-w-0"
           @update:content="onBlockContentChange"
           @split="(before, after) => onSplit(i, before, after)"
@@ -834,6 +837,7 @@ defineExpose({
           @page-ref-clicked="emit('page-ref-clicked', $event)"
           @block-ref-clicked="emit('block-ref-clicked', $event)"
           @tag-clicked="emit('tag-clicked', $event)"
+          @asset-dropped="emit('asset-dropped', $event)"
         />
       </div>
     </template>

+ 58 - 3
packages/editor/src/components/EditorBlock.vue

@@ -127,6 +127,13 @@ const props = defineProps<{
    * mutations (.add, .delete) automatically produce a new version.
    */
   resolvedRefsVersion?: number
+  /**
+   * Resolves a dropped or pasted file to a URL. When provided, the editor
+   * inserts `![](url)` after resolution and skips the `asset-dropped`
+   * emit. When absent, `asset-dropped` is emitted so the parent handles
+   * the upload.
+   */
+  resolveAsset?: (file: File) => Promise<string>
 }>()
 
 const emit = defineEmits<{
@@ -156,6 +163,7 @@ const emit = defineEmits<{
   "page-ref-clicked": [payload: { pageName: string; alias?: string }]
   "block-ref-clicked": [payload: { blockId: string }]
   "tag-clicked": [payload: { tag: string }]
+  "asset-dropped": [payload: { file: File; pos: number }]
 }>()
 
 const log = createLogger("Block", props.debug)
@@ -412,6 +420,21 @@ function focusAt(cursorPosition?: FocusPosition) {
   view.focus()
 }
 
+function handleFile(file: File, pos: number) {
+  if (props.resolveAsset) {
+    props.resolveAsset(file).then((url) => {
+      const md = file.type.startsWith("image/")
+        ? `![](${url})`
+        : `[${file.name}](${url})`
+      if (!view) return
+      view.dispatch(view.state.tr.insertText(md, pos, pos))
+      view.focus()
+    })
+  } else {
+    emit("asset-dropped", { file, pos })
+  }
+}
+
 onMounted(() => {
   const initialDoc = contentToDoc(content.value || "")
 
@@ -628,9 +651,32 @@ onMounted(() => {
         }
         return false
       },
-      handlePaste: createPasteHandler((before, after) =>
-        emit("split", before, after),
-      ),
+      handlePaste(view, event) {
+        // Handle clipboard files (e.g. pasted screenshots) first.
+        const file = event.clipboardData?.files?.[0]
+        if (file) {
+          handleFile(
+            file,
+            view.state.selection.from ?? view.state.selection.anchor,
+          )
+          return true
+        }
+        // Fall through to text-paste handler for multi-line splits.
+        return createPasteHandler((before, after) =>
+          emit("split", before, after),
+        )(view, event)
+      },
+      handleDrop(view, event) {
+        const file = event.dataTransfer?.files?.[0]
+        if (!file) return false
+        const pos = view.posAtCoords({
+          left: event.clientX,
+          top: event.clientY,
+        })
+        if (!pos) return false
+        handleFile(file, pos.pos)
+        return true
+      },
       dispatchTransaction(transaction: Transaction) {
         const currentView = view
         if (!currentView) return
@@ -715,6 +761,15 @@ onMounted(() => {
       focusAt(props.cursorPosition)
     }
 
+    if (props.resolvedRefsVersion !== undefined && view) {
+      view.dispatch(
+        view.state.tr.setMeta("decorations:resolvedRefs", {
+          refs: props.resolvedRefs ?? new Set<string>(),
+          version: props.resolvedRefsVersion,
+        }),
+      )
+    }
+
     if (props.id && props.registry) {
       props.registry.register(props.id, {
         focus: focusAt,

+ 77 - 28
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -22,11 +22,12 @@ import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
 import {
   type DecorationRange,
-  type ParsedDecorations,
   MarkdownRuleEngine,
+  type ParsedDecorations,
   parseMarkdown,
 } from "@/lib/markdown-parser"
 import type { BlockDecoration } from "@/lib/markdown-rules"
+import { safeImageSrc } from "@/lib/safe-image-src"
 
 const log = createLogger("Decorations")
 
@@ -168,7 +169,8 @@ function buildCombinedText(paragraphs: ParagraphInfo[]): {
   let text = ""
   for (let i = 0; i < paragraphs.length; i++) {
     text += paragraphs[i]!.text
-    const sepLen = i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
+    const sepLen =
+      i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
     boundaries.push(text.length + sepLen)
     if (i < paragraphs.length - 1) {
       text += separatorBetween(paragraphs, i)
@@ -356,11 +358,7 @@ function applyBlockDecorations(
         : null
       const source = block.wrapperAttrs?.["data-table-source"] ?? ""
       for (const range of sorted) {
-        const specs = buildDecorationsForRange(
-          range,
-          boundaries,
-          paragraphs,
-        )
+        const specs = buildDecorationsForRange(range, boundaries, paragraphs)
         for (const spec of specs) {
           decorationSpecs.push(
             Decoration.inline(spec.from, spec.to, {
@@ -459,11 +457,7 @@ function applyBlockDecorations(
 
   // ── Decorations for each range ──
   for (const range of sorted) {
-    const specs = buildDecorationsForRange(
-      range,
-      boundaries,
-      paragraphs,
-    )
+    const specs = buildDecorationsForRange(range, boundaries, paragraphs)
     for (const spec of specs) {
       const isHiddenDecorator = range.type === "hidden"
 
@@ -519,11 +513,7 @@ function applyInlineDecorations(
         : null
       const source = token.wrapperAttrs?.["data-math"] ?? ""
       for (const range of sorted) {
-        const specs = buildDecorationsForRange(
-          range,
-          boundaries,
-          paragraphs,
-        )
+        const specs = buildDecorationsForRange(range, boundaries, paragraphs)
         for (const spec of specs) {
           decorationSpecs.push(
             Decoration.inline(spec.from, spec.to, {
@@ -570,12 +560,59 @@ function applyInlineDecorations(
     }
   }
 
+  // ── Image: show <img> when blurred; show source when focused ──
+  if (token.type === "image") {
+    if (!isFocused) {
+      const firstRange = sorted[0]
+      const tokenStart = firstRange
+        ? resolveOffset(firstRange.from, boundaries, paragraphs)
+        : null
+      const src = safeImageSrc(token.wrapperAttrs?.["data-src"] ?? "")
+      const alt = token.wrapperAttrs?.["data-alt"] ?? ""
+      for (const range of sorted) {
+        const specs = buildDecorationsForRange(range, boundaries, paragraphs)
+        for (const spec of specs) {
+          decorationSpecs.push(
+            Decoration.inline(spec.from, spec.to, {
+              class: "md-image-hidden",
+            }),
+          )
+        }
+      }
+      if (src && tokenStart) {
+        decorationSpecs.push(
+          Decoration.widget(
+            tokenStart.pmPos,
+            (view) => {
+              const wrapper = document.createElement("span")
+              wrapper.className = "md-image-wrapper"
+              const img = document.createElement("img")
+              img.src = src
+              img.alt = alt
+              img.className = "md-image-rendered"
+              img.loading = "lazy"
+              wrapper.appendChild(img)
+              wrapper.addEventListener("mousedown", (e) => {
+                e.preventDefault()
+                e.stopPropagation()
+                const $pos = view.state.doc.resolve(tokenStart.pmPos)
+                view.dispatch(
+                  view.state.tr.setSelection(TextSelection.near($pos)),
+                )
+                view.focus()
+              })
+              return wrapper
+            },
+            { side: -1 },
+          ),
+        )
+      }
+      return
+    }
+  }
+
   for (const range of sorted) {
-    const specs = buildDecorationsForRange(
-      range,
-      boundaries,
-      paragraphs,
-    )
+    const specs = buildDecorationsForRange(range, boundaries, paragraphs)
 
     for (const spec of specs) {
       const isHiddenDecorator = range.type === "hidden"
@@ -804,6 +841,19 @@ function getInlineTagInfo(
       return { open: '<span class="md-block-ref">', close: "</span>" }
     case "tag":
       return { open: '<span class="md-tag">', close: "</span>" }
+    case "image": {
+      const urlRange = token.decorationRanges[2]
+      if (urlRange?.type === "hidden") {
+        const url = text.slice(urlRange.from + 2, urlRange.to - 1)
+        const safe = safeImageSrc(url)
+        if (safe)
+          return {
+            open: `<img src="${escapeHtml(safe)}" class="md-image-rendered" loading="lazy">`,
+            close: "",
+          }
+      }
+      return null
+    }
     default:
       return null
   }
@@ -841,9 +891,11 @@ function renderCellContent(text: string): string {
         // If KaTeX fails in a table cell, fall back to escaped source.
         try {
           renderKatexSync(mathSource, temp, false)
-          opens[contentRange.from] = (opens[contentRange.from] ?? "") + temp.innerHTML
+          opens[contentRange.from] =
+            (opens[contentRange.from] ?? "") + temp.innerHTML
         } catch {
-          opens[contentRange.from] = (opens[contentRange.from] ?? "") + `<span class="md-math-error">${escapeHtml(mathSource)}</span>`
+          opens[contentRange.from] =
+            `${opens[contentRange.from] ?? ""}<span class="md-math-error">${escapeHtml(mathSource)}</span>`
         }
       }
       continue
@@ -1038,10 +1090,7 @@ export function createMarkdownDecorationsPlugin(
         const pluginState = markdownDecorationsKey.getState(state)
         if (!pluginState) return DecorationSet.empty
 
-        if (
-          cachedSet &&
-          cachedVersion === pluginState.decorationVersion
-        ) {
+        if (cachedSet && cachedVersion === pluginState.decorationVersion) {
           return cachedSet
         }
 

+ 47 - 6
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
 import {
   type BlockDecoration,
   type BlockRefDecoration,
+  type ImageDecoration,
   type InlineMathDecoration,
   type PageRefDecoration,
   parseMarkdown,
@@ -733,14 +734,54 @@ describe("InlineMath", () => {
   })
 })
 
+describe("Image", () => {
+  it("parses ![alt](url) as image decoration", () => {
+    const result = parseMarkdown("![alt](url)")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("image")
+    const token = result.content[0] as ImageDecoration
+    expect(token.wrapperAttrs?.["data-src"]).toBe("url")
+    expect(token.wrapperAttrs?.["data-alt"]).toBe("alt")
+    const ranges = token.decorationRanges
+    expect(ranges).toHaveLength(3)
+    expect(ranges[0]).toEqual({ type: "hidden", from: 0, to: 2 })
+    expect(ranges[1]).toEqual({ type: "content", from: 2, to: 5 })
+    expect(ranges[2]).toEqual({ type: "hidden", from: 5, to: 11 })
+  })
+
+  it("parses image with empty alt text", () => {
+    const result = parseMarkdown("![](https://example.com/pic.png)")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("image")
+    const token = result.content[0] as ImageDecoration
+    expect(token.wrapperAttrs?.["data-src"]).toBe("https://example.com/pic.png")
+    expect(token.wrapperAttrs?.["data-alt"]).toBe("")
+  })
+
+  it("parses image inline in a sentence", () => {
+    const result = parseMarkdown("see ![photo](img.jpg) below")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("image")
+    const token = result.content[0] as ImageDecoration
+    expect(token.wrapperAttrs?.["data-alt"]).toBe("photo")
+    expect(token.wrapperAttrs?.["data-src"]).toBe("img.jpg")
+  })
+
+  it("does not parse bare [text](url) as image", () => {
+    const result = parseMarkdown("[text](url)")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("link")
+  })
+})
+
 describe("horizontal-rule", () => {
   it("parses --- as horizontal rule", () => {
     const result = parseMarkdown("---")
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].type).toBe("horizontal-rule")
-    expect(
-      (result.blocks[0] as BlockDecoration).nodeWrapper?.className,
-    ).toBe("md-hr")
+    expect((result.blocks[0] as BlockDecoration).nodeWrapper?.className).toBe(
+      "md-hr",
+    )
   })
 
   it("parses *** as horizontal rule", () => {
@@ -818,9 +859,9 @@ describe("table", () => {
 
   it("has nodeWrapper with md-table class", () => {
     const result = parseMarkdown("| A | B |\n| --- | --- |\n| C | D |")
-    expect(
-      (result.blocks[0] as BlockDecoration).nodeWrapper?.className,
-    ).toBe("md-table")
+    expect((result.blocks[0] as BlockDecoration).nodeWrapper?.className).toBe(
+      "md-table",
+    )
   })
 
   it("parses table with alignment markers", () => {

+ 2 - 0
packages/editor/src/lib/markdown-parser.ts

@@ -14,6 +14,7 @@ import {
   type CalloutType,
   type DecorationRange,
   type HighlightDecoration,
+  type ImageDecoration,
   type InlineMathDecoration,
   MarkdownRuleEngine,
   type PageRefDecoration,
@@ -30,6 +31,7 @@ export type {
   CalloutType,
   DecorationRange,
   HighlightDecoration,
+  ImageDecoration,
   InlineMathDecoration,
   PageRefDecoration,
   ParsedDecorations,

+ 2 - 0
packages/editor/src/lib/markdown-rules/index.ts

@@ -18,6 +18,7 @@ export {
   createBlockRefRule,
   createDelimitedRule,
   createHighlightRule,
+  createImageRule,
   createInlineMathRule,
   createInlineRules,
   createLinkRule,
@@ -34,6 +35,7 @@ export type {
   CalloutType,
   DecorationRange,
   HighlightDecoration,
+  ImageDecoration,
   InlineMathDecoration,
   MarkdownRule,
   PageRefDecoration,

+ 37 - 0
packages/editor/src/lib/markdown-rules/inline-rules.ts

@@ -13,6 +13,7 @@ import type { SyntaxNode } from "@lezer/common"
 import type {
   BlockRefDecoration,
   HighlightDecoration,
+  ImageDecoration,
   InlineMathDecoration,
   MarkdownRule,
   PageRefDecoration,
@@ -272,6 +273,41 @@ export function createHighlightRule(): MarkdownRule<HighlightDecoration> {
 
 // ── Default rule set ─────────────────────────────────────────────────
 
+export function createImageRule(): MarkdownRule<ImageDecoration> {
+  return {
+    name: "image",
+    nodeTypes: ["Image"],
+    match(node) {
+      return node.type.name === "Image"
+    },
+    run(node, ctx) {
+      const linkMarks = findChildrenByTypeName(node, "LinkMark")
+      if (linkMarks.length < 2) return null
+      const urlNode = findChildByTypeName(node, "URL")
+      const opener = linkMarks[0]
+      const closer = linkMarks[1]
+      const lastMark = linkMarks[linkMarks.length - 1]
+      if (!opener || !closer || !lastMark) return null
+      const altStart = opener.to
+      const altEnd = closer.from
+      const alt = altStart < altEnd ? ctx.doc.slice(altStart, altEnd) : ""
+      const url = urlNode ? ctx.doc.slice(urlNode.from, urlNode.to) : ""
+      return {
+        type: "image",
+        wrapperAttrs: {
+          "data-src": url,
+          "data-alt": alt,
+        },
+        decorationRanges: [
+          { type: "hidden", from: opener.from, to: opener.to },
+          { type: "content", from: altStart, to: altEnd },
+          { type: "hidden", from: closer.from, to: lastMark.to },
+        ],
+      }
+    },
+  }
+}
+
 export function createInlineRules(): MarkdownRule<unknown>[] {
   return [
     createDelimitedRule({
@@ -304,5 +340,6 @@ export function createInlineRules(): MarkdownRule<unknown>[] {
     createTagRule(),
     createHighlightRule(),
     createInlineMathRule(),
+    createImageRule(),
   ]
 }

+ 5 - 0
packages/editor/src/lib/markdown-rules/types.ts

@@ -70,6 +70,10 @@ export interface InlineMathDecoration extends ParsedToken {
   type: "inline-math"
 }
 
+export interface ImageDecoration extends ParsedToken {
+  type: "image"
+}
+
 // ── Block decoration ─────────────────────────────────────────────────
 
 export type TaskState =
@@ -118,6 +122,7 @@ export interface ParsedDecorations {
     | TagDecoration
     | HighlightDecoration
     | InlineMathDecoration
+    | ImageDecoration
   )[]
   properties: PropertyInfo[]
   blocks: BlockDecoration[]