Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/vue/src/components/DocxEditor.vue
<template>
<div
:class="[
'docx-editor-vue ep-root paged-editor',
isDark ? 'dark' : '',
className,
{
'paged-editor--readonly': readOnly,
'paged-editor--hf-editing': hfEdit !== null,
'paged-editor--editing-header': hfEdit?.position === 'header',
'paged-editor--editing-footer': hfEdit?.position === 'footer',
},
]"
:style="style"
>
<div class="docx-editor-vue__toolbar-shell">
<DocxEditorMenuBar
:show-menu-bar="showMenuBar"
:document-name="documentName"
:document-name-editable="documentNameEditable"
:render-logo="renderLogo"
:render-title-bar-right="renderTitleBarRight"
@rename="handleDocumentNameChange"
@menu-action="handleMenuAction"
@insert-table="handleMenuTableInsert"
>
<template #title-bar-left><slot name="title-bar-left" /></template>
<template #title-bar-right><slot name="title-bar-right" /></template>
</DocxEditorMenuBar>
<!-- Toolbar pill: formatting buttons + editing-mode dropdown. TableToolbar
renders into the `table-context` slot (inline in the same pill); the
slot is empty when the cursor isn't in a table. -->
<Toolbar
v-if="showToolbar"
:view="activeFormattingView"
:get-commands="getCommands"
:state-tick="stateTick"
:zoom-percent="zoomPercent"
:is-min-zoom="isMinZoom"
:is-max-zoom="isMaxZoom"
:zoom-presets="ZOOM_PRESETS"
:show-zoom-control="showZoomControl"
:editor-mode="editorMode"
:comments-sidebar-open="showSidebar"
:image-context="imageToolbarContext"
:theme="documentTheme"
:font-families="fontFamilies"
:document-styles="documentStyles"
@insert-link="showHyperlink = true"
@apply-style="handleApplyStyle"
@zoom-in="zoomIn"
@zoom-out="zoomOut"
@zoom-set="setZoom"
@toggle-sidebar="handleToggleSidebar"
@mode-change="setEditorMode"
@image-wrap-type="handleToolbarImageWrap"
@image-properties="showImageProperties = true"
@image-transform="handleImageTransform"
>
<template #table-context>
<TableToolbar
:view="activeFormattingView"
:get-commands="getCommands"
:state-tick="stateTick"
:theme="documentTheme"
/>
</template>
<template v-if="toolbarExtra" #toolbar-extra>
<component :is="toolbarExtra" />
</template>
<template v-else #toolbar-extra>
<slot name="toolbar-extra" />
</template>
</Toolbar>
</div>
<DocxEditorDialogs
v-model:show-find-replace="showFindReplace"
v-model:show-hyperlink="showHyperlink"
v-model:show-insert-symbol="showInsertSymbol"
v-model:show-image-properties="showImageProperties"
v-model:show-page-setup="showPageSetup"
v-model:show-watermark="showWatermark"
v-model:show-keyboard-shortcuts="showKeyboardShortcuts"
:view="editorView"
:bookmarks="bookmarkOptions"
:selected-image-pm-pos="selectedImage?.pmPos ?? null"
:section-properties="currentSectionProps"
:current-watermark="currentWatermark"
:watermark-presets="watermarkPresets"
:scroll-visible-position-into-view="scrollVisiblePositionIntoView"
@insert-symbol="handleInsertSymbol"
@hyperlink-submit="handleHyperlinkSubmit"
@hyperlink-remove="handleHyperlinkRemove"
@page-setup-apply="handlePageSetupApply"
@watermark-apply="handleWatermarkApply"
/>
<div v-if="parseError" class="docx-editor-vue__error">
{{ parseError }}
</div>
<div v-if="!isReady && !parseError" class="docx-editor-vue__loading">Loading...</div>
<div ref="hiddenPmRef" class="docx-editor-vue__hidden-pm paged-editor__hidden-pm" />
<div class="docx-editor-vue__editor-scroll" @mousedown="handleEditorScrollMouseDown">
<div
v-if="showRuler && currentSectionProps"
class="docx-editor-vue__ruler-row"
:style="rulerRowStyle"
>
<HorizontalRuler
:section-props="currentSectionProps"
:zoom="zoom"
:editable="!readOnly"
:indent-left="rulerIndents.indentLeft"
:indent-right="rulerIndents.indentRight"
:first-line-indent="rulerIndents.firstLineIndent"
:hanging-indent="rulerIndents.hangingIndent"
:tab-stops="rulerIndents.tabStops"
@left-margin-change="handleLeftMarginChange"
@right-margin-change="handleRightMarginChange"
@indent-left-change="handleIndentLeftChange"
@indent-right-change="handleIndentRightChange"
@first-line-indent-change="handleFirstLineIndentChange"
@tab-stop-remove="handleTabStopRemove"
/>
</div>
<div class="docx-editor-vue__editor-area">
<div
ref="pagesViewportRef"
class="docx-editor-vue__pages-viewport"
@mousedown="handlePagesMouseDown"
@mousemove="handlePagesMouseMove"
@click="handlePagesClick"
@dblclick="handlePagesDoubleClick"
@contextmenu.prevent="handleContextMenu"
@wheel="handleZoomWheel"
>
<div v-if="showRuler && currentSectionProps" class="docx-editor-vue__vertical-ruler">
<VerticalRuler
:section-props="currentSectionProps"
:zoom="zoom"
:editable="!readOnly"
@top-margin-change="handleTopMarginChange"
@bottom-margin-change="handleBottomMarginChange"
/>
</div>
<div
ref="pagesRef"
class="docx-editor-vue__pages paged-editor__pages"
:style="pagesContainerStyle"
/>
<ContentControlWidgets v-if="!readOnly" :container="pagesRef" :view="editorView" />
<InlineHeaderFooterEditor
:is-open="hfEdit !== null"
:position="hfEdit?.position ?? 'header'"
:view="activeHfView"
:target-rect="hfEdit?.targetRect ?? null"
@save="handleHfSave"
@close="hfEdit = null"
@remove="handleHfRemove"
/>
<!-- HF selection overlay: blue highlight rects for a drag-selected range
in the painted header/footer. Coords are viewport-relative (position:
fixed), recomputed on every HF transaction and on scroll/resize. -->
<div
v-for="(rect, i) in hfEdit ? hfSelectionRects : []"
:key="`hf-sel-${i}-${rect.top}-${rect.left}`"
class="vue-hf-sel-rect"
aria-hidden="true"
:style="{
position: 'fixed',
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
background: 'var(--doc-selection)',
pointerEvents: 'none',
zIndex: 9998,
}"
/>
<div
v-if="hfEdit && hfCaretRect"
aria-hidden="true"
:style="{
position: 'fixed',
top: `${hfCaretRect.top}px`,
left: `${hfCaretRect.left}px`,
width: '2px',
height: `${hfCaretRect.height}px`,
background: '#4285f4',
pointerEvents: 'none',
zIndex: 9999,
animation: 'hf-caret-blink 1.06s steps(1) infinite',
}"
/>
<ImageSelectionOverlay
:image-info="selectedImage"
:zoom="zoom"
:view="editorView"
@open-properties="showImageProperties = true"
@deselect="selectedImage = null"
@interact-start="imageInteracting = true"
@interact-end="imageInteracting = false"
@context-menu="handleSelectedImageContextMenu"
/>
<DecorationLayer
:get-view="getEditorViewForDecorations"
:get-pages-container="getPagesContainerForDecorations"
:zoom="zoom"
:transaction-version="stateTick"
:sync-coordinator="syncCoordinator"
/>
<!-- Floating "Add comment" button — appears at the right edge
of the page when the user has a non-empty selection. -->
<button
v-if="floatingCommentBtn && !isAddingComment && !readOnly"
type="button"
class="docx-editor-vue__floating-comment"
:style="{ top: floatingCommentBtn.top + 'px', left: floatingCommentBtn.left + 'px' }"
:title="t('comments.addComment')"
@mousedown.prevent.stop="handleStartAddComment"
>
<MaterialSymbol name="add_comment" :size="16" />
</button>
<!-- Table quick-action "+" button — appears on hover near a
table edge. Hovering the button cancels the hide-debounce
so the user can actually reach it. -->
<button
v-if="tableInsertButton && !readOnly"
type="button"
class="docx-editor-vue__table-insert-btn"
:style="{
left: tableInsertButton.x + 'px',
top: tableInsertButton.y + 'px',
}"
:title="
tableInsertButton.type === 'row' ? 'Insert row below' : 'Insert column to the right'
"
:aria-label="
tableInsertButton.type === 'row' ? 'Insert row below' : 'Insert column to the right'
"
@mousedown="handleTableInsertClick"
@mouseenter="clearTableInsertTimer"
@mouseleave="tableInsertButton = null"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M6 1v10M1 6h10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
<CommentMarginMarkers
:comments="comments"
:pages-container="pagesRef"
:zoom="zoom"
:page-width-px="pageWidthPx"
:sidebar-open="showSidebar"
:resolved-comment-ids="resolvedCommentIds"
@marker-click="handleMarkerClick"
/>
<UnifiedSidebar
:is-open="showSidebar"
:comments="comments"
:tracked-changes="trackedChanges"
:is-adding-comment="isAddingComment"
:add-comment-y-position="addCommentYPosition"
:show-resolved="true"
:pages-container="pagesRef"
:page-width-px="pageWidthPx"
:zoom="zoom"
:active-item-id="activeSidebarItem"
@close="showSidebar = false"
@add-comment="handleAddComment"
@cancel-add-comment="handleCancelAddComment"
@comment-reply="handleCommentReply"
@comment-resolve="handleCommentResolve"
@comment-unresolve="handleCommentUnresolve"
@comment-delete="handleCommentDelete"
@accept-change="handleAcceptChange"
@reject-change="handleRejectChange"
@accept-change-by-id="handleAcceptChangeById"
@reject-change-by-id="handleRejectChangeById"
@tracked-change-reply="handleTrackedChangeReply"
@update:active-item-id="(id: string | null) => (activeSidebarItem = id)"
/>
<!-- Hyperlink popup — lives inside the scroll container so it
moves with the link on scroll for free (position: absolute
inside the pages-viewport, no JS scroll listener). -->
<HyperlinkPopup
:data="hyperlinkPopupData"
:read-only="readOnly"
@navigate="handleHyperlinkPopupNavigate"
@copy="hyperlinkPopupData = null"
@edit="handleHyperlinkPopupEdit"
@remove="handleHyperlinkPopupRemove"
@close="hyperlinkPopupData = null"
/>
</div>
<button
v-if="!showOutline && showOutlineButton"
type="button"
class="docx-editor-vue__outline-toggle"
:title="'Show document outline'"
@click="handleToggleOutline"
@mousedown.stop
>
<MaterialSymbol name="format_list_bulleted" :size="20" />
</button>
<PageIndicator
v-if="scrollPageInfo.totalPages > 1"
:current-page="scrollPageInfo.currentPage"
:total-pages="scrollPageInfo.totalPages"
:visible="scrollPageInfo.visible"
/>
<DocumentOutline
:is-open="showOutline"
:headings="outlineHeadings"
@close="showOutline = false"
@navigate="handleOutlineNavigate"
/>
</div>
</div>
<!-- Hidden file picker for File > Open (mirrors React DocxEditor's
`docxInputRef`). Host slots can still expose their own button
(e.g. examples/vue/src/App.vue's title-bar-right `Open`). -->
<input
ref="docxInputRef"
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
style="display: none"
@change="handleDocxFileChange"
/>
<!-- Hidden image picker for Insert > Image (direct insert, no dialog). -->
<input
ref="imageInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleImageFileChange"
/>
<DocxEditorOverlays
:read-only="readOnly"
:context-menu="contextMenu"
:image-context-menu="imageContextMenu"
:image-context-menu-text-actions="imageContextMenuTextActions"
:can-open-image-properties="!!selectedImage"
@context-menu-action="handleContextMenuAction"
@close-context-menu="contextMenu.isOpen = false"
@image-wrap-select="handleImageWrapSelect"
@close-image-context-menu="imageContextMenu = null"
@open-image-properties="showImageProperties = true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import type { EditorView } from 'prosemirror-view';
import { TextSelection } from 'prosemirror-state';
import {
computeHfCaretRectFromView,
computeHfSelectionRectsFromView,
} from '@eigenpal/docx-editor-core/layout-bridge';
import { getSelectionInfo as getSelectionInfoImpl } from '../utils/refApiQueries';
import { extractSelectionState } from '@eigenpal/docx-editor-core/prosemirror';
import { nearestHfHostEl } from '../utils/domQueries';
import Toolbar from './Toolbar.vue';
import TableToolbar from './ui/TableToolbar.vue';
import DecorationLayer from './DecorationLayer.vue';
import ImageSelectionOverlay from './ImageSelectionOverlay.vue';
import DocumentOutline from './DocumentOutline.vue';
import UnifiedSidebar from './UnifiedSidebar.vue';
import CommentMarginMarkers from './CommentMarginMarkers.vue';
import MaterialSymbol from './ui/MaterialSymbol.vue';
import PageIndicator from './PageIndicator.vue';
import InlineHeaderFooterEditor from './InlineHeaderFooterEditor.vue';
import ContentControlWidgets from './ContentControlWidgets.vue';
import HorizontalRuler from './ui/HorizontalRuler.vue';
import VerticalRuler from './ui/VerticalRuler.vue';
import DocxEditorMenuBar from './DocxEditor/DocxEditorMenuBar.vue';
import DocxEditorDialogs from './DocxEditor/DocxEditorDialogs.vue';
import DocxEditorOverlays from './DocxEditor/DocxEditorOverlays.vue';
import HyperlinkPopup from './ui/HyperlinkPopup.vue';
import type { TrackedChangeEntry } from './sidebar/sidebarUtils';
import type { EditorMode, DocxEditorProps } from './DocxEditor/types';
import { useDocxEditor } from '../composables/useDocxEditor';
import { useZoom } from '../composables/useZoom';
import { useTableResize } from '../composables/useTableResize';
import { useFileIO } from '../composables/useFileIO';
import { useHyperlinkManagement } from '../composables/useHyperlinkManagement';
import { useFormattingActions } from '../composables/useFormattingActions';
import { usePageSetupControls } from '../composables/usePageSetupControls';
import { useWatermarkControls } from '../composables/useWatermarkControls';
import { useOutlineSidebar } from '../composables/useOutlineSidebar';
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts';
import { useCommentManagement } from '../composables/useCommentManagement';
import { useHostCallbacks } from '../composables/useHostCallbacks';
import { useCommentLifecycle } from '../composables/useCommentLifecycle';
import { useImageActions } from '../composables/useImageActions';
import { useContextMenus } from '../composables/useContextMenus';
import { usePagesPointer } from '../composables/usePagesPointer';
import { useSelectionSync } from '../composables/useSelectionSync';
import { useMenuActions } from '../composables/useMenuActions';
import { useDocumentLifecycle } from '../composables/useDocumentLifecycle';
import { useDocxEditorRefApi } from '../composables/useDocxEditorRefApi';
import type { Document } from '@eigenpal/docx-editor-core/types/document';
import type { Comment } from '@eigenpal/docx-editor-core/types/content';
import type { HeadingInfo } from '@eigenpal/docx-editor-core/utils/headingCollector';
import { createTranslator, provideLocale } from '../i18n';
import { twipsToPixels } from '@eigenpal/docx-editor-core/utils/units';
import { SIDEBAR_DOCUMENT_SHIFT } from '@eigenpal/docx-editor-core/utils';
import { useColorMode } from '../composables/useColorMode';
import { useFontLifecycle } from '../composables/useFontLifecycle';
import { LayoutSelectionGate } from '@eigenpal/docx-editor-core/prosemirror';
import { extractSelectionContext } from '@eigenpal/docx-editor-core/prosemirror/plugins/selectionTracker';
import { createCommentIdAllocator } from '@eigenpal/docx-editor-core/prosemirror/commentIdAllocator';
const props = withDefaults(defineProps<DocxEditorProps>(), {
documentBuffer: null,
document: null,
showToolbar: true,
showMenuBar: true,
showRuler: true,
documentName: '',
readOnly: false,
author: 'User',
mode: 'editing',
i18n: undefined,
theme: null,
colorMode: 'light',
externalPlugins: () => [],
showZoomControl: true,
initialZoom: 1,
toolbarExtra: undefined,
className: '',
style: undefined,
showOutline: false,
showOutlineButton: true,
fontFamilies: undefined,
watermarkPresets: undefined,
onPrint: undefined,
disableFindReplaceShortcuts: false,
renderLogo: undefined,
onDocumentNameChange: undefined,
documentNameEditable: true,
renderTitleBarRight: undefined,
});
const emit = defineEmits<{
(e: 'change', doc: Document): void;
(e: 'update:document', doc: Document | null): void;
(e: 'error', error: Error): void;
(e: 'ready'): void;
(e: 'rename', name: string): void;
(e: 'menu-action', action: string): void;
(e: 'mode-change', mode: EditorMode): void;
}>();
const isDark = useColorMode(() => props.colorMode);
const editorMode = ref<EditorMode>(props.mode);
const readOnly = computed(() => props.readOnly || editorMode.value === 'viewing');
// Author for UI-created comments and tracked changes; threaded into the editor
// (suggestion-mode plugin) and the comment composables. (#720)
const authorRef = computed(() => props.author);
provideLocale(computed(() => props.i18n));
const { t } = createTranslator(computed(() => props.i18n));
const hiddenPmRef = ref<HTMLElement | null>(null);
const pagesRef = ref<HTMLElement | null>(null);
const pagesViewportRef = ref<HTMLElement | null>(null);
const stateTick = ref(0);
const contentChangeSubscribers = new Set<(document: unknown) => void>();
const selectionChangeSubscribers = new Set<(selection: unknown) => void>();
const syncCoordinator = new LayoutSelectionGate();
const showFindReplace = ref(false);
const showHyperlink = ref(false);
const showInsertSymbol = ref(false);
const showImageProperties = ref(false);
const showPageSetup = ref(false);
const showOutline = ref(props.showOutline);
const showKeyboardShortcuts = ref(false);
const showSidebar = ref(false);
const isAddingComment = ref(false);
const activeSidebarItem = ref<string | null>(null);
// Tree-shaped + reassigned wholesale: shallowRef avoids deep-proxying the
// Document-shaped Comment / TrackedChange / Heading payloads. Per the
// design's shallowRef contract (Decision 5/6) and notes/reactivity-review.md.
const comments = shallowRef<Comment[]>([]);
const trackedChanges = shallowRef<TrackedChangeEntry[]>([]);
const outlineHeadings = shallowRef<HeadingInfo[]>([]);
const {
zoom,
zoomPercent,
isMinZoom,
isMaxZoom,
setZoom,
zoomIn,
zoomOut,
handleWheel: handleZoomWheel,
handleKeyDown: handleZoomKeyDown,
installShortcuts: installZoomShortcuts,
ZOOM_PRESETS,
} = useZoom(props.initialZoom);
installZoomShortcuts();
const {
editorView,
isReady,
parseError,
layout,
loadBuffer,
loadDocument: loadParsedDocument,
save: saveBlob,
focus,
destroy,
getDocument,
getCommands,
reLayout,
getHfPmView,
syncHfPMs,
setHfTransactionListener,
setDocument,
} = useDocxEditor({
hiddenContainer: hiddenPmRef,
pagesContainer: pagesRef,
readOnly,
externalPlugins: props.externalPlugins, syncCoordinator, editorMode,
author: authorRef,
onChange: (doc) => {
emit('change', doc);
emit('update:document', doc);
props.onChange?.(doc);
contentChangeSubscribers.forEach((listener) => listener(doc));
},
onError: (err) => {
emit('error', err);
props.onError?.(err);
},
onSelectionUpdate: () => {
stateTick.value++;
// The overlay repaint is intentionally NOT called here — it's driven via
// the `syncCoordinator.onRender` registration below so it paints against
// current DOM. Painting synchronously here resolves the caret against the
// not-yet-repainted DOM and the caret vanishes until the next click (#736).
const view = editorView.value;
// The prop mirrors React's `onSelectionChange`, which delivers a
// `SelectionState` (formatting/style snapshot). The ref-API subscribers
// keep Vue's existing `SelectionInfo` payload — a separate surface.
props.onSelectionChange?.(view ? extractSelectionState(view.state) : null);
const selection = getSelectionInfoImpl(view);
selectionChangeSubscribers.forEach((listener) => listener(selection));
},
});
// Host-facing comment callbacks + onEditorViewReady wiring (the `onComment*` /
// `onEditorViewReady` props), threaded into the comment composables below.
const { commentCallbacks } = useHostCallbacks(props, editorView);
// ─── Document-state derived computed refs ─────────────────────────────────
// Active section's properties drive the horizontal ruler (margins + indents).
// React reads `package.document.finalSectionProperties` for the same purpose;
// fall back to the first section's properties for older parses.
const currentSectionProps = computed(() => {
void stateTick.value;
const doc = getDocument();
if (!doc?.package?.document) return null;
const body = doc.package.document;
return body.finalSectionProperties ?? body.sections?.[0]?.properties ?? null;
});
// Active paragraph's indents/tab stops, so the ruler handles track the
// selection like React's. Read from the same extractSelectionContext the
// toolbar uses; recomputed on every selection/transaction via stateTick.
const rulerIndents = computed(() => {
void stateTick.value;
const view = editorView.value;
const pf = view ? extractSelectionContext(view.state).paragraphFormatting : {};
return {
indentLeft: pf.indentLeft ?? 0,
indentRight: pf.indentRight ?? 0,
firstLineIndent: pf.indentFirstLine ?? 0,
hangingIndent: pf.hangingIndent ?? false,
tabStops: pf.tabs ?? null,
};
});
const documentTheme = computed(() => {
void stateTick.value;
return getDocument()?.package?.theme ?? props.theme ?? null;
});
// Paragraph styles from the loaded document — feeds the toolbar style picker so
// it shows the document's real style names/order (matches React's Toolbar
// `documentStyles={document?.package.styles?.styles}`).
const documentStyles = computed(() => {
void stateTick.value;
return getDocument()?.package?.styles?.styles ?? undefined;
});
// HF caret overlay rect from the persistent HF view; shared with React via core's `computeHfCaretRectFromView`.
const hfCaretRect = ref<{ top: number; left: number; height: number } | null>(null);
// HF drag-selection rects — drawn when the user selects a range inside the
// painted header/footer. The body selection overlay is gated off in HF mode
// (`isHfEditing` in useSelectionSync), so without these the selection is set on
// the HF PM but never highlighted (#691). Shared with React via core's
// `computeHfSelectionRectsFromView`.
const hfSelectionRects = ref<Array<{ top: number; left: number; width: number; height: number }>>(
[]
);
// Paint the HF caret + drag-selection rects from the live HF view together
// (mirror of React's `applyHfOverlay`). `computeHfCaretRectFromView` returns
// null for a non-empty selection and `computeHfSelectionRectsFromView` returns
// [] for a collapsed one, so the caret and highlight are mutually exclusive.
function applyHfOverlay(view: EditorView, position: 'header' | 'footer') {
hfCaretRect.value = computeHfCaretRectFromView(view, position);
hfSelectionRects.value = computeHfSelectionRectsFromView(view, position);
}
function clearHfOverlay() {
hfCaretRect.value = null;
hfSelectionRects.value = [];
}
useFontLifecycle(() => props.fonts, (err) => {
emit('error', err);
props.onError?.(err);
});
// Memoized so the template doesn't walk the headers/footers Maps every tick.
const activeHfView = computed<EditorView | null>(() =>
hfEdit.value?.headerFooter ? (getHfPmView(hfEdit.value.headerFooter) ?? null) : null
);
// Interactive toolbar formatting targets the edited header/footer, else body (#749).
const activeFormattingView = computed<EditorView | null>(() => activeHfView.value ?? editorView.value);
// Registered in onMounted because `hfEdit` is destructured later in this script setup (TDZ).
onMounted(() => {
setHfTransactionListener((_rId, view) => {
// Re-derive toolbar state against the HF selection (incl. selection-only
// moves the HF dispatch never reports to stateTick) — parity with React (#749).
stateTick.value++;
// Defer a frame so the painter repaints, then re-measure the painted HF rect.
requestAnimationFrame(() => {
const edit = hfEdit.value;
if (!edit) return;
applyHfOverlay(view, edit.position);
const hfEl = nearestHfHostEl(edit.position);
const viewport = pagesViewportRef.value;
if (!hfEl || !viewport) return;
const el = hfEl.getBoundingClientRect();
const vp = viewport.getBoundingClientRect();
const z = zoom.value || 1;
hfEdit.value = {
...edit,
targetRect: {
top: (el.top - vp.top + viewport.scrollTop) / z,
left: (el.left - vp.left + viewport.scrollLeft) / z,
width: el.width / z,
height: el.height / z,
},
};
});
});
watch(
() => hfEdit.value,
(e) => {
if (!e) {
clearHfOverlay();
return;
}
// Collapse body PM selection + blur the body view so the user doesn't
// see two carets (body + header) at once and stray keystrokes can't
// land in the body before the HF view reclaims focus.
const view = editorView.value;
if (view) {
try {
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 0));
view.dispatch(tr);
} catch {
// selection may be invalid mid-transition; overlay is gated on
// `isHfEditing` so the body caret stays hidden anyway.
}
(view.dom as HTMLElement).blur?.();
}
// Force the selection overlay to re-render so the body caret disappears.
selectionSync.updateSelectionOverlay();
}
);
// HF caret uses position:fixed — recompute on scroll/resize so it follows the painted span.
let rafScroll = 0;
function onHfScroll() {
if (!hfEdit.value || rafScroll) return;
rafScroll = requestAnimationFrame(() => {
rafScroll = 0;
const hf = hfEdit.value;
if (!hf?.headerFooter) return;
const view = getHfPmView(hf.headerFooter);
if (view) applyHfOverlay(view, hf.position);
});
}
window.addEventListener('scroll', onHfScroll, true);
window.addEventListener('resize', onHfScroll);
onBeforeUnmount(() => {
if (rafScroll) cancelAnimationFrame(rafScroll);
window.removeEventListener('scroll', onHfScroll, true);
window.removeEventListener('resize', onHfScroll);
});
});
// When the comments sidebar opens, shift the pages container (NOT the
// scrolling viewport) left by SIDEBAR_DOCUMENT_SHIFT. Applied on the
// inner `__pages` container so the viewport's scrollbar stays at the
// real right edge instead of moving with the page.
const pagesContainerStyle = computed(() => {
const parts: string[] = [];
if (showSidebar.value) parts.push(`translateX(-${SIDEBAR_DOCUMENT_SHIFT}px)`);
if (zoom.value !== 1) parts.push(`scale(${zoom.value})`);
return {
transform: parts.length > 0 ? parts.join(' ') : undefined,
transformOrigin: 'top center',
transition: 'transform 0.2s ease',
};
});
const rulerRowStyle = computed(() => ({
paddingLeft: '20px',
paddingRight: 20 + (showSidebar.value ? SIDEBAR_DOCUMENT_SHIFT * 2 : 0) + 'px',
transition: 'padding 0.2s ease',
}));
const pageWidthPx = computed(() => {
const sp = currentSectionProps.value;
return twipsToPixels(sp?.pageWidth ?? 12240) * zoom.value;
});
const resolvedCommentIds = computed(() => {
const out = new Set<number>();
for (const c of comments.value) {
if (c.parentId == null && c.done) out.add(c.id);
}
return out;
});
const bookmarkOptions = computed(() => {
void stateTick.value;
const view = editorView.value;
if (!view) return [];
const seen = new Set<string>();
const options: Array<{ name: string; label?: string }> = [];
view.state.doc.descendants((node) => {
const bookmarks = node.attrs?.bookmarks as Array<{ name?: string }> | undefined;
if (!bookmarks) return true;
for (const bookmark of bookmarks) {
const name = bookmark.name;
if (!name || name.startsWith('_') || seen.has(name)) continue;
seen.add(name);
options.push({ name, label: name });
}
return true;
});
return options.sort((a, b) => a.name.localeCompare(b.name));
});
// One comment/revision ID allocator per editor instance (monotonic, no reuse),
// shared by the comment lifecycle and management composables so comment and
// tracked-change IDs never collide.
const commentIdAllocator = createCommentIdAllocator();
// Comment lifecycle: declared before useFileIO so IO can call extractCommentsAndChanges.
const {
floatingCommentBtn,
pendingCommentRange,
addCommentYPosition,
sidebarAutoOpenedRef,
extractCommentsAndChanges,
handleAddComment,
handleCancelAddComment,
handleStartAddComment,
handleMarkerClick,
} = useCommentLifecycle({
editorView,
getDocument,
comments,
trackedChanges,
resolvedCommentIds,
activeSidebarItem,
showSidebar,
isAddingComment,
readOnly,
zoom,
stateTick,
pagesRef,
pagesViewportRef,
emit,
commentIdAllocator,
author: authorRef,
commentCallbacks,
});
const {
docxInputRef,
imageInputRef,
handleImageFileChange,
handleDocxFileChange,
handleDocumentNameChange,
downloadCurrentDocument,
loadDocumentBuffer,
loadDocument,
save,
} = useFileIO({
loadBuffer,
loadParsedDocument,
getDocument,
saveBlob,
extractCommentsAndChanges,
emit,
documentName: () => props.documentName,
onDocumentNameChange: props.onDocumentNameChange,
getActiveView: () => activeFormattingView.value,
nextTick,
});
const {
hyperlinkPopupData,
handleHyperlinkSubmit,
handleHyperlinkRemove,
handleHyperlinkPopupNavigate,
handleHyperlinkPopupEdit,
handleHyperlinkPopupRemove,
} = useHyperlinkManagement({ editorView, getCommands });
const {
handleClearFormatting,
handleApplyStyle,
handleInsertPageBreak,
handleInsertSectionBreakNextPage,
handleInsertSectionBreakContinuous,
handleInsertSymbol,
applyFormatting,
setParagraphStyle,
insertBreak,
} = useFormattingActions({ editorView, activeView: activeFormattingView, getDocument });
const {
handlePageSetupApply,
handleLeftMarginChange,
handleRightMarginChange,
handleTopMarginChange,
handleBottomMarginChange,
handleIndentLeftChange,
handleIndentRightChange,
handleFirstLineIndentChange,
handleTabStopRemove,
} = usePageSetupControls({ editorView, getDocument, readOnly, stateTick, reLayout, emit });
const { showWatermark, currentWatermark, handleWatermarkApply } = useWatermarkControls({
editorView,
readOnly,
stateTick,
});
const {
handleToggleOutline,
handleOutlineNavigate,
handleToggleSidebar,
handleEditorScrollMouseDown,
} = useOutlineSidebar({
editorView,
showOutline,
showSidebar,
outlineHeadings,
activeSidebarItem,
extractCommentsAndChanges,
});
useKeyboardShortcuts({
showKeyboardShortcuts,
showFindReplace,
showHyperlink,
handleZoomKeyDown,
disableFindReplaceShortcuts: () => props.disableFindReplaceShortcuts,
});
const {
addComment,
replyToComment,
resolveComment,
proposeChange,
handleCommentReply,
handleCommentResolve,
handleCommentUnresolve,
handleCommentDelete,
handleAcceptChange, handleRejectChange,
handleAcceptChangeById, handleRejectChangeById,
handleTrackedChangeReply,
} = useCommentManagement({
editorView,
getDocument,
comments,
trackedChanges,
showSidebar,
isAddingComment,
pendingCommentRange,
contentChangeSubscribers,
extractCommentsAndChanges,
emit,
commentIdAllocator,
author: authorRef,
commentCallbacks,
});
// Composable order (TDZ-sensitive): useImageActions → usePagesPointer → useContextMenus → useSelectionSync → useDocxEditorRefApi.
const {
selectedImage,
imageInteracting,
imageToolbarContext,
handleToolbarImageWrap,
handleImageTransform,
} = useImageActions({ editorView, zoom, stateTick, getCommands });
// Table resize handlers — port of React PagedEditor.tsx column/row/right-edge
// resize. tryStartResize() runs from handlePagesMouseDown; install() wires
// global mousemove/mouseup that drives the drag and commits the PM transaction.
const tableResize = useTableResize();
let tableResizeCleanup: (() => void) | null = null;
const {
tableInsertButton,
hfEdit,
scrollPageInfo,
resolvePos,
setPmSelection,
scrollVisiblePositionIntoView,
handlePagesMouseDown,
handlePagesMouseMove,
handlePagesClick,
handlePagesDoubleClick,
handleTableInsertClick,
clearTableInsertTimer,
handleHfSave,
handleHfRemove,
} = usePagesPointer({
editorView,
pagesRef,
pagesViewportRef,
selectedImage,
imageInteracting,
hyperlinkPopupData,
readOnly,
zoom,
layout,
tableResize,
getCommands,
getDocument,
reLayout,
emit,
clearOverlay,
syncHfPMs,
getHfPmView,
setDocument,
});
const {
contextMenu,
imageContextMenu,
imageContextMenuTextActions,
handleContextMenu,
handleSelectedImageContextMenu,
handleImageWrapSelect,
handleContextMenuAction,
} = useContextMenus({
editorView,
selectedImage,
zoom,
showImageProperties,
getCommands,
clearOverlay,
setPmSelection,
resolvePos,
});
const { handleMenuAction, handleMenuTableInsert } = useMenuActions({
editorView,
getCommands,
docxInputRef,
imageInputRef,
showPageSetup,
showWatermark,
showHyperlink,
showInsertSymbol,
showKeyboardShortcuts,
handleClearFormatting,
handleInsertPageBreak,
handleInsertSectionBreakNextPage,
handleInsertSectionBreakContinuous,
handleToggleOutline,
handleToggleSidebar,
downloadCurrentDocument,
emit,
});
useDocumentLifecycle({
documentBuffer: () => props.documentBuffer,
document: () => props.document,
loadDocumentBuffer,
loadDocument,
sidebarAutoOpenedRef,
});
const getEditorViewForDecorations = () => editorView.value;
const getPagesContainerForDecorations = () => pagesRef.value;
watch(
() => props.mode,
(mode) => {
if (mode && mode !== editorMode.value) editorMode.value = mode;
}
);
watch(
() => props.showOutline,
(next) => {
showOutline.value = !!next;
}
);
function setEditorMode(mode: EditorMode) {
if (editorMode.value === mode) return;
editorMode.value = mode;
emit('mode-change', mode);
}
onMounted(() => {
tableResizeCleanup = tableResize.install();
});
onBeforeUnmount(() => {
tableResizeCleanup?.();
});
// =========================================================================
// Selection & caret overlay — useSelectionSync owns the implementation.
//
// These wrappers MUST stay as hoisted `function` declarations. The
// `useDocxEditor({ onSelectionUpdate })` call earlier in this script
// closes over `updateSelectionOverlay` by name; if these were rewritten
// as `const updateSelectionOverlay = ...`, the closure would TDZ-crash
// because `useDocxEditor` runs before `useSelectionSync` here. Function
// declarations are hoisted, so the closure resolves at call time
// (after script-setup finishes and `selectionSync` exists).
// =========================================================================
function clearOverlay() {
selectionSync.clearOverlay();
}
function updateSelectionOverlay() {
selectionSync.updateSelectionOverlay();
}
const isHfEditing = computed(() => hfEdit.value !== null);
const selectionSync = useSelectionSync({
editorView,
pagesRef,
zoom,
selectedImage,
isHfEditing,
imageInteracting,
});
// Drive the overlay through the layout gate (mirrors DecorationLayer): the
// `requestRender` in `dispatchTransaction` runs this immediately for
// selection-only moves or defers it until `onLayoutComplete` after a doc edit
// repaints, so the caret always lands on current DOM (#736). The eager call
// paints the initial caret (the editor's own `requestRender` ran before this).
const stopSelectionRender = syncCoordinator.onRender(() => updateSelectionOverlay());
updateSelectionOverlay();
onBeforeUnmount(() => {
stopSelectionRender();
clearOverlay();
});
// Ref-API assembly — single source of truth for the surface
// described by `DocxEditorRef`. `satisfies DocxEditorRef` lives
// inside `useDocxEditorRefApi` so signature drift is caught at
// composable-build time.
const { exposed } = useDocxEditorRefApi({
editorView,
layout,
pagesRef,
pagesViewportRef,
zoom,
comments,
focus,
destroy,
getDocument,
setZoom,
save,
loadDocument,
loadDocumentBuffer,
addComment,
replyToComment,
resolveComment,
proposeChange,
applyFormatting,
setParagraphStyle,
insertBreak,
scrollVisiblePositionIntoView,
contentChangeSubscribers,
selectionChangeSubscribers,
onPrint: props.onPrint,
});
defineExpose(exposed);
</script>
<style src="./DocxEditor.css"></style>