Parcourir la source

feat(editor): add EditorToolbar with Nuxt UI, FormattingHandlers, and focus-preserving refocus

- EditorToolbar.vue with block/inline/insert/reference groups using UButton, UTooltip, UDropdownMenu, USeparator
- FormattingHandlers exported interface (19 methods) from formatting.ts
- handlerWithRefocus helper on Block.vue formatting handlers to re-focus editor after toolbar actions
- @nuxt/ui/vite plugin added to editor package's vite.config.ts for Nuxt UI component resolution
- Dev app: /toolbar route with live demo, integration table, and no-op onBlur (preserves handlers during click)
- Remove stale duplicate insertBold/insertItalic/insertCode/insertStrikethrough from interface
- Keyboard shortcut hints in tooltips, flat heading dropdown items, distinct block math icon
Zander Hawke il y a 1 semaine
Parent
commit
1ed764ac38

+ 1 - 0
apps/dev/src/App.vue

@@ -14,6 +14,7 @@ const navigationItems = [
   { label: "Code Blocks", icon: "i-lucide-code", to: "/code-blocks" },
   { label: "Properties & Dates", icon: "i-lucide-list", to: "/properties" },
   { label: "Math", icon: "i-lucide-sigma", to: "/math" },
+  { label: "Toolbar", icon: "i-lucide-rows-3", to: "/toolbar" },
 ]
 </script>
 

+ 2 - 0
apps/dev/src/main.ts

@@ -12,6 +12,7 @@ import MathPage from "~/pages/math.vue"
 import Properties from "~/pages/properties.vue"
 import RefsTags from "~/pages/refs-tags.vue"
 import Tasks from "~/pages/tasks.vue"
