diff --git a/.changeset/button-block-fixes-and-polish.md b/.changeset/button-block-fixes-and-polish.md
new file mode 100644
index 0000000..ad94174
--- /dev/null
+++ b/.changeset/button-block-fixes-and-polish.md
@@ -0,0 +1,17 @@
+---
+'@xproeditor/core': minor
+'@xproeditor/vue': minor
+'@xproeditor/react': minor
+---
+
+Add a new `button` block type (label, link URL, open-in-new-tab, primary/outline/ghost style, alignment) with a settings popover in both adapters and read-only rendering in `DocRenderer`.
+
+Add a standalone, categorized emoji picker (search, 7 categories, recents) usable both from the callout icon button and via a Slack/Discord-style `:` inline trigger that searches and inserts an emoji directly into text.
+
+Fix several real bugs surfaced while building the Bento showcase theme:
+- Slash-menu arrow-key/Enter navigation was dead on arrival — the root keydown handler bailed out for any key typed inside the contenteditable block the menu lives in, which is the only place slash state ever exists.
+- Floating UI (slash menu, bubble toolbar, popovers, dropdowns) is portaled to `document.body`, which silently escaped any theme class scoped to an ancestor of the editor — these now resync `--xpe-*` variables onto the portaled node so scoped custom themes apply everywhere, not just inline.
+- The to-do checkbox's checkmark and the toggle chevron were sized via Tailwind utility classes that weren't reliably taking effect on raw SVGs — both now use explicit width/height so the checkmark is always visible.
+- Vue's button-block label lost all styling because it reused `ui/Button.vue`'s scoped `.xpe-btn` class names on a plain `
` — Vue scoped CSS only applies to elements a component itself renders, so the classes were dead. Replaced with locally-scoped styles.
+
+Slash menu now groups items into Basic/Lists/Media/Advanced sections, locks page scroll while open (Notion-style), and flips above the caret when there's no room below.
diff --git a/.changeset/context-menu-scrollbars-radius-color-fix.md b/.changeset/context-menu-scrollbars-radius-color-fix.md
new file mode 100644
index 0000000..9826aa3
--- /dev/null
+++ b/.changeset/context-menu-scrollbars-radius-color-fix.md
@@ -0,0 +1,13 @@
+---
+'@xproeditor/core': minor
+'@xproeditor/vue': minor
+'@xproeditor/react': minor
+---
+
+Add a right-click context menu on blocks (Notion-style): Duplicate, Delete, and — for callout blocks — a Color flyout with the existing background presets, all in a clean two-level menu with no extra clutter.
+
+Add slim, theme-token-driven custom scrollbars across every scrollable surface (popovers, dropdowns, the slash/emoji menus, tables, code blocks, the editor root, and the doc renderer) instead of the browser default — add the `.xpe-scroll` class to any other scrollable container you build to match.
+
+Soften corners across the whole popover/menu/button/input/callout/table/code family to scale off `--xpe-radius` instead of hardcoded pixel values, so a single token change now reshapes the entire editor consistently.
+
+Fix: the text/highlight color popover in the floating bubble toolbar could never actually be used — clicking a swatch immediately closed the panel. The panel's "stay open" effect compared the toolbar's position by object reference, and a same-range `selectionchange` event (fired by clicking inside the toolbar itself) always produces a fresh position object, so the panel closed itself before a color could register. Also fixed a malformed Tailwind class that left the "active color" checkmark badge invisible, and replaced a few remaining hardcoded indigo accents with theme tokens.
diff --git a/.changeset/media-blocks-and-theming.md b/.changeset/media-blocks-and-theming.md
new file mode 100644
index 0000000..8b3e1dd
--- /dev/null
+++ b/.changeset/media-blocks-and-theming.md
@@ -0,0 +1,7 @@
+---
+'@xproeditor/core': minor
+'@xproeditor/vue': minor
+'@xproeditor/react': minor
+---
+
+Add `audio` and `file` block types with upload / library / URL insertion, per-file metadata (name, size, MIME), download card UI, and read-only rendering in `DocRenderer`. Media import now works out of the box without an `upload` prop (object-URL fallback), supports pasting files from the clipboard, and dropping OS files directly onto the editor at a precise position. Image blocks gain a paste-URL tab. Core exports new media helpers: `blockTypeForFile`, `formatFileSize`, `mediaPropsFromFile`, `fileToObjectUrl`, `acceptForBlockType`.
diff --git a/.changeset/slash-menu-theming-rtl.md b/.changeset/slash-menu-theming-rtl.md
new file mode 100644
index 0000000..8012e6d
--- /dev/null
+++ b/.changeset/slash-menu-theming-rtl.md
@@ -0,0 +1,6 @@
+---
+'@xproeditor/vue': minor
+'@xproeditor/react': minor
+---
+
+Slash-command menu now measures itself and positions precisely at the caret — flipping above when near the viewport bottom, clamping to the viewport edges, staying aligned in RTL, and showing a "No results" state instead of abruptly closing. Theming: a richer minimal token set (`--xpe-surface`, `--xpe-surface-hover`, `--xpe-primary-muted`, `--xpe-primary-foreground`, `--xpe-ring`, `--xpe-danger`, `--xpe-radius`, `--xpe-shadow`) plus a built-in dark theme via the `xpe-dark` class or `data-xpe-theme="dark"`; the slash menu, media blocks, and all editor chrome (toolbars, popovers, dropdown menus, inputs, tabs, table controls, gutter, text blocks) are fully token-driven, so the built-in dark theme and custom themes restyle the entire editor. Remaining physical CSS (left/right) converted to logical properties for correct RTL layout.
diff --git a/.claude/skills/adapter-parity/SKILL.md b/.claude/skills/adapter-parity/SKILL.md
new file mode 100644
index 0000000..d9731ac
--- /dev/null
+++ b/.claude/skills/adapter-parity/SKILL.md
@@ -0,0 +1,56 @@
+---
+name: adapter-parity
+description: Use whenever you change editor *behavior* (keyboard shortcuts, markdown shortcuts, selection handling, table operations, drag/drop, paste handling) in either the Vue or React adapter, or when auditing whether the two adapters have drifted apart. Ports the change to the sibling adapter and verifies the two state machines still mirror each other function-for-function.
+---
+
+# Keeping the Vue and React adapters in sync
+
+xproeditor intentionally keeps two state machines — `BlockEditor.vue`'s
+`
+
+
+
+
+
+
+
+ {{ busy() ? 'Working...' : 'Add audio' }}
+
+
+
+
+ Upload
+
+
+ Library
+
+
+ Link
+
+
+
+
+
+
+
+ Choose audio file
+
+
+
+
+
+
+
+ Open media library
+
+
+
+
+
+
+
+
+ Add
+
+
+
{{ embedError }}
+
+
+
+
+
+
+
+
+ {{ block.props.name }}
+ {{ formatFileSize(block.props.size) }}
+
+
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+
diff --git a/packages/vue/src/components/EditorBlockContextMenu.vue b/packages/vue/src/components/EditorBlockContextMenu.vue
new file mode 100644
index 0000000..0784b9f
--- /dev/null
+++ b/packages/vue/src/components/EditorBlockContextMenu.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+ Color
+
+
+
+
+ Duplicate
+
+
+
+ Delete
+
+
+
+
+
+ Color
+
+
+
+
+
+
+
+
diff --git a/packages/vue/src/components/EditorBlockItem.vue b/packages/vue/src/components/EditorBlockItem.vue
index 87e66ab..8be39b0 100644
--- a/packages/vue/src/components/EditorBlockItem.vue
+++ b/packages/vue/src/components/EditorBlockItem.vue
@@ -11,7 +11,11 @@ import {
IconEmojiPicker,
IconValueDisplay,
} from '../ui'
+import EditorAudioBlock from './EditorAudioBlock.vue'
+import EditorBlockContextMenu from './EditorBlockContextMenu.vue'
+import EditorButtonBlock from './EditorButtonBlock.vue'
import EditorCodeBlock from './EditorCodeBlock.vue'
+import EditorFileBlock from './EditorFileBlock.vue'
import EditorImageBlock from './EditorImageBlock.vue'
import EditorSelectionHighlight from './EditorSelectionHighlight.vue'
import EditorTableBlock from './EditorTableBlock.vue'
@@ -33,6 +37,7 @@ const props = defineProps<{
/** Shell / language direction fallback when block dir is auto. */
editorDir?: 'ltr' | 'rtl'
readonly?: boolean
+ themeSource?: HTMLElement | null
/** When set, opens the callout icon picker (slash command / programmatic). */
iconPickerRequest?: { tab: 'emoji' | 'icon' } | null
}>()
@@ -67,9 +72,20 @@ const emit = defineEmits<{
const CALLOUT_COLORS = ['#f8fafc', '#fefce8', '#fff7ed', '#fef2f2', '#f0fdf4', '#eff6ff', '#faf5ff']
-const inner = ref
| InstanceType | InstanceType | null>(null)
+const inner = ref | InstanceType | InstanceType | InstanceType | null>(null)
const calloutIconPickerRef = ref | null>(null)
const showCalloutColors = ref(false)
+const contextMenuPos = ref<{ x: number; y: number } | null>(null)
+
+function onContextMenu(e: MouseEvent) {
+ if (props.readonly) {
+return
+}
+
+ e.preventDefault()
+ emit('select')
+ contextMenuPos.value = { x: e.clientX, y: e.clientY }
+}
const calloutIcon = computed({
get: () => props.block.props.icon ?? '💡',
@@ -144,6 +160,7 @@ defineExpose({
:dir="blockDir"
:style="{ paddingInlineStart: `${indent * 28}px` }"
@pointerdown="emit('pointerdown', $event)"
+ @contextmenu="onContextMenu"
>
@@ -179,10 +196,10 @@ defineExpose({
-
+
Duplicate
-
+
Delete
@@ -194,7 +211,7 @@ defineExpose({
-
+
Color
-
•
-
{{ number ?? 1 }}.
+
•
+
{{ number ?? 1 }}.
-
+
emit('input', s, c)"
@enter="o => emit('enter', o)"
@backspace-start="emit('backspaceStart')"
@@ -374,6 +392,28 @@ defineExpose({
@select="emit('select')"
/>
+ emit('patch', p)"
+ @select="emit('select')"
+ />
+
+ emit('patch', p)"
+ @select="emit('select')"
+ />
+
-
+
+
+
emit('input', s, c)"
+ @enter="o => emit('enter', o)"
+ @backspace-start="emit('backspaceStart')"
+ @delete-end="emit('deleteEnd')"
+ @arrow-up="emit('arrowUp')"
+ @arrow-down="emit('arrowDown')"
+ @tab="s => emit('tab', s)"
+ @format="m => emit('format', m)"
+ @pasted="p => emit('pasted', p)"
+ @focus="emit('focus')"
+ @selection-pointer-down="p => emit('selectionPointerDown', p)"
+ @patch="p => emit('patch', p)"
+ @select="emit('select')"
+ />
+
+
+
emit('patch', { color: c })"
+ @duplicate="emit('duplicate')"
+ @delete="emit('remove')"
+ @close="contextMenuPos = null"
+ />
diff --git a/packages/vue/src/components/EditorBubbleToolbar.vue b/packages/vue/src/components/EditorBubbleToolbar.vue
index b43d9bc..7d1de2f 100644
--- a/packages/vue/src/components/EditorBubbleToolbar.vue
+++ b/packages/vue/src/components/EditorBubbleToolbar.vue
@@ -19,7 +19,8 @@ import {
Lightbulb,
Underline,
} from 'lucide-vue-next';
-import { ref, watch } from 'vue';
+import { nextTick, ref, watch } from 'vue';
+import { syncThemeVars } from '@xproeditor/core';
import type { BlockType, MarkName } from '@xproeditor/core';
import { Button, Input } from '../ui';
import EditorToolbarColorPanel from './toolbar/EditorToolbarColorPanel.vue';
@@ -31,8 +32,25 @@ const props = defineProps<{
currentColor?: string | null;
currentHighlight?: string | null;
blockType: BlockType;
+ /** Element still inside the editor's themed DOM scope — used to resync
+ * `--xpe-*` variables onto this toolbar once it's teleported to ``. */
+ themeSource?: HTMLElement | null;
}>();
+const toolbarEl = ref(null);
+
+watch(
+ [() => props.position, () => props.themeSource],
+ async () => {
+ await nextTick();
+
+ if (props.themeSource && toolbarEl.value) {
+ syncThemeVars(props.themeSource, toolbarEl.value);
+ }
+ },
+ { immediate: true },
+);
+
const emit = defineEmits<{
mark: [mark: MarkName, value: boolean | string | null];
turnInto: [type: BlockType];
@@ -52,11 +70,23 @@ const TURN_INTO: Array<{ type: BlockType; label: string; icon: unknown }> = [
const panel = ref<'none' | 'link' | 'color' | 'turninto'>('none');
const linkInput = ref('');
+let lastPositionKey: string | null = null;
watch(
() => props.position,
- () => {
- panel.value = 'none';
+ (position) => {
+ // `position` is a fresh object on every parent update (e.g. a
+ // same-range selectionchange), so only close an open panel when the
+ // selection actually moved — Vue's default watch compares by
+ // reference, which would close the color/link panel the instant a
+ // swatch inside it is clicked.
+ const key = `${position.x},${position.y}`;
+
+ if (lastPositionKey !== null && lastPositionKey !== key) {
+ panel.value = 'none';
+ }
+
+ lastPositionKey = key;
},
);
@@ -83,6 +113,7 @@ function turnIntoLabel(): string {
{{ turnIntoLabel() }}
-
+
-
+
Remove
@@ -196,7 +227,7 @@ function turnIntoLabel(): string {
-
+
{{ t.label }}
@@ -244,18 +275,18 @@ function turnIntoLabel(): string {
border-radius: 8px;
border: none;
background: transparent;
- color: #4b5563;
+ color: var(--xpe-muted-foreground, #4b5563);
cursor: pointer;
transition:
background 0.1s,
color 0.1s;
}
.ebt-btn:hover {
- background: #f3f4f6;
- color: #111827;
+ background: var(--xpe-muted, #f3f4f6);
+ color: var(--xpe-foreground, #111827);
}
.ebt-active {
- background: #eef2ff;
- color: #4f46e5;
+ background: var(--xpe-primary-muted, #eef2ff);
+ color: var(--xpe-primary, #4f46e5);
}
diff --git a/packages/vue/src/components/EditorButtonBlock.vue b/packages/vue/src/components/EditorButtonBlock.vue
new file mode 100644
index 0000000..0c34a44
--- /dev/null
+++ b/packages/vue/src/components/EditorButtonBlock.vue
@@ -0,0 +1,186 @@
+
+
+
+
+
+ emit('input', s, c)"
+ @enter="o => emit('enter', o)"
+ @backspace-start="emit('backspaceStart')"
+ @delete-end="emit('deleteEnd')"
+ @arrow-up="emit('arrowUp')"
+ @arrow-down="emit('arrowDown')"
+ @tab="s => emit('tab', s)"
+ @format="m => emit('format', m)"
+ @pasted="p => emit('pasted', p)"
+ @focus="emit('focus')"
+ @selection-pointer-down="p => emit('selectionPointerDown', p)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Link URL
+
+
+
+
+
+
+ Open in new tab
+
+
+
+
Style
+
+
+ {{ capitalize(s) }}
+
+
+
+
+
+
Alignment
+
+
+ {{ capitalize(a) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/vue/src/components/EditorEmojiTriggerMenu.vue b/packages/vue/src/components/EditorEmojiTriggerMenu.vue
new file mode 100644
index 0000000..438e451
--- /dev/null
+++ b/packages/vue/src/components/EditorEmojiTriggerMenu.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+ emit('select', emoji)" />
+
+
+
diff --git a/packages/vue/src/components/EditorFileBlock.vue b/packages/vue/src/components/EditorFileBlock.vue
new file mode 100644
index 0000000..da311bf
--- /dev/null
+++ b/packages/vue/src/components/EditorFileBlock.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
{{ busy() ? 'Working...' : 'Attach a file' }}
+
+
+
+
+ Upload
+
+
+
+ Library
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ block.props.name || 'File' }}
+
+
+ {{ formatFileSize(block.props.size) }}
+
+
+
+
+
+
+
+
+
+ ↻
+
+
+ ✕
+
+
+
+
+
+
diff --git a/packages/vue/src/components/EditorFormatToolbar.vue b/packages/vue/src/components/EditorFormatToolbar.vue
index 77f22b1..76d195e 100644
--- a/packages/vue/src/components/EditorFormatToolbar.vue
+++ b/packages/vue/src/components/EditorFormatToolbar.vue
@@ -155,7 +155,7 @@ const tableActionsDisabled = () => disabled() || !isTable();
-
+
disabled() || !isTable();
class="flex w-full items-center gap-2.5 px-3 py-2 text-start text-[13px] transition-colors"
:class="
t.type === state?.blockType
- ? 'bg-indigo-50/60 text-indigo-600'
- : 'text-gray-700 hover:bg-gray-50'
+ ? 'bg-[var(--xpe-primary-muted)] text-[var(--xpe-primary)]'
+ : 'text-[var(--xpe-foreground)] hover:bg-[var(--xpe-surface-hover)]'
"
@click="emit('turnInto', t.type); turnIntoOpen = false"
>
-
+
{{ t.label }}
@@ -265,7 +265,7 @@ const tableActionsDisabled = () => disabled() || !isTable();
type="button"
variant="ghost"
size="sm"
- class="h-8 px-2 text-xs text-red-500 hover:bg-red-50 hover:text-red-600"
+ class="h-8 px-2 text-xs text-[var(--xpe-danger)] hover:bg-red-50 hover:text-red-600"
@click="emit('mark', 'link', null); linkOpen = false"
>
Remove
@@ -316,7 +316,7 @@ const tableActionsDisabled = () => disabled() || !isTable();
@pointerdown.stop
>
-
+
diff --git a/packages/vue/src/components/EditorImageBlock.vue b/packages/vue/src/components/EditorImageBlock.vue
index 0124e34..bbb32d0 100644
--- a/packages/vue/src/components/EditorImageBlock.vue
+++ b/packages/vue/src/components/EditorImageBlock.vue
@@ -1,7 +1,8 @@
-
-
-
- {{ busy() ? 'Working...' : 'Add an image' }}
-
-
+
+
+
+ {{ busy() ? 'Working...' : 'Add an image' }}
+
+
+
-
Upload
-
Library
+
+ Link
+
+
+
+
+
+
+
+ Choose image
+
+
+
+
+
+
+
+ Open media library
+
+
+
+
+
+
+
+
+ Add
+
+
+
{{ embedError }}
+
-
@@ -124,8 +206,8 @@ const busy = () => uploading.value || picking.value;
@@ -139,7 +221,7 @@ const busy = () => uploading.value || picking.value;
class="rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors"
:class="
(block.props.width ?? 100) === w
- ? 'bg-white text-gray-900'
+ ? 'bg-[var(--xpe-surface)] text-[var(--xpe-foreground)]'
: 'text-white/80 hover:bg-white/20'
"
@click.stop="emit('patch', { width: w })"
@@ -165,7 +247,7 @@ const busy = () => uploading.value || picking.value;
= {
+ basic: 'Basic blocks',
+ lists: 'Lists & tasks',
+ media: 'Media',
+ advanced: 'Advanced',
+}
+
const ITEMS: SlashItem[] = [
- { id: 'paragraph', type: 'paragraph', label: 'Text', description: 'Plain paragraph', keywords: ['text', 'paragraph', 'p'], icon: Type },
- { id: 'heading_1', type: 'heading_1', label: 'Heading 1', description: 'Large section heading', keywords: ['h1', 'heading', 'title'], icon: Heading1 },
- { id: 'heading_2', type: 'heading_2', label: 'Heading 2', description: 'Medium section heading', keywords: ['h2', 'heading', 'subtitle'], icon: Heading2 },
- { id: 'heading_3', type: 'heading_3', label: 'Heading 3', description: 'Small section heading', keywords: ['h3', 'heading'], icon: Heading3 },
- { id: 'bulleted_list_item', type: 'bulleted_list_item', label: 'Bulleted list', description: 'Simple bullet list', keywords: ['bullet', 'list', 'ul'], icon: List },
- { id: 'numbered_list_item', type: 'numbered_list_item', label: 'Numbered list', description: 'Ordered list', keywords: ['number', 'ordered', 'ol'], icon: ListOrdered },
- { id: 'to_do', type: 'to_do', label: 'To-do', description: 'Checkbox task', keywords: ['todo', 'check', 'task'], icon: CheckSquare },
- { id: 'toggle', type: 'toggle', label: 'Toggle', description: 'Collapsible content', keywords: ['toggle', 'collapse', 'accordion'], icon: ChevronRight },
- { id: 'quote', type: 'quote', label: 'Quote', description: 'Capture a quote', keywords: ['quote', 'blockquote'], icon: Quote },
- { id: 'callout', type: 'callout', label: 'Callout', description: 'Highlighted note', keywords: ['callout', 'note', 'info', 'warning'], icon: Lightbulb },
- { id: 'emoji', type: 'callout', label: 'Emoji', description: 'Callout with emoji icon', keywords: ['emoji', 'emoticon', 'smile'], icon: Smile, pickIcon: 'emoji' },
- { id: 'icon', type: 'callout', label: 'Icon', description: 'Callout with vector icon', keywords: ['icon', 'symbol', 'lucide'], icon: Blocks, pickIcon: 'icon' },
- { id: 'code', type: 'code', label: 'Code', description: 'Code block with syntax', keywords: ['code', 'snippet', 'pre'], icon: Code2 },
- { id: 'divider', type: 'divider', label: 'Divider', description: 'Horizontal line', keywords: ['divider', 'hr', 'separator', 'line'], icon: Minus },
- { id: 'image', type: 'image', label: 'Image', description: 'Upload an image', keywords: ['image', 'photo', 'picture', 'upload'], icon: ImageIcon },
- { id: 'video', type: 'video', label: 'Video', description: 'Upload or embed a video', keywords: ['video', 'youtube', 'vimeo', 'movie'], icon: Video },
- { id: 'table', type: 'table', label: 'Table', description: 'Simple table', keywords: ['table', 'grid'], icon: Table2 },
+ { id: 'paragraph', type: 'paragraph', label: 'Text', description: 'Plain paragraph', keywords: ['text', 'paragraph', 'p'], icon: Type, group: 'basic' },
+ { id: 'heading_1', type: 'heading_1', label: 'Heading 1', description: 'Large section heading', keywords: ['h1', 'heading', 'title'], icon: Heading1, group: 'basic' },
+ { id: 'heading_2', type: 'heading_2', label: 'Heading 2', description: 'Medium section heading', keywords: ['h2', 'heading', 'subtitle'], icon: Heading2, group: 'basic' },
+ { id: 'heading_3', type: 'heading_3', label: 'Heading 3', description: 'Small section heading', keywords: ['h3', 'heading'], icon: Heading3, group: 'basic' },
+ { id: 'bulleted_list_item', type: 'bulleted_list_item', label: 'Bulleted list', description: 'Simple bullet list', keywords: ['bullet', 'list', 'ul'], icon: List, group: 'lists' },
+ { id: 'numbered_list_item', type: 'numbered_list_item', label: 'Numbered list', description: 'Ordered list', keywords: ['number', 'ordered', 'ol'], icon: ListOrdered, group: 'lists' },
+ { id: 'to_do', type: 'to_do', label: 'To-do', description: 'Checkbox task', keywords: ['todo', 'check', 'task'], icon: CheckSquare, group: 'lists' },
+ { id: 'toggle', type: 'toggle', label: 'Toggle', description: 'Collapsible content', keywords: ['toggle', 'collapse', 'accordion'], icon: ChevronRight, group: 'lists' },
+ { id: 'image', type: 'image', label: 'Image', description: 'Upload an image', keywords: ['image', 'photo', 'picture', 'upload'], icon: ImageIcon, group: 'media' },
+ { id: 'video', type: 'video', label: 'Video', description: 'Upload or embed a video', keywords: ['video', 'youtube', 'vimeo', 'movie'], icon: Video, group: 'media' },
+ { id: 'audio', type: 'audio', label: 'Audio', description: 'Upload or link audio', keywords: ['audio', 'music', 'song', 'sound', 'mp3'], icon: Music, group: 'media' },
+ { id: 'file', type: 'file', label: 'File', description: 'Attach a downloadable file', keywords: ['file', 'attachment', 'pdf', 'document', 'download'], icon: Paperclip, group: 'media' },
+ { id: 'quote', type: 'quote', label: 'Quote', description: 'Capture a quote', keywords: ['quote', 'blockquote'], icon: Quote, group: 'advanced' },
+ { id: 'callout', type: 'callout', label: 'Callout', description: 'Highlighted note', keywords: ['callout', 'note', 'info', 'warning'], icon: Lightbulb, group: 'advanced' },
+ { id: 'emoji', type: 'callout', label: 'Emoji', description: 'Callout with emoji icon', keywords: ['emoji', 'emoticon', 'smile'], icon: Smile, group: 'advanced', pickIcon: 'emoji' },
+ { id: 'icon', type: 'callout', label: 'Icon', description: 'Callout with vector icon', keywords: ['icon', 'symbol', 'lucide'], icon: Blocks, group: 'advanced', pickIcon: 'icon' },
+ { id: 'code', type: 'code', label: 'Code', description: 'Code block with syntax', keywords: ['code', 'snippet', 'pre'], icon: Code2, group: 'advanced' },
+ { id: 'divider', type: 'divider', label: 'Divider', description: 'Horizontal line', keywords: ['divider', 'hr', 'separator', 'line'], icon: Minus, group: 'advanced' },
+ { id: 'table', type: 'table', label: 'Table', description: 'Simple table', keywords: ['table', 'grid'], icon: Table2, group: 'advanced' },
+ { id: 'button', type: 'button', label: 'Button', description: 'A clickable link styled as a button', keywords: ['button', 'link', 'cta', 'action'], icon: SquareMousePointer, group: 'advanced' },
]
const props = defineProps<{
query: string
- position: { x: number; y: number }
+ /** Caret anchor in viewport coordinates: menu opens below `bottom`, flips above `top` when needed. */
+ position: { x: number; y: number; top?: number }
+ dir?: 'ltr' | 'rtl'
+ /** Element still inside the editor's themed DOM scope — used to resync
+ * `--xpe-*` variables onto this menu once it's teleported to ``. */
+ themeSource?: HTMLElement | null
}>()
const emit = defineEmits<{
@@ -50,6 +69,8 @@ const emit = defineEmits<{
const activeIndex = ref(0)
const listEl = ref(null)
+const menuEl = ref(null)
+const placed = ref<{ left: number; top: number }>({ left: props.position.x, top: props.position.y })
const filtered = computed(() => {
const q = props.query.toLowerCase().trim()
@@ -63,15 +84,72 @@ return ITEMS
)
})
-watch(() => props.query, () => {
- activeIndex.value = 0
-})
-watch(filtered, (items) => {
- if (items.length === 0) {
-emit('close')
+/** Same items, annotated with a group header wherever the group changes. */
+const grouped = computed(() =>
+ filtered.value.map((item, idx) => ({
+ item,
+ idx,
+ headerLabel: idx === 0 || filtered.value[idx - 1].group !== item.group ? GROUP_LABELS[item.group] : null,
+ })),
+)
+
+/**
+ * Place the menu with its real measured size: below the caret when it fits,
+ * flipped above otherwise, clamped to the viewport. In RTL the menu grows
+ * toward the start (its end edge hugs the caret).
+ */
+function place() {
+ const el = menuEl.value
+
+ if (!el) {
+return
}
+
+ if (props.themeSource) {
+syncThemeVars(props.themeSource, el)
+}
+
+ const margin = 8
+ const gap = 6
+ const { width, height } = el.getBoundingClientRect()
+ const anchorTop = props.position.top ?? props.position.y
+ let left = props.dir === 'rtl' ? props.position.x - width : props.position.x
+ left = Math.max(margin, Math.min(left, window.innerWidth - width - margin))
+ let top = props.position.y + gap
+
+ if (top + height > window.innerHeight - margin) {
+ top = Math.max(margin, anchorTop - height - gap)
+ }
+
+ placed.value = { left, top }
+}
+
+let unlockScroll: (() => void) | null = null
+
+onMounted(() => {
+ nextTick(place)
+ window.addEventListener('resize', place)
+ // Body scroll is locked below, but a scrollable ancestor of the editor
+ // (e.g. an app's own scroll container) can still move — reposition if so.
+ window.addEventListener('scroll', place, true)
+ unlockScroll = lockPageScroll()
+})
+
+onBeforeUnmount(() => {
+ window.removeEventListener('resize', place)
+ window.removeEventListener('scroll', place, true)
+ unlockScroll?.()
+ unlockScroll = null
})
+watch(() => props.query, () => {
+ activeIndex.value = 0
+ nextTick(place)
+})
+watch(() => props.position, () => {
+ nextTick(place)
+}, { deep: true })
+
function scrollActiveIntoView() {
nextTick(() => {
listEl.value?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
@@ -103,32 +181,43 @@ defineExpose({ move, confirm })
-
Blocks
+
+
+ No results for “{{ query }}”
+
-
-
+
+ {{ entry.headerLabel }}
+
+
-
-
-
- {{ item.label }}
- {{ item.description }}
-
-
+
+
+
+
+ {{ entry.item.label }}
+ {{ entry.item.description }}
+
+
+
diff --git a/packages/vue/src/components/EditorTableBlock.vue b/packages/vue/src/components/EditorTableBlock.vue
index 6ba2cff..32af3ac 100644
--- a/packages/vue/src/components/EditorTableBlock.vue
+++ b/packages/vue/src/components/EditorTableBlock.vue
@@ -175,7 +175,7 @@ defineExpose({
Unmerge
-
+
@@ -214,7 +214,7 @@ defineExpose({
-
+
@@ -272,7 +272,7 @@ defineExpose({
@@ -289,15 +289,15 @@ defineExpose({
align-items: center;
gap: 4px;
font-size: 11px;
- color: #9ca3af;
- background: #f9fafb;
- border: 1px solid #f3f4f6;
+ color: var(--xpe-muted-foreground, #9ca3af);
+ background: var(--xpe-surface-hover, #f9fafb);
+ border: 1px solid var(--xpe-muted, #f3f4f6);
border-radius: 6px;
padding: 2px 8px;
cursor: pointer;
}
.etable-btn:hover:not(:disabled) {
- color: #4f46e5;
+ color: var(--xpe-primary, #4f46e5);
border-color: #e0e7ff;
}
.etable-btn:disabled {
@@ -305,8 +305,8 @@ defineExpose({
cursor: not-allowed;
}
.etable-btn-active {
- color: #4f46e5;
+ color: var(--xpe-primary, #4f46e5);
border-color: #c7d2fe;
- background: #eef2ff;
+ background: var(--xpe-primary-muted, #eef2ff);
}
diff --git a/packages/vue/src/components/EditorTableCell.vue b/packages/vue/src/components/EditorTableCell.vue
index 2b7fa52..8d5c68d 100644
--- a/packages/vue/src/components/EditorTableCell.vue
+++ b/packages/vue/src/components/EditorTableCell.vue
@@ -234,8 +234,8 @@ defineExpose({ focusAt, getSelection, setSelection, el })
:is="isHeader ? 'th' : 'td'"
class="etc-cell border border-gray-150 p-0 relative min-w-[100px] align-top"
:class="[
- isHeader ? 'bg-gray-50/80' : '',
- selected ? 'ring-2 ring-indigo-400 ring-inset' : '',
+ isHeader ? 'bg-[var(--xpe-muted)]/80' : '',
+ selected ? 'ring-2 ring-[var(--xpe-ring)] ring-inset' : '',
]"
:style="cellStyle"
:colspan="cell.colspan && cell.colspan > 1 ? cell.colspan : undefined"
@@ -245,8 +245,8 @@ defineExpose({ focusAt, getSelection, setSelection, el })
(null);
const fileInput = ref
(null);
async function uploadFile(file: File) {
- if (!props.upload || !file.type.startsWith('video/')) {
+ if (!file.type.startsWith('video/')) {
return;
}
uploading.value = true;
try {
- const url = await props.upload(file);
- emit('patch', { url, provider: 'file' });
+ const url = await (props.upload ?? fileToObjectUrl)(file);
+ emit('patch', { ...mediaPropsFromFile(file, url), provider: 'file' });
} finally {
uploading.value = false;
}
@@ -122,15 +122,15 @@ watch(mode, (next) => {
-
-
+
+
{{ busy() ? 'Working...' : 'Add a video' }}
@@ -139,7 +139,7 @@ watch(mode, (next) => {
Upload
@@ -148,7 +148,7 @@ watch(mode, (next) => {
v-if="pickMedia"
type="button"
class="rounded-lg px-2.5 py-1 text-xs font-medium"
- :class="mode === 'library' ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500 hover:bg-gray-100'"
+ :class="mode === 'library' ? 'bg-[var(--xpe-primary-muted)] text-[var(--xpe-primary)]' : 'text-[var(--xpe-muted-foreground)] hover:bg-[var(--xpe-surface-hover)]'"
@click.stop="mode = 'library'"
>
Library
@@ -156,7 +156,7 @@ watch(mode, (next) => {
Embed
@@ -167,7 +167,7 @@ watch(mode, (next) => {
@@ -179,7 +179,7 @@ watch(mode, (next) => {
@@ -189,24 +189,24 @@ watch(mode, (next) => {
-
+
Add
-
{{ embedError }}
+
{{ embedError }}
@@ -214,8 +214,8 @@ watch(mode, (next) => {