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 +` + + 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 @@ + + + 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
+ + +
+ +
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 {