<!--
  The formatting rail — Vue counterpart of React's <Toolbar>
  (packages/react/src/components/Toolbar.tsx). Button order, dropdowns,
  and groupings match React's render order so the parity preview shows
  the same rail in the same sequence. Rendered by DocxEditor and
  exposed publicly as `Toolbar`.
-->
<template>
  <div class="basic-toolbar" v-if="view">
    <!-- 1. Undo / Redo -->
    <button
      :title="t('formattingBar.undoShortcut')"
      :aria-label="t('formattingBar.undo')"
      :disabled="!canUndo"
      @mousedown.prevent="execCommand('undo')"
    >
      <MaterialSymbol name="undo" />
    </button>
    <button
      :title="t('formattingBar.redoShortcut')"
      :aria-label="t('formattingBar.redo')"
      :disabled="!canRedo"
      @mousedown.prevent="execCommand('redo')"
    >
      <MaterialSymbol name="redo" />
    </button>

    <span class="divider" />

    <!-- 2. Zoom (compact: -, 100%, +) -->
    <div v-if="showZoomControl" class="toolbar-dropdown zoom-group" ref="zoomDropdownRef">
      <button
        class="size-btn"
        @mousedown.prevent="$emit('zoom-out')"
        :disabled="isMinZoom"
        :title="t('zoom.zoomOut')"
      >
        −
      </button>
      <button
        class="toolbar-dropdown__trigger zoom-trigger"
        @mousedown.prevent="toggleDropdown('zoom')"
        :aria-expanded="openDropdown === 'zoom'"
        aria-haspopup="listbox"
        :title="t('zoom.zoomLevel')"
      >
        {{ zoomPercent }}%
        <MaterialSymbol class="chevron" name="arrow_drop_down" :size="16" />
      </button>
      <button
        class="size-btn"
        @mousedown.prevent="$emit('zoom-in')"
        :disabled="isMaxZoom"
        :title="t('zoom.zoomIn')"
      >
        +
      </button>
      <div
        v-if="openDropdown === 'zoom'"
        :style="dropdownMenuStyle"
        class="toolbar-dropdown__menu zoom-menu"
      >
        <button
          v-for="p in zoomPresets"
          :key="p"
          class="toolbar-dropdown__item"
          :class="{ active: zoomPercent === Math.round(p * 100) }"
          @mousedown.prevent="$emit('zoom-set', p)"
        >
          {{ Math.round(p * 100) }}%
        </button>
      </div>
    </div>

    <span class="divider" />

    <!-- 3. Style Picker -->
    <div class="toolbar-dropdown" ref="styleDropdownRef">
      <button
        class="toolbar-dropdown__trigger style-trigger"
        @mousedown.prevent="toggleDropdown('style')"
        :aria-expanded="openDropdown === 'style'"
        aria-haspopup="listbox"
        :title="t('styles.selectAriaLabel')"
      >
        {{ currentStyleLabel }}
        <MaterialSymbol class="chevron" name="arrow_drop_down" :size="16" />
      </button>
      <div
        v-if="openDropdown === 'style'"
        :style="dropdownMenuStyle"
        class="toolbar-dropdown__menu style-menu"
      >
        <button
          v-for="s in resolvedParagraphStyles"
          :key="s.id"
          class="toolbar-dropdown__item"
          :class="{ active: (ctx.paragraphFormatting.styleId || 'Normal') === s.id }"
          @mousedown.prevent="handleApplyStyle(s.id)"
        >
          <span :style="s.previewStyle">{{ s.label }}</span>
        </button>
      </div>
    </div>

    <span class="divider" />

    <!-- 4. Font Family Picker -->
    <div class="toolbar-dropdown" ref="fontDropdownRef">
      <button
        class="toolbar-dropdown__trigger font-trigger"
        @mousedown.prevent="toggleDropdown('font')"
        :aria-expanded="openDropdown === 'font'"
        aria-haspopup="listbox"
        :title="t('font.selectAriaLabel')"
      >
        {{ currentFontFamily }}
        <MaterialSymbol class="chevron" name="arrow_drop_down" :size="16" />
      </button>
      <div
        v-if="openDropdown === 'font'"
        :style="dropdownMenuStyle"
        class="toolbar-dropdown__menu font-menu"
      >
        <template v-for="group in fontGroups" :key="group.label">
          <div v-if="group.fonts.length > 0" class="toolbar-dropdown__group-label">
            {{ group.label }}
          </div>
          <button
            v-for="f in group.fonts"
            :key="f.name"
            class="toolbar-dropdown__item"
            :class="{ active: currentFontFamily === f.name }"
            :style="{ fontFamily: f.fontFamily }"
            @mousedown.prevent="setFont(f.name)"
          >
            {{ f.name }}
          </button>
        </template>
      </div>
    </div>

    <!-- 5. Font Size: −, value, + -->
    <div class="toolbar-dropdown font-size-group" ref="sizeDropdownRef">
      <button
        class="size-btn"
        @mousedown.prevent="decreaseFontSize"
        :title="t('fontSize.decrease')"
      >
        −
      </button>
      <input
        class="size-trigger size-input"
        type="text"
        :value="currentFontSize"
        :title="t('fontSize.label')"
        @mousedown.stop
        @focus="onSizeFocus"
        :aria-expanded="openDropdown === 'size'"
        aria-haspopup="listbox"
        @input="onSizeInput"
        @keydown.up.prevent.stop="increaseFontSize()"
        @keydown.down.prevent.stop="decreaseFontSize()"
        @keydown.enter.prevent="commitFontSize($event)"
        @blur="commitFontSize($event)"
      />
      <button
        class="size-btn"
        @mousedown.prevent="increaseFontSize"
        :title="t('fontSize.increase')"
      >
        +
      </button>
      <div
        v-if="openDropdown === 'size'"
        :style="dropdownMenuStyle"
        class="toolbar-dropdown__menu size-menu"
      >
        <button
          v-for="s in fontSizePresets"
          :key="s"
          class="toolbar-dropdown__item"
          :class="{ active: currentFontSize === s }"
          @mousedown.prevent="pickFontSize(s)"
        >
          {{ s }}
        </button>
      </div>
    </div>

    <span class="divider" />

    <!-- 6. Bold / Italic / Underline / Strike -->
    <button
      :title="t('formattingBar.boldShortcut')"
      :aria-label="t('formattingBar.bold')"
      :class="{ active: ctx.textFormatting.bold }"
      @mousedown.prevent="execCommand('toggleBold')"
    >
      <MaterialSymbol name="format_bold" />
    </button>
    <button
      :title="t('formattingBar.italicShortcut')"
      :aria-label="t('formattingBar.italic')"
      :class="{ active: ctx.textFormatting.italic }"
      @mousedown.prevent="execCommand('toggleItalic')"
    >
      <MaterialSymbol name="format_italic" />
    </button>
    <button
      :title="t('formattingBar.underlineShortcut')"
      :aria-label="t('formattingBar.underline')"
      :class="{ active: !!ctx.textFormatting.underline }"
      @mousedown.prevent="execCommand('toggleUnderline')"
    >
      <MaterialSymbol name="format_underlined" />
    </button>
    <button
      :title="t('formattingBar.strikethrough')"
      :aria-label="t('formattingBar.strikethrough')"
      :class="{ active: ctx.textFormatting.strike }"
      @mousedown.prevent="execCommand('toggleStrike')"
    >
      <MaterialSymbol name="strikethrough_s" />
    </button>

    <!-- 7. Text color — Word-style split button + advanced picker (theme matrix) -->
    <ColorPicker
      mode="text"
      :value="currentTextColorHex"
      :theme="theme ?? null"
      @change="onTextColor"
    />

    <!-- 8. Highlight — Word-style split button + advanced picker -->
    <ColorPicker
      mode="highlight"
      :value="currentHighlightHex"
      :theme="theme ?? null"
      @change="onHighlight"
    />

    <!-- 9. Link -->
    <button
      :title="t('formattingBar.insertLinkShortcut')"
      :aria-label="t('formattingBar.insertLink')"
      @mousedown.prevent="$emit('insert-link')"
    >
      <MaterialSymbol name="link" />
    </button>

    <!-- 10. Superscript / Subscript -->
    <button
      :title="t('formattingBar.superscriptShortcut')"
      :aria-label="t('formattingBar.superscript')"
      :class="{ active: ctx.textFormatting.vertAlign === 'superscript' }"
      @mousedown.prevent="execCommand('toggleSuperscript')"
    >
      <MaterialSymbol name="superscript" />
    </button>
    <button
      :title="t('formattingBar.subscriptShortcut')"
      :aria-label="t('formattingBar.subscript')"
      :class="{ active: ctx.textFormatting.vertAlign === 'subscript' }"
      @mousedown.prevent="execCommand('toggleSubscript')"
    >
      <MaterialSymbol name="subscript" />
    </button>

    <span class="divider" />

    <!-- 11. Alignment dropdown (current align icon + chevron) -->
    <div class="toolbar-dropdown" ref="alignDropdownRef">
      <button
        class="toolbar-dropdown__trigger align-trigger"
        @mousedown.prevent="toggleDropdown('align')"
        :aria-expanded="openDropdown === 'align'"
        aria-haspopup="listbox"
        :title="t('formattingBar.groups.alignment')"
      >
        <MaterialSymbol :name="alignIconName" />
        <MaterialSymbol class="chevron" name="arrow_drop_down" :size="16" />
      </button>
      <!-- Horizontal icon strip matching React's AlignmentButtons (icon-only,
           blue active state), not a vertical labeled menu. -->
      <div
        v-if="openDropdown === 'align'"
        :style="dropdownMenuStyle"
        class="toolbar-dropdown__menu align-strip"
      >
        <button
          class="align-strip__btn"
          :class="{ active: currentAlignment === 'left' }"
          :title="`${t('alignment.alignLeft')} (${t('alignment.alignLeftShortcut')})`"
          @mousedown.prevent="
            execCommand('alignLeft');
            openDropdown = null;
          "
        >
          <MaterialSymbol name="format_align_left" :size="18" />
        </button>
        <button
          class="align-strip__btn"
          :class="{ active: currentAlignment === 'center' }"
          :title="`${t('alignment.center')} (${t('alignment.centerShortcut')})`"
          @mousedown.prevent="
            execCommand('alignCenter');
            openDropdown = null;
          "
        >
          <MaterialSymbol name="format_align_center" :size="18" />
        </button>
        <button
          class="align-strip__btn"
          :class="{ active: currentAlignment === 'right' }"
          :title="`${t('alignment.alignRight')} (${t('alignment.alignRightShortcut')})`"
          @mousedown.prevent="
            execCommand('alignRight');
            openDropdown = null;
          "
        >
          <MaterialSymbol name="format_align_right" :size="18" />
        </button>
        <button
          class="align-strip__btn"
          :class="{ active: currentAlignment === 'both' }"
          :title="`${t('alignment.justify')} (${t('alignment.justifyShortcut')})`"
          @mousedown.prevent="
            execCommand('alignJustify');
            openDropdown = null;
          "
        >
          <MaterialSymbol name="format_align_justify" :size="18" />
        </button>
      </div>
    </div>

    <!-- 12. Lists + Indent -->
    <button
      :title="t('lists.bulletList')"
      :aria-label="t('lists.bulletList')"
      :class="{ active: ctx.inList && ctx.listType === 'bullet' }"
      @mousedown.prevent="execCommand('toggleBulletList')"
    >
      <MaterialSymbol name="format_list_bulleted" />
    </button>
    <button
      :title="t('lists.numberedList')"
      :aria-label="t('lists.numberedList')"
      :class="{ active: ctx.inList && ctx.listType === 'numbered' }"
      @mousedown.prevent="execCommand('toggleNumberedList')"
    >
      <MaterialSymbol name="format_list_numbered" />
    </button>
    <button
      :title="t('lists.decreaseIndent')"
      :aria-label="t('lists.decreaseIndent')"
      :disabled="!canOutdent"
      @mousedown.prevent="execCommand('outdent')"
    >
      <MaterialSymbol name="format_indent_decrease" />
    </button>
    <button
      :title="t('lists.increaseIndent')"
      :aria-label="t('lists.increaseIndent')"
      @mousedown.prevent="execCommand('indent')"
    >
      <MaterialSymbol name="format_indent_increase" />
    </button>

    <!-- 13. Line Spacing -->
    <div class="toolbar-dropdown" ref="spacingDropdownRef">
      <button
        class="toolbar-dropdown__trigger"
        @mousedown.prevent="toggleDropdown('spacing')"
        :aria-expanded="openDropdown === 'spacing'"
        aria-haspopup="listbox"
        :title="t('lineSpacing.label')"
      >
        <MaterialSymbol name="format_line_spacing" />
        <MaterialSymbol class="chevron" name="arrow_drop_down" :size="16" />
      </button>
      <div
        v-if="openDropdown === 'spacing'"
        :style="dropdownMenuStyle"
        class="toolbar-dropdown__menu"
      >
        <button
          v-for="sp in lineSpacingOptions"
          :key="sp.value"
          class="toolbar-dropdown__item"
          :class="{ active: isCurrentLineSpacing(sp.value) }"
          @mousedown.prevent="setLineSpacing(sp.value)"
        >
          {{ sp.labelKey ? t(sp.labelKey) : sp.label }}
        </button>
      </div>
    </div>

    <!-- 14. Clear Formatting -->
    <button
      :title="t('formattingBar.clearFormatting')"
      :aria-label="t('formattingBar.clearFormatting')"
      @mousedown.prevent="handleClearFormatting"
    >
      <MaterialSymbol name="format_clear" />
    </button>

    <span class="divider" />

    <!-- 15. Comments & Changes -->
    <button
      :title="t('formattingBar.commentsAndChanges')"
      :aria-label="t('formattingBar.commentsAndChanges')"
      :class="{ active: commentsSidebarOpen }"
      @mousedown.prevent="$emit('toggle-sidebar')"
    >
      <MaterialSymbol name="comment" />
    </button>

    <!-- 15.5 Image context group — wrap-mode dropdown + transform
         (rotate/flip) + image-properties tune button. Visible only
         when a NodeSelection lands on an image node. -->
    <template v-if="imageContext">
      <span class="divider" />
      <ImageWrapDropdown
        :image-context="imageContext"
        @change="(v) => $emit('image-wrap-type', v)"
      />
      <ImageTransformDropdown @transform="(a) => $emit('image-transform', a)" />
      <button
        :title="t('formattingBar.imagePropertiesShortcut')"
        :aria-label="t('formattingBar.imageProperties')"
        @mousedown.prevent="$emit('image-properties')"
      >
        <MaterialSymbol name="tune" />
      </button>
    </template>

    <!-- 16. Table context buttons — visible only when cursor is in a
         table. Hosted via a named slot so the buttons render inline
         inside this pill instead of a separate row. -->
    <slot name="table-context" />

    <!-- 17. Editing-mode dropdown — flows inline after the comments
         button with a divider before it. -->
    <span class="divider" />
    <EditingModeDropdown
      v-if="editorMode"
      :model-value="editorMode"
      @update:model-value="(m: any) => $emit('mode-change', m)"
    />
    <slot name="toolbar-extra" />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { EditorView } from 'prosemirror-view';
import { undoDepth, redoDepth } from 'prosemirror-history';
import { extractSelectionContext } from '@eigenpal/docx-editor-core/prosemirror/plugins/selectionTracker';
import { clearFormatting } from '@eigenpal/docx-editor-core/prosemirror/commands/formatting';
import MaterialSymbol from './ui/MaterialSymbol.vue';
import ColorPicker from './ui/ColorPicker.vue';
import EditingModeDropdown, { type EditorMode } from './EditingModeDropdown.vue';
import ImageWrapDropdown from './ui/ImageWrapDropdown.vue';
import ImageTransformDropdown, {
  type TransformAction as ImageTransformAction,
} from './ui/ImageTransformDropdown.vue';
import type { SelectionContext } from '@eigenpal/docx-editor-core/prosemirror/plugins/selectionTracker';
import type { ColorValue, Theme, Style } from '@eigenpal/docx-editor-core/types/document';
import {
  normalizeFontFamilies,
  type FontOption,
} from '@eigenpal/docx-editor-core/utils/fontOptions';
import {
  defaultFonts,
  fontSizePresets,
  lineSpacingOptions,
  ZOOM_PRESETS,
  DEFAULT_ZOOM_PERCENT,
} from './Toolbar/presets';
import { useToolbarDropdowns } from '../composables/useToolbarDropdowns';
import { useToolbarFontSize } from '../composables/useToolbarFontSize';
import { useParagraphStyleOptions } from '../composables/useParagraphStyleOptions';
import { useTranslation } from '../i18n';

/**
 * Image context — populated by the host when a NodeSelection lands on
 * an image node. Drives the wrap dropdown's active-value math
 * (e.g. `displayMode === 'float'`) and the "Image properties" button.
 */
export interface ImageToolbarContext {
  wrapType: string;
  displayMode: string;
  cssFloat: string | null;
}

const props = defineProps<{
  view: EditorView | null;
  getCommands: () => Record<string, (...args: any[]) => any>;
  stateTick: number;
  zoomPercent?: number;
  isMinZoom?: boolean;
  isMaxZoom?: boolean;
  zoomPresets?: number[];
  showZoomControl?: boolean;
  editorMode?: EditorMode;
  /** Whether the comments sidebar is currently open — drives the
      active state on the comments toolbar button. */
  commentsSidebarOpen?: boolean;
  /** Image-node selection context — when non-null, the image group
      (wrap dropdown + properties button) renders inline inside the
      pill, just like React's Toolbar. */
  imageContext?: ImageToolbarContext | null;
  /** Document theme — feeds the color picker's theme-color matrix. */
  theme?: Theme | null;
  /** Optional custom font list matching React's `fontFamilies` prop. */
  fontFamilies?: ReadonlyArray<string | FontOption>;
  /** Paragraph styles from the loaded document (document.package.styles.styles).
      When present, the style picker shows the document's real styles + names,
      matching React's Toolbar `documentStyles` prop. Falls back to presets. */
  documentStyles?: Style[];
}>();

const { t } = useTranslation();

const emit = defineEmits<{
  (e: 'find-replace'): void;
  (e: 'insert-table'): void;
  (e: 'insert-image'): void;
  (e: 'insert-link'): void;
  (e: 'insert-symbol'): void;
  (e: 'insert-page-break'): void;
  (e: 'insert-toc'): void;
  (e: 'page-setup'): void;
  (e: 'toggle-outline'): void;
  (e: 'zoom-in'): void;
  (e: 'zoom-out'): void;
  (e: 'zoom-set', level: number): void;
  (e: 'toggle-sidebar'): void;
  (e: 'apply-style', styleId: string): void;
  (e: 'mode-change', mode: EditorMode): void;
  (e: 'image-wrap-type', wrapType: string): void;
  (e: 'image-properties'): void;
  (e: 'image-transform', action: ImageTransformAction): void;
}>();

// =========================================================================
// Dropdown state
// =========================================================================

const zoomPercent = computed(() => props.zoomPercent ?? DEFAULT_ZOOM_PERCENT);
const isMinZoom = computed(() => props.isMinZoom ?? false);
const isMaxZoom = computed(() => props.isMaxZoom ?? false);
const zoomPresets = computed(() => props.zoomPresets ?? ZOOM_PRESETS);
const showZoomControl = computed(() => props.showZoomControl ?? true);

const styleDropdownRef = ref<HTMLElement | null>(null);
const fontDropdownRef = ref<HTMLElement | null>(null);
const sizeDropdownRef = ref<HTMLElement | null>(null);
const alignDropdownRef = ref<HTMLElement | null>(null);
const spacingDropdownRef = ref<HTMLElement | null>(null);
const zoomDropdownRef = ref<HTMLElement | null>(null);

// `.basic-toolbar` uses `overflow-x: auto` for narrow-viewport scrolling,
// which forces overflow-y to clip per the CSS spec. Dropdowns therefore
// can't escape via `position: absolute` — `useToolbarDropdowns` renders
// each menu with `position: fixed` and recomputes coords on every open.
const { openDropdown, dropdownMenuStyle, toggleDropdown, recomputeDropdownPos } =
  useToolbarDropdowns({
    zoom: zoomDropdownRef,
  style: styleDropdownRef,
  font: fontDropdownRef,
  size: sizeDropdownRef,
  align: alignDropdownRef,
  spacing: spacingDropdownRef,
});

// =========================================================================
// Selection context (reactive via stateTick)
// =========================================================================

const ctx = computed<SelectionContext>(() => {
  void props.stateTick;
  const v = props.view;
  if (!v) {
    return {
      hasSelection: false,
      isMultiParagraph: false,
      textFormatting: {},
      paragraphFormatting: {},
      startParagraphIndex: 0,
      endParagraphIndex: 0,
      inList: false,
      activeCommentIds: [],
      inInsertion: false,
      inDeletion: false,
    };
  }
  return extractSelectionContext(v.state);
});

// =========================================================================
// Derived state for toolbar display
// =========================================================================

// All of the mark-driven displays (font family / size / colors) read from
// `ctx.value.textFormatting`, which is built by core's `extractSelectionContext`
// and correctly handles both cursor and range selections — including the
// "$from at a mark boundary" case. No local mark snapshot needed.

const currentFontFamily = computed(() => {
  const ff = ctx.value.textFormatting.fontFamily;
  return ff?.ascii || ff?.hAnsi || 'Arial';
});

const currentFontSize = computed(() => {
  const sz = ctx.value.textFormatting.fontSize;
  return sz ? sz / 2 : 11; // half-points to points
});

const currentAlignment = computed(() => {
  return ctx.value.paragraphFormatting.alignment || 'left';
});

const alignIconName = computed(() => {
  switch (currentAlignment.value) {
    case 'center':
      return 'format_align_center';
    case 'right':
      return 'format_align_right';
    case 'both':
      return 'format_align_justify';
    default:
      return 'format_align_left';
  }
});

// Style-picker options + current label (see useParagraphStyleOptions).
const { resolvedParagraphStyles, currentStyleLabel } = useParagraphStyleOptions({
  documentStyles: () => props.documentStyles,
  currentStyleId: () => ctx.value.paragraphFormatting.styleId || 'Normal',
  t,
});

// Mirror React Toolbar's `canUndo` / `canRedo` props (Toolbar.tsx:81-82).
// React threads them in from a parent hook; Vue computes them straight from the
// prosemirror-history plugin state via undoDepth/redoDepth so the buttons grey
// out the same way without needing extra plumbing in DocxEditor.
const canUndo = computed(() => {
  void props.stateTick;
  const v = props.view;
  return v ? undoDepth(v.state) > 0 : false;
});
const canRedo = computed(() => {
  void props.stateTick;
  const v = props.view;
  return v ? redoDepth(v.state) > 0 : false;
});

// Mirror React ListButtons.canOutdent (ListButtons.tsx:229) — outdent only fires
// when the cursor is in a list level > 0 OR the paragraph has a left indent.
const canOutdent = computed(() => {
  const c = ctx.value;
  const inListLevel = c.inList && (c.listLevel ?? 0) > 0;
  const hasIndent = (c.paragraphFormatting.indentLeft ?? 0) > 0;
  return inListLevel || hasIndent;
});

// Hex (no `#`, uppercase) of the currently-applied text color, for matching
// the active swatch in the text-color dropdown. Mirrors React's
// `selectedColor` prop on `<StandardColors>` (ColorPicker.tsx:707).
const currentTextColorHex = computed<string | undefined>(() => {
  const rgb = ctx.value.textFormatting.color?.rgb;
  return rgb ? rgb.replace(/^#/, '').toUpperCase() : undefined;
});

// Same for highlight color — React's highlight ColorPicker passes
// `currentFormatting.highlight` straight through as `selectedColor`.
const currentHighlightHex = computed<string | undefined>(() => {
  const h = ctx.value.textFormatting.highlight;
  if (!h || h === 'none') return undefined;
  return h.replace(/^#/, '').toUpperCase();
});

// =========================================================================
// Constants
// =========================================================================

const normalizedFonts = computed(() => normalizeFontFamilies(props.fontFamilies) ?? defaultFonts);
const fontGroups = computed(() => [
  {
    label: t('font.sansSerif'),
    fonts: normalizedFonts.value.filter((font) => font.category === 'sans-serif'),
  },
  {
    label: t('font.serif'),
    fonts: normalizedFonts.value.filter((font) => font.category === 'serif'),
  },
  {
    label: t('font.monospace'),
    fonts: normalizedFonts.value.filter((font) => font.category === 'monospace'),
  },
  {
    label: t('font.other'),
    fonts: normalizedFonts.value.filter((font) => !font.category || font.category === 'other'),
  },
]);

// =========================================================================
// Command helpers
// =========================================================================

function execCommand(name: string, ...args: any[]) {
  const v = props.view;
  if (!v) return;
  const cmds = props.getCommands();
  const cmdFactory = cmds[name];
  if (!cmdFactory) {
    console.warn('[Toolbar] command not found:', name);
    return;
  }
  const command = cmdFactory(...args);
  command(v.state, (tr: any) => v.dispatch(tr), v);
  // The dispatched transaction triggers the host's onSelectionUpdate, which
  // bumps stateTick — `ctx` re-derives marks for the toolbar automatically.
  // Only refocus if PM lost focus — unconditional focus() can dispatch
  // a selection-syncing transaction that clears stored marks.
  if (!v.hasFocus()) v.focus();
  openDropdown.value = null;
}

function setFont(fontName: string) {
  execCommand('setFontFamily', fontName);
}

// Font-size box + steppers + preset dropdown (see useToolbarFontSize for the
// editing/commit flow and the `sizeTyped` guard).
const {
  onSizeFocus,
  onSizeInput,
  commitFontSize,
  pickFontSize,
  increaseFontSize,
  decreaseFontSize,
} = useToolbarFontSize({ currentFontSize, openDropdown, recomputeDropdownPos, execCommand });

function onTextColor(color: ColorValue | string) {
  if (typeof color === 'object' && color.auto) {
    execCommand('clearTextColor');
    return;
  }
  // ColorPicker emits a ColorValue ({ rgb } or themed { themeColor, rgb, themeTint/Shade }).
  execCommand('setTextColor', typeof color === 'string' ? { rgb: color } : color);
}

function handleClearFormatting() {
  const v = props.view;
  if (!v) return;
  clearFormatting(v.state, (tr: any) => v.dispatch(tr), v);
  if (!v.hasFocus()) v.focus();
}

function onHighlight(color: ColorValue | string) {
  // Highlight mode always emits a string ('none' or a hex).
  const value = typeof color === 'string' ? color : (color.rgb ?? 'none');
  execCommand('setHighlight', value);
}

function handleApplyStyle(styleId: string) {
  emit('apply-style', styleId);
  openDropdown.value = null;
}

function setLineSpacing(value: number) {
  execCommand('setLineSpacing', value);
}

function isCurrentLineSpacing(twips: number): boolean {
  const current = ctx.value.paragraphFormatting.lineSpacing;
  if (!current) return twips === 240; // default is single
  return Math.abs(current - twips) < 10;
}
</script>

<style scoped>
/* Pill-shaped toolbar — mirrors React's Toolbar exactly:
   bg #f1f5f9 (slate-100), rounded-full, 36px min-height, px-2 py-1,
   mx-2 mb-1, overflow-x auto. */
.basic-toolbar {
  display: flex;
  align-items: center;
  gap: 1px;
  padding: 4px 8px;
  margin: 0 8px 4px;
  /* Match React's formatting-row pill exactly (Toolbar.tsx: bg-muted) */
  background: hsl(var(--muted));
  border-radius: 9999px;
  flex-wrap: nowrap;
  min-height: 36px;
  overflow-x: auto;
  scrollbar-width: thin;
}
.basic-toolbar::-webkit-scrollbar {
  height: 4px;
}
.basic-toolbar::-webkit-scrollbar-thumb {
  background: var(--doc-shadow);
  border-radius: 2px;
}
/* Buttons mirror React's ghost icon-sm Button: 28×28, rounded-md (6px),
   14px, muted-foreground default / foreground + muted on hover. */
.basic-toolbar button,
.basic-toolbar input {
  font-family: inherit;
}
.basic-toolbar button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 28px;
  height: 28px;
  border: none;
  border-radius: 6px;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  /* Normal weight + shadcn tokens to match React's toolbar buttons in light
     and dark (text-muted-foreground / hover:text-foreground / hover:bg-muted). */
  font-weight: 400;
  color: hsl(var(--muted-foreground));
  padding: 0;
  white-space: nowrap;
  flex-shrink: 0;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.basic-toolbar button:hover:not(:disabled) {
  background: hsl(var(--muted));
  color: hsl(var(--foreground));
}
.basic-toolbar button:active:not(:disabled) {
  background: hsl(var(--muted));
}
.basic-toolbar button:focus-visible {
  outline: 2px solid var(--doc-primary);
  outline-offset: 2px;
}
.basic-toolbar button:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}
.basic-toolbar button.active,
.basic-toolbar button.active:hover {
  background: hsl(var(--foreground));
  color: var(--doc-on-primary);
}
/* Dark: --foreground flips light → white slab; use Word's accent toggle. */
.ep-root.dark .basic-toolbar button.active,
.ep-root.dark .basic-toolbar button.active:hover {
  background: var(--doc-primary-light);
  color: var(--doc-primary);
}
/* Match React's icon size (18px in 28px button). */
.basic-toolbar button :deep(svg) {
  width: 18px;
  height: 18px;
}
/* Alignment popover — horizontal icon strip mirroring React's
   AlignmentButtons (icon-only 32px buttons, blue active toggle). */
.align-strip {
  display: flex;
  gap: 2px;
  padding: 6px;
}
.basic-toolbar .align-strip__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: transparent;
  color: var(--doc-text);
  cursor: pointer;
}
.basic-toolbar .align-strip__btn:hover:not(.active) {
  background: var(--doc-bg-hover);
}
.basic-toolbar .align-strip__btn.active {
  background: var(--doc-primary-light);
  color: var(--doc-primary);
}
/* Group divider — React's ToolbarGroup border-r border-slate-200/50. */
.divider {
  width: 1px;
  height: 20px;
  margin: 0 6px;
  background: hsl(var(--border) / 0.5);
  flex-shrink: 0;
}
/* Editing-mode chip — full-strength --doc-text + --doc-bg-hover, like React. */
.basic-toolbar :deep(.editing-mode__trigger) {
  height: 28px;
  background: transparent;
  color: var(--doc-text);
}
.basic-toolbar :deep(.editing-mode__trigger:hover),
.basic-toolbar :deep(.editing-mode__trigger--open) {
  background: var(--doc-bg-hover);
  color: var(--doc-text);
}

/* Dropdown system */
.toolbar-dropdown {
  position: relative;
  display: flex;
  align-items: center;
}
/* Scoped under .basic-toolbar to beat the base button rule; mirrors React's
   SelectTrigger (text-sm, normal weight, foreground). Hover = base button. */
.basic-toolbar .toolbar-dropdown__trigger {
  display: flex;
  align-items: center;
  gap: 2px;
  height: 28px;
  padding: 0 6px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  font-weight: 400;
  color: hsl(var(--foreground));
  white-space: nowrap;
}
.chevron {
  font-size: 10px;
  color: hsl(var(--muted-foreground));
}
.toolbar-dropdown__menu {
  position: fixed;
  z-index: 10000;
  background: var(--doc-surface);
  /* Match React's Radix Select content (border-border slate, rounded-lg, shadow-lg) */
  border: 1px solid hsl(var(--border));
  border-radius: 8px;
  box-shadow: var(--doc-shadow-lg);
  max-height: 320px;
  overflow-y: auto;
  min-width: 120px;
  padding: 4px 0;
}
/* Scoped under .basic-toolbar to beat the base button rule (menu renders in
   the toolbar subtree). Resets its fixed 28px height so items grow to the
   preview font (Title 26px); React SelectItem foreground + py-1.5 (style menu
   bumps to py-2.5 below); hover (bg-muted) = base button:hover. */
.basic-toolbar .toolbar-dropdown__item {
  display: block;
  width: 100%;
  height: auto;
  min-width: 0;
  line-height: 1.3;
  padding: 6px 12px;
  border: none;
  background: transparent;
  cursor: pointer;
  text-align: left;
  font-size: 13px;
  color: hsl(var(--foreground));
  white-space: nowrap;
}
.basic-toolbar .toolbar-dropdown__item:hover {
  background: hsl(var(--muted));
}
.basic-toolbar .toolbar-dropdown__item.active {
  /* React marks the selected item with a grey bg-muted highlight, not indigo. */
  background: hsl(var(--muted));
  color: hsl(var(--foreground));
  font-weight: 500;
}
.toolbar-dropdown__group-label {
  /* Match React's SelectLabel: text-xs font-medium, Title Case (no uppercase). */
  padding: 6px 8px;
  font-size: 12px;
  font-weight: 500;
  color: hsl(var(--muted-foreground));
}

/* Style picker */
.style-trigger {
  min-width: 90px;
}
.style-menu {
  /* Vue's menu is position:fixed with only top/left set, so width:auto
     balloons (shrink-to-fit doesn't apply) — pin React's ~145px width; long
     names truncate via the item overflow + span ellipsis. max-height mirrors
     React's max-h-[400px] (the base 320px cap clipped the last styles). */
  width: 160px;
  max-height: 400px;
}
.basic-toolbar .style-menu .toolbar-dropdown__item {
  overflow: hidden;
  /* React's StylePicker item uses py-2.5 px-3 = 10px 12px. */
  padding: 10px 12px;
}
.style-menu .toolbar-dropdown__item span {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Font picker */
.font-trigger {
  min-width: 100px;
}
.font-menu {
  min-width: 180px;
}

/* Font size */
.font-size-group {
  display: flex;
  align-items: center;
  gap: 0;
}
/* Ghost +/- stepper buttons (borderless, like React's icon buttons) */
.size-btn {
  width: 28px !important;
  height: 28px !important;
  min-width: 28px !important;
  font-size: 15px !important;
  line-height: 1;
  border: none !important;
  border-radius: 4px !important;
  padding: 0 !important;
  background: transparent;
  color: hsl(var(--muted-foreground));
  cursor: pointer;
}
.size-btn:hover:not(:disabled) {
  background: hsl(var(--muted) / 0.8);
  color: hsl(var(--foreground));
}
.size-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
/* Editable, clearly-bordered value box (mirrors React's font-size input) */
.size-trigger {
  width: 40px;
  height: 28px;
  margin: 0 2px;
  text-align: center;
  font-size: 14px;
  font-weight: 400;
  color: hsl(var(--foreground));
  border: 1px solid hsl(var(--border));
  border-radius: 4px;
  background: var(--doc-surface);
  padding: 0;
  outline: none;
}
.size-trigger:focus {
  border-color: var(--doc-primary);
  box-shadow: 0 0 0 1px var(--doc-primary);
}
.size-menu {
  min-width: 60px;
}
.size-menu .toolbar-dropdown__item {
  text-align: center;
}

/* Zoom control */
.zoom-group {
  display: flex;
  align-items: center;
  gap: 0;
}
.zoom-trigger {
  text-align: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 400;
  border: 1px solid transparent;
}
.zoom-menu {
  min-width: 80px;
}
.zoom-menu .toolbar-dropdown__item {
  text-align: center;
}
</style>