+import ToolbarPage from "~/pages/toolbar.vue"
 import "~/style.css"
 
 const routes = [
@@ -25,6 +26,7 @@ const routes = [
   { path: "/code-blocks", component: CodeBlocks },
   { path: "/properties", component: Properties },
   { path: "/math", component: MathPage },
+  { path: "/toolbar", component: ToolbarPage },
 ]
 
 const app = createApp(App)

+ 124 - 0
apps/dev/src/pages/toolbar.vue

@@ -0,0 +1,124 @@
+<script setup lang="ts">
+import { Block, EditorToolbar } from "@enesis/editor"
+import type { FormattingHandlers } from "@enesis/editor"
+import { ref } from "vue"
+
+const content = ref(`# Toolbar Demo
+
+This page shows the **EditorToolbar** integrated with a **Block** component.
+
+Try selecting text and clicking toolbar buttons — active state **updates** as the cursor moves.
+
+| Feature | Shortcut |
+|---|---|
+| Bold | **⌘B** |
+| Italic | *⌘I* |
+| Code | \`⌘\`\` |
+| Strikethrough | ~~⌘⇧S~~ |
+| Highlight | ^^⌘⇧H^^ |
+| Link | ⌘K |
+| Inline Math | ⌘M |
+
+You can also change the heading level or toggle task state from the toolbar.`)
+
+const handlers = ref<FormattingHandlers | null>(null)
+const selectionVersion = ref(0)
+
+function onFocus(payload: { handlers: FormattingHandlers }) {
+  handlers.value = payload.handlers
+}
+
+// Don't clear handlers on blur — the toolbar buttons need to
+// remain interactive when the editor loses focus (mousedown on a button
+// fires blur on the editor before the click event). Handlers are replaced
+// when a different Block emits @focus.
+function onBlur() {
+  // no-op
+}
+
+function onSelectionChange() {
+  selectionVersion.value++
+}
+</script>
+
+<template>
+  <UPage>
+    <UPageHeader
+      title="Toolbar"
+      description="Formatting toolbar with active state tracking."
+    />
+
+    <UPageBody>
+      <div class="space-y-8">
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Editor with Toolbar</h2>
+          <p class="text-sm text-muted leading-relaxed">
+            The toolbar receives <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code> from the Block's <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@focus</code> event.
+            Active state re-evaluates on <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@selection-change</code> via a <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">selectionVersion</code> counter.
+          </p>
+
+          <div class="flex flex-col gap-3 rounded-lg border border-default bg-elevated p-4">
+            <EditorToolbar
+              :handlers="handlers"
+              :selection-version="selectionVersion"
+            />
+            <Block
+              v-model:content="content"
+              :focused="true"
+              cursor-position="end"
+              marker-mode="always-visible"
+              @focus="onFocus"
+              @blur="onBlur"
+              @selection-change="onSelectionChange"
+            />
+          </div>
+        </section>
+
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Integration Pattern</h2>
+          <div class="rounded-lg border border-default overflow-hidden">
+            <table class="w-full text-sm">
+              <thead>
+                <tr class="border-b border-default bg-elevated">
+                  <th class="text-left px-4 py-2 font-medium text-muted text-xs uppercase tracking-wider">Step</th>
+                  <th class="text-left px-4 py-2 font-medium text-muted text-xs uppercase tracking-wider">Description</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr class="border-b border-default">
+                  <td class="px-4 py-2 font-mono text-xs">1</td>
+                  <td class="px-4 py-2 text-xs text-muted">Block emits <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@focus</code> with <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">{ view, handlers }</code></td>
+                </tr>
+                <tr class="border-b border-default">
+                  <td class="px-4 py-2 font-mono text-xs">2</td>
+                  <td class="px-4 py-2 text-xs text-muted">Parent passes <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code> to <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">EditorToolbar</code> prop</td>
+                </tr>
+                <tr class="border-b border-default">
+                  <td class="px-4 py-2 font-mono text-xs">3</td>
+                  <td class="px-4 py-2 text-xs text-muted">Block emits <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@selection-change</code> on cursor move — parent increments <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">selectionVersion</code></td>
+                </tr>
+                <tr class="border-b border-default">
+                  <td class="px-4 py-2 font-mono text-xs">4</td>
+                  <td class="px-4 py-2 text-xs text-muted">Toolbar's <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">computed</code>s re-run, calling <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers.isActive(format)</code> for each button</td>
+                </tr>
+                <tr>
+                  <td class="px-4 py-2 font-mono text-xs">5</td>
+                  <td class="px-4 py-2 text-xs text-muted">Block emits <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@blur</code> — parent does <strong>not</strong> clear handlers (toolbar must stay interactive while button click is in flight)</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </section>
+
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Known Limitations</h2>
+          <ul class="text-sm text-muted leading-relaxed space-y-2 list-disc list-inside">
+            <li>Reference insertion (Page Ref, Block Ref, Tag) uses <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">window.prompt()</code> — will be replaced with the pattern plugin's suggestion menu in Phase 8.</li>
+            <li>Undo/redo buttons are not implemented yet.</li>
+            <li>Single-block toolbar only — a cross-block toolbar (BlockList) will share state via <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">activeHandlers</code> in a future phase.</li>
+          </ul>
+        </section>
+      </div>
+    </UPageBody>
+  </UPage>
+</template>

+ 46 - 38
packages/editor/src/components/Block.vue

@@ -29,6 +29,8 @@ import {
 } from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
 import {
+  getActiveHeading,
+  getActiveTask,
   insertBlockMath,
   insertBlockRef,
   insertInlineMath,
@@ -43,6 +45,7 @@ import {
   toggleItalic,
   toggleStrikethrough,
   toggleTask,
+  type FormattingHandlers,
 } from "@/lib/formatting"
 import { createLogger } from "@/lib/logger"
 
@@ -67,7 +70,7 @@ const emit = defineEmits<{
   "pattern-open": [payload: PatternOpenPayload]
   "pattern-update": [payload: PatternUpdatePayload]
   "pattern-close": [PatternClosePayload]
-  focus: [payload: { view: EditorView; handlers: typeof formattingHandlers }]
+  focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
   blur: []
   "selection-change": [payload: { from: number; to: number; empty: boolean }]
 }>()
@@ -84,59 +87,64 @@ const handleKeyDown = createBlockKeyboardHandler({
   "arrow-down-from-end": () => emit("arrow-down-from-end"),
 })
 
-const formattingHandlers = {
-  toggleBold: () => {
-    if (view) toggleBold(view)
-  },
-  toggleItalic: () => {
-    if (view) toggleItalic(view)
-  },
-  toggleCode: () => {
-    if (view) toggleCode(view)
-  },
-  toggleStrikethrough: () => {
-    if (view) toggleStrikethrough(view)
-  },
-  insertBold: () => {
-    if (view) toggleBold(view)
-  },
-  insertItalic: () => {
-    if (view) toggleItalic(view)
-  },
-  insertCode: () => {
-    if (view) toggleCode(view)
-  },
-  insertStrikethrough: () => {
-    if (view) toggleStrikethrough(view)
-  },
+function handlerWithRefocus(fn: (view: EditorView) => void) {
+  return () => {
+    if (!view) return
+    fn(view)
+    requestAnimationFrame(() => view?.focus())
+  }
+}
+
+const formattingHandlers: FormattingHandlers = {
+  toggleBold: handlerWithRefocus(toggleBold),
+  toggleItalic: handlerWithRefocus(toggleItalic),
+  toggleCode: handlerWithRefocus(toggleCode),
+  toggleStrikethrough: handlerWithRefocus(toggleStrikethrough),
+  toggleHighlight: handlerWithRefocus(toggleHighlight),
   insertLink: (url?: string) => {
-    if (view) insertLink(view, url)
-  },
-  insertInlineMath: () => {
-    if (view) insertInlineMath(view)
-  },
-  insertBlockMath: () => {
-    if (view) insertBlockMath(view)
+    if (!view) return
+    insertLink(view, url)
+    requestAnimationFrame(() => view?.focus())
   },
+  insertInlineMath: handlerWithRefocus(insertInlineMath),
+  insertBlockMath: handlerWithRefocus(insertBlockMath),
   insertPageRef: (pageName: string, alias?: string) => {
-    if (view) insertPageRef(view, pageName, alias)
+    if (!view) return
+    insertPageRef(view, pageName, alias)
+    requestAnimationFrame(() => view?.focus())
   },
   insertBlockRef: (blockId: string) => {
-    if (view) insertBlockRef(view, blockId)
+    if (!view) return
+    insertBlockRef(view, blockId)
+    requestAnimationFrame(() => view?.focus())
   },
   insertTag: (tag: string) => {
-    if (view) insertTag(view, tag)
+    if (!view) return
+    insertTag(view, tag)
+    requestAnimationFrame(() => view?.focus())
   },
   setHeading: (level: number | null) => {
-    if (view) setHeading(view, level)
+    if (!view) return
+    setHeading(view, level)
+    requestAnimationFrame(() => view?.focus())
   },
   toggleTask: (state?: string) => {
-    if (view) toggleTask(view, state as Parameters<typeof toggleTask>[1])
+    if (!view) return
+    toggleTask(view, state as Parameters<typeof toggleTask>[1])
+    requestAnimationFrame(() => view?.focus())
   },
   isActive: (format: string): boolean => {
     if (!view) return false
     return isFormatActive(view, format)
   },
+  getActiveHeading: (): number | null => {
+    if (!view) return null
+    return getActiveHeading(view)
+  },
+  getActiveTask: (): string | null => {
+    if (!view) return null
+    return getActiveTask(view)
+  },
 }
 
 const editorRef = useTemplateRef("editorRef")

+ 256 - 0
packages/editor/src/components/EditorToolbar.vue

@@ -0,0 +1,256 @@
+<script setup lang="ts">
+import { computed } from "vue"
+import type { FormattingHandlers } from "@/lib/formatting"
+
+const props = defineProps<{
+  handlers: FormattingHandlers | null
+  selectionVersion?: number
+}>()
+
+const active = computed(() => {
+  if (!props.handlers) return {}
+  void props.selectionVersion
+  return {
+    bold: props.handlers.isActive("bold"),
+    italic: props.handlers.isActive("italic"),
+    code: props.handlers.isActive("code"),
+    strikethrough: props.handlers.isActive("strikethrough"),
+    highlight: props.handlers.isActive("highlight"),
+    inlineMath: props.handlers.isActive("inlineMath"),
+  }
+})
+
+const currentHeading = computed(() => {
+  if (!props.handlers) return null
+  void props.selectionVersion
+  return props.handlers.getActiveHeading()
+})
+
+const currentTask = computed(() => {
+  if (!props.handlers) return null
+  void props.selectionVersion
+  return props.handlers.getActiveTask()
+})
+
+const headingLabel = computed(() => {
+  const level = currentHeading.value
+  return level === null ? "Paragraph" : `H${level}`
+})
+
+const headingIcon = computed(() => {
+  const level = currentHeading.value
+  if (level === null) return "i-lucide-text"
+  return `i-lucide-heading-${level}` as const
+})
+
+const headingItems = computed(() => {
+  const h = props.handlers
+  if (!h) return []
+  const current = currentHeading.value
+  return [
+    { label: "Paragraph", icon: "i-lucide-text", active: current === null, onSelect: () => h.setHeading(null) },
+    { type: "separator" as const },
+    { label: "Heading 1", icon: "i-lucide-heading-1", active: current === 1, onSelect: () => h.setHeading(1) },
+    { label: "Heading 2", icon: "i-lucide-heading-2", active: current === 2, onSelect: () => h.setHeading(2) },
+    { label: "Heading 3", icon: "i-lucide-heading-3", active: current === 3, onSelect: () => h.setHeading(3) },
+    { label: "Heading 4", icon: "i-lucide-heading-4", active: current === 4, onSelect: () => h.setHeading(4) },
+    { label: "Heading 5", icon: "i-lucide-heading-5", active: current === 5, onSelect: () => h.setHeading(5) },
+    { label: "Heading 6", icon: "i-lucide-heading-6", active: current === 6, onSelect: () => h.setHeading(6) },
+  ]
+})
+
+function promptPageRef() {
+  const name = window.prompt("Enter page name:")
+  if (name) props.handlers?.insertPageRef(name)
+}
+
+function promptBlockRef() {
+  const id = window.prompt("Enter block ID:")
+  if (id) props.handlers?.insertBlockRef(id)
+}
+
+function promptTag() {
+  const tag = window.prompt("Enter tag name:")
+  if (tag) props.handlers?.insertTag(tag)
+}
+</script>
+
+<template>
+  <div
+    role="toolbar"
+    aria-label="Formatting toolbar"
+    data-slot="base"
+    class="flex items-stretch gap-1.5"
+  >
+    <!-- Block -->
+    <div role="group" aria-label="Block" class="flex items-center gap-0.5">
+      <UDropdownMenu :items="headingItems" :disabled="!handlers">
+        <UButton
+          :icon="headingIcon"
+          :label="headingLabel"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!handlers"
+        />
+      </UDropdownMenu>
+      <UButton
+        icon="i-lucide-list-todo"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="subtle"
+        size="sm"
+        :active="currentTask !== null"
+        :disabled="!handlers"
+        @click="handlers?.toggleTask()"
+      />
+    </div>
+
+    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
+
+    <!-- Inline -->
+    <div role="group" aria-label="Inline" class="flex items-center gap-0.5">
+      <UTooltip text="Bold (⌘B)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-bold"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="subtle"
+          size="sm"
+          :active="active.bold"
+          :disabled="!handlers"
+          @click="handlers?.toggleBold()"
+        />
+      </UTooltip>
+      <UTooltip text="Italic (⌘I)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-italic"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="subtle"
+          size="sm"
+          :active="active.italic"
+          :disabled="!handlers"
+          @click="handlers?.toggleItalic()"
+        />
+      </UTooltip>
+      <UTooltip text="Code (⌘`)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-code"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="subtle"
+          size="sm"
+          :active="active.code"
+          :disabled="!handlers"
+          @click="handlers?.toggleCode()"
+        />
+      </UTooltip>
+      <UTooltip text="Strikethrough (⌘⇧S)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-strikethrough"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="subtle"
+          size="sm"
+          :active="active.strikethrough"
+          :disabled="!handlers"
+          @click="handlers?.toggleStrikethrough()"
+        />
+      </UTooltip>
+      <UTooltip text="Highlight (⌘⇧H)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-highlighter"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="subtle"
+          size="sm"
+          :active="active.highlight"
+          :disabled="!handlers"
+          @click="handlers?.toggleHighlight()"
+        />
+      </UTooltip>
+    </div>
+
+    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
+
+    <!-- Insert -->
+    <div role="group" aria-label="Insert" class="flex items-center gap-0.5">
+      <UTooltip text="Link (⌘K)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-link"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!handlers"
+          @click="handlers?.insertLink()"
+        />
+      </UTooltip>
+      <UTooltip text="Inline math (⌘M)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-sigma"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="subtle"
+          size="sm"
+          :active="active.inlineMath"
+          :disabled="!handlers"
+          @click="handlers?.insertInlineMath()"
+        />
+      </UTooltip>
+      <UTooltip text="Block math" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-square-radical"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!handlers"
+          @click="handlers?.insertBlockMath()"
+        />
+      </UTooltip>
+    </div>
+
+    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
+
+    <!-- References -->
+    <div role="group" aria-label="References" class="flex items-center gap-0.5">
+      <UTooltip text="Page reference" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-book-marked"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!handlers"
+          @click="promptPageRef"
+        />
+      </UTooltip>
+      <UTooltip text="Block reference" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-arrow-up-from-dot"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!handlers"
+          @click="promptBlockRef"
+        />
+      </UTooltip>
+      <UTooltip text="Tag" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-hash"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!handlers"
+          @click="promptTag"
+        />
+      </UTooltip>
+    </div>
+  </div>
+</template>

+ 3 - 1
packages/editor/src/index.ts

@@ -1,10 +1,12 @@
 import type { App } from "vue"
 import Block from "@/components/Block.vue"
+import EditorToolbar from "@/components/EditorToolbar.vue"
 
 import "@/assets/style.css"
 
 export type { MarkdownPattern } from "@/lib/markdown-extensions"
-export { Block }
+export type { FormattingHandlers } from "@/lib/formatting"
+export { Block, EditorToolbar }
 
 export default {
   install(app: App) {

+ 28 - 3
packages/editor/src/lib/formatting.ts

@@ -31,6 +31,27 @@ function getFormat(key: string): FormatConfig | undefined {
   return FORMAT_CONFIGS[key as keyof typeof FORMAT_CONFIGS]
 }
 
+// ── Public interface ──────────────────────────────────────────────
+
+export interface FormattingHandlers {
+  toggleBold: () => void
+  toggleItalic: () => void
+  toggleCode: () => void
+  toggleStrikethrough: () => void
+  toggleHighlight: () => void
+  insertLink: (url?: string) => void
+  insertInlineMath: () => void
+  insertBlockMath: () => void
+  insertPageRef: (pageName: string, alias?: string) => void
+  insertBlockRef: (blockId: string) => void
+  insertTag: (tag: string) => void
+  setHeading: (level: number | null) => void
+  toggleTask: (state?: string) => void
+  isActive: (format: string) => boolean
+  getActiveHeading: () => number | null
+  getActiveTask: () => string | null
+}
+
 // ── Block helpers ─────────────────────────────────────────────────
 
 /** Find the current textblock and return its start offset + text */
@@ -315,9 +336,9 @@ export function insertTag(view: EditorView, tag: string) {
   view.dispatch(tr)
 }
 
-// ── setHeading ────────────────────────────────────────────────────
+// ── Heading state ─────────────────────────────────────────────────
 
-function parseCurrentHeading(view: EditorView): number | null {
+export function getActiveHeading(view: EditorView): number | null {
   const block = currentBlock(view)
   if (!block) return null
   const parsed = parseMarkdown(block.text, engine)
@@ -331,7 +352,7 @@ export function setHeading(view: EditorView, level: number | null) {
   const block = currentBlock(view)
   if (!block) return
 
-  const currentLevel = parseCurrentHeading(view)
+  const currentLevel = getActiveHeading(view)
 
   if (level === null || level === currentLevel) {
     const match = HEADING_RE.exec(block.text)
@@ -383,6 +404,10 @@ function parseCurrentTask(
   return null
 }
 
+export function getActiveTask(view: EditorView): string | null {
+  return parseCurrentTask(view)?.state ?? null
+}
+
 /**
  * Find the start of the task content (after the marker keyword and its
  * trailing separator).  The decoration ranges from createTaskRule give us

+ 2 - 1
packages/editor/vite.config.ts

@@ -1,10 +1,11 @@
 import { resolve } from "node:path"
+import ui from "@nuxt/ui/vite"
 import tailwindcss from "@tailwindcss/vite"
 import vue from "@vitejs/plugin-vue"
 import { defineConfig } from "vite"
 
 export default defineConfig({
-  plugins: [vue(), tailwindcss()],
+  plugins: [vue(), tailwindcss(), ui()],
   resolve: {
     alias: [
       { find: /^@\//, replacement: `${resolve(__dirname, "src")}/` },