From 43fa1a3862fab904475925436196ba1aa486bc1d Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Mon, 7 Sep 2026 22:37:46 -0700 Subject: [PATCH 1/4] feat: add composable dialog customization for React and Vue --- .changeset/dialog-customization.md | 5 + docs/CUSTOMIZING.md | 78 +- docs/api/docx-editor-core/editor.api.md | 227 +++++ docs/api/docx-editor-core/index.api.md | 1 + docs/api/docx-editor-react/index.api.md | 301 ++++-- docs/api/docx-editor-vue/index.api.md | 449 ++++++--- .../site/content/guides/customize-dialogs.mdx | 200 ++++ docs/site/content/guides/meta.json | 1 + docs/site/content/meta.json | 1 + docs/site/content/react/composition.mdx | 5 + docs/site/content/react/hooks.mdx | 9 + docs/site/content/react/props.mdx | 7 + docs/site/content/vue/composables.mdx | 10 + docs/site/content/vue/composition.mdx | 5 + docs/site/content/vue/props.mdx | 7 + e2e/dialog-customization.interaction.spec.ts | 74 ++ examples/shared/dialog-customization.css | 45 + examples/vite/src/DialogCustomizationDemo.tsx | 45 + examples/vite/src/main.tsx | 17 +- examples/vue/src/DialogCustomizationDemo.vue | 37 + examples/vue/src/main.ts | 5 +- .../__tests__/text-form-field-chrome.test.ts | 38 + .../text-form-field-interaction.test.ts | 92 +- .../src/editor/docx-editor-host-config.ts | 7 + packages/core/src/editor/docx-editor-types.ts | 3 + packages/core/src/editor/docx-editor.ts | 5 + packages/core/src/editor/index.ts | 33 + .../src/editor/paginated-surface-options.ts | 5 + packages/core/src/editor/paginated-surface.ts | 14 +- .../src/editor/paragraph-dialog-fields.ts | 370 +++++++ .../core/src/editor/paragraph-dialog-types.ts | 93 ++ .../src/editor/surface-text-form-fields.ts | 65 +- .../core/src/editor/text-form-field-chrome.ts | 34 + .../core/src/editor/text-form-field-dialog.ts | 7 +- .../src/editor/text-form-field-session.ts | 18 + .../store/store/text-form-field-options.ts | 1 + packages/core/src/styles/editor.css | 268 +++++ .../nuxt/src/vue-composables.generated.ts | 3 + packages/react/src/components/DocxEditor.tsx | 4 + .../react/src/editor/DocxEditorContent.tsx | 2 + .../react/src/editor/DocxEditorPageSetup.tsx | 399 ++++---- .../src/editor/DocxEditorParagraphDialog.tsx | 923 +++++++++--------- packages/react/src/editor/DocxEditorRoot.tsx | 15 +- .../editor/DocxEditorTextFormFieldDialog.tsx | 257 +++++ packages/react/src/editor/dialog-host.tsx | 117 +++ packages/react/src/editor/dialog-parts.tsx | 316 ++++++ .../react/src/editor/menu/DocxEditorMenu.tsx | 24 +- .../src/editor/paragraph-dialog-fields.ts | 383 +------- .../src/editor/paragraph-dialog-host.tsx | 8 +- .../react/src/editor/useParagraphFormat.ts | 105 +- packages/react/src/index.ts | 23 + packages/react/src/types.ts | 3 + packages/react/test/context-menu.test.tsx | 52 +- .../react/test/dialog-customization.test.tsx | 264 +++++ packages/vue/src/components/DocxEditor.tsx | 5 + packages/vue/src/editor/DocxEditorContent.ts | 3 + .../vue/src/editor/DocxEditorPageSetup.tsx | 442 ++++----- .../src/editor/DocxEditorParagraphDialog.tsx | 919 ++++++++--------- packages/vue/src/editor/DocxEditorRoot.ts | 5 +- .../editor/DocxEditorTextFormFieldDialog.tsx | 255 +++++ packages/vue/src/editor/dialog-host.ts | 123 +++ packages/vue/src/editor/dialog-parts.ts | 315 ++++++ .../vue/src/editor/menu/DocxEditorMenu.tsx | 19 +- .../vue/src/editor/paragraph-dialog-fields.ts | 383 +------- .../vue/src/editor/paragraph-dialog-host.tsx | 22 +- packages/vue/src/editor/useDocxEditorRoot.ts | 1 + packages/vue/src/editor/useParagraphFormat.ts | 96 +- packages/vue/src/index.ts | 23 + packages/vue/src/types.ts | 1 + packages/vue/test/context-menu.test.ts | 38 +- .../vue/test/dialog-customization.test.ts | 114 +++ packages/vue/test/menu-composition.test.ts | 52 + packages/vue/test/paragraph-dialog.test.ts | 124 ++- scripts/parity/parity.contract.json | 1 + 74 files changed, 5791 insertions(+), 2630 deletions(-) create mode 100644 .changeset/dialog-customization.md create mode 100644 docs/site/content/guides/customize-dialogs.mdx create mode 100644 e2e/dialog-customization.interaction.spec.ts create mode 100644 examples/shared/dialog-customization.css create mode 100644 examples/vite/src/DialogCustomizationDemo.tsx create mode 100644 examples/vue/src/DialogCustomizationDemo.vue create mode 100644 packages/core/src/editor/__tests__/text-form-field-chrome.test.ts create mode 100644 packages/core/src/editor/paragraph-dialog-fields.ts create mode 100644 packages/core/src/editor/paragraph-dialog-types.ts create mode 100644 packages/core/src/editor/text-form-field-chrome.ts create mode 100644 packages/core/src/editor/text-form-field-session.ts create mode 100644 packages/react/src/editor/DocxEditorTextFormFieldDialog.tsx create mode 100644 packages/react/src/editor/dialog-host.tsx create mode 100644 packages/react/src/editor/dialog-parts.tsx create mode 100644 packages/react/test/dialog-customization.test.tsx create mode 100644 packages/vue/src/editor/DocxEditorTextFormFieldDialog.tsx create mode 100644 packages/vue/src/editor/dialog-host.ts create mode 100644 packages/vue/src/editor/dialog-parts.ts create mode 100644 packages/vue/test/dialog-customization.test.ts diff --git a/.changeset/dialog-customization.md b/.changeset/dialog-customization.md new file mode 100644 index 000000000..9bb055e0d --- /dev/null +++ b/.changeset/dialog-customization.md @@ -0,0 +1,5 @@ +--- +'@docx-editor.dev/react': minor +--- + +Add customizable Field Options, Page Setup, and Paragraph Options dialogs with equivalent React and Vue controls and theme hooks. Fixes #771. diff --git a/docs/CUSTOMIZING.md b/docs/CUSTOMIZING.md index 812f41db0..586f9d74f 100644 --- a/docs/CUSTOMIZING.md +++ b/docs/CUSTOMIZING.md @@ -10,10 +10,10 @@ if you find yourself at the bottom of this page, open an issue rather than livin ## 1. Props on the parts -Every packaged control is a compound with the same contract: render it with no children and -you get the default arrangement; a child that names one of its members **replaces that member -in place**; `hidden` removes it; `preset={false}` starts from nothing; and there is a part for -adding something the library does not model. +Packaged compounds expose named parts. Render a compound without children to use +its default arrangement. A named child replaces the corresponding part in place. +Use `hidden` to remove a part and `preset={false}` to supply your own arrangement. +The available parts depend on the component. ```tsx @@ -27,11 +27,14 @@ adding something the library does not model. The same shape applies to `DocxEditor.Menu`, `DocxEditor.ContextMenu` and `DocxEditor.Navigation`. +Page Setup, Paragraph Options, and legacy text Field Options also expose named +parts. Use the editor's `dialogs` configuration for automatically opened instances. +See [Customize dialogs](site/content/guides/customize-dialogs.mdx) for React and Vue examples. + ### Prefer your own classes over styling ours -Every compound exposes its internals as statics, and every part takes a `className`. So -instead of writing CSS against our class names, **compose the parts and hang your own class -on each one**: +Use the documented part statics and their `className` props to attach your own +classes. For example: ```tsx @@ -57,20 +60,25 @@ props: `toggle={{ className }}`. `menu` and `contextMenu` on `` acce **What you can pass** -| Prop | Where | Notes | -| --- | --- | --- | -| `icon` | toolbar parts, menu rows, menu triggers, colour splits, context-menu rows | Any `ReactNode`. ~18px inline SVG matches the packaged controls | -| `t` | any compound | Your i18n resolver. Without it the raw keys render, never English | -| `preset={false}` | any compound | Renders your children verbatim, in your order | -| `hidden` | any packaged part | Removes it from the default arrangement | -| `className` | every part | Appended after the load-bearing classes | -| `label`, `onSelect`, `disabled`, `disabledReason` | `Toolbar.Action`, `ContextMenu.Item`, `Menu.Row` | Host-owned actions | +| Prop | Where | Notes | +| ------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `icon` | toolbar parts, menu rows, menu triggers, colour splits, context-menu rows | Any `ReactNode`. ~18px inline SVG matches the packaged controls | +| `t` | any compound | Your i18n resolver. Without it the raw keys render, never English | +| `preset={false}` | any compound | Renders your children verbatim, in your order | +| `hidden` | any packaged part | Removes it from the default arrangement | +| `className` | every part | Appended after the load-bearing classes | +| `label`, `onSelect`, `disabled`, `disabledReason` | `Toolbar.Action`, `ContextMenu.Item`, `Menu.Row` | Host-owned actions | **Host actions still ask the engine.** A control the registry does not describe has no enabled state of its own — but you can borrow the engine's: ```tsx -const { isEnabled, disabledReason } = useEditorCommand({ type: 'setMarkAttr', mark: 'highlight', attr: 'val', value: 'cyan' }); +const { isEnabled, disabledReason } = useEditorCommand({ + type: 'setMarkAttr', + mark: 'highlight', + attr: 'val', + value: 'cyan', +}); ``` `useEditorCommand` takes a `ChromeSlotId` **or** a raw `EditorCommand`, so your own action @@ -99,23 +107,23 @@ pane alone, and nothing else in the app changes. ### The palette -| Token | Paints | -| --- | --- | -| `--doc-surface` | Panels, menus, dropdowns, cards | -| `--doc-card` | Comment and suggestion cards | -| `--doc-bg` | The workspace behind the page | -| `--doc-bg-subtle`, `--doc-bg-input` | Section backgrounds, input fields | -| `--doc-bg-hover` | Hover states — **and** the navigation toggle's resting plate | -| `--doc-primary`, `--doc-primary-hover`, `--doc-primary-light` | Accent, selected states | -| `--doc-accent`, `--doc-accent-bg` | Secondary accent | -| `--doc-on-primary` | Text on an accent fill | -| `--doc-text`, `--doc-text-muted`, `--doc-text-subtle`, `--doc-text-placeholder` | Text ramp. The rulers draw their ticks in the last two | -| `--doc-border`, `--doc-border-light`, `--doc-border-dark`, `--doc-border-input` | Rules and outlines | -| `--doc-link` | Hyperlinks in chrome | -| `--doc-error`, `--doc-success`, `--doc-warning` (+ `-bg`) | Status | -| `--doc-focus-ring`, `--doc-selection` | Focus and selection | -| `--doc-shadow`, `--doc-shadow-strong`, `--doc-shadow-subtle`, `--doc-shadow-lg` | Elevation | -| `--doc-overlay` | Modal backdrops | +| Token | Paints | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `--doc-surface` | Panels, menus, dropdowns, cards | +| `--doc-card` | Comment and suggestion cards | +| `--doc-bg` | The workspace behind the page | +| `--doc-bg-subtle`, `--doc-bg-input` | Section backgrounds, input fields | +| `--doc-bg-hover` | Hover states — **and** the navigation toggle's resting plate | +| `--doc-primary`, `--doc-primary-hover`, `--doc-primary-light` | Accent, selected states | +| `--doc-accent`, `--doc-accent-bg` | Secondary accent | +| `--doc-on-primary` | Text on an accent fill | +| `--doc-text`, `--doc-text-muted`, `--doc-text-subtle`, `--doc-text-placeholder` | Text ramp. The rulers draw their ticks in the last two | +| `--doc-border`, `--doc-border-light`, `--doc-border-dark`, `--doc-border-input` | Rules and outlines | +| `--doc-link` | Hyperlinks in chrome | +| `--doc-error`, `--doc-success`, `--doc-warning` (+ `-bg`) | Status | +| `--doc-focus-ring`, `--doc-selection` | Focus and selection | +| `--doc-shadow`, `--doc-shadow-strong`, `--doc-shadow-subtle`, `--doc-shadow-lg` | Elevation | +| `--doc-overlay` | Modal backdrops | Dark mode is the same list re-declared under `.docx-editor.dark`. @@ -129,7 +137,7 @@ Dark mode is the same list re-declared under `.docx-editor.dark`. ### What is deliberately not themeable **The document canvas.** Painter output stays Word-faithful — a page that matched your brand -would be a lie about what the file contains. Theme the space *around* the page instead; Igloo +would be a lie about what the file contains. Theme the space _around_ the page instead; Igloo puts the page on an iceberg rather than tinting it. --- @@ -166,7 +174,7 @@ Both cost real debugging time in Igloo, and both are ordinary CSS a host would w **`backdrop-filter` captures `position: fixed` children.** An element with `backdrop-filter` (or `filter`, or `transform`) becomes the containing block for every fixed descendant. A frosted header containing the menu bar makes the Page Setup dialog's -`inset: 0` overlay resolve against the *header*, so the dialog centres inside a 120px strip. +`inset: 0` overlay resolve against the _header_, so the dialog centres inside a 120px strip. Put the effect on a `::before` pseudo-element instead. **`z-index` traps popovers.** A `z-index` on the wrapper around `Viewport` opens a stacking diff --git a/docs/api/docx-editor-core/editor.api.md b/docs/api/docx-editor-core/editor.api.md index 7621467ac..4aeee6fdb 100644 --- a/docs/api/docx-editor-core/editor.api.md +++ b/docs/api/docx-editor-core/editor.api.md @@ -38,6 +38,11 @@ export function canExecuteImageCommand(command: Extract): ImageMutationPreconditions | null; +// @public +export function changedFields(seed: ParagraphDialogFields, current: ParagraphDialogFields, +seedMixed?: ParagraphDialogMixed, +currentMixed?: ParagraphDialogMixed): ParagraphFormatUpdate | null; + // @public export const CHROME_GROUPS: readonly [{ readonly controls: readonly [{ @@ -864,6 +869,7 @@ export interface DocxEditorInstance extends Editor { setRemoteCaretLabelHost(host: RemoteCaretLabelHost | null): void; setReviewAuthorVisible(author: string, visible: boolean): void; setRevisionStyles(styles: RevisionStyles): void; + setTextFormFieldChrome(handlers: TextFormFieldChromeHandlers): Unsubscribe; setTranslate(translate: ((key: string, params?: Record) => string) | undefined): void; showAllReviewAuthors(): void; stateVersion(): number; @@ -1093,6 +1099,9 @@ export interface FontUrlSource { readonly weight: number; } +// @public +export const formatInches: (twips: number) => string; + // @public export function formattingBarChromeGroups(image: ImageContext | null): readonly ChromeGroup[]; @@ -1270,6 +1279,9 @@ export interface ImageResourceLimits { // @public export type ImageWrapTarget = 'inline' | 'square' | 'squareLeft' | 'squareRight' | 'tight' | 'through' | 'topAndBottom' | 'behind' | 'inFront'; +// @public (undocumented) +export const inchesToTwips: (inches: number) => number; + // @public export function isFontResolver(value: unknown): value is MarkedFontResolver; @@ -1317,12 +1329,18 @@ export type MarkedFontResolver = T & Font // @public export const MAX_RESOLVER_FAMILIES = 64; +// @public +export function mixedFieldsOf(format: ParagraphFormatRead): ParagraphDialogMixed; + // @public export function mountPaginatedSurface(container: HTMLElement, bytes: Uint8Array, options?: PaginatedSurfaceOptions): OpenPaginatedResult; // @public export type NavigationCommand = 'left' | 'right' | 'up' | 'down' | 'wordLeft' | 'wordRight' | 'lineStart' | 'lineEnd' | 'documentStart' | 'documentEnd' | 'pageUp' | 'pageDown'; +// @public (undocumented) +export const NO_MIXED_FIELDS: ParagraphDialogMixed; + // @public export type OpenPaginatedResult = { readonly ok: true; @@ -1626,6 +1644,7 @@ export interface PaginatedSurfaceOptions { readonly onEquationPopover?: (activation: EquationActivation) => void; readonly onHyperlinkPopover?: (activation: HyperlinkActivation) => void; readonly onRequestHyperlink?: () => void; + readonly onRequestTextFormField?: (session: TextFormFieldDialogSession) => boolean; readonly pointer?: 'engine' | 'native'; readonly producer?: string; readonly reviewModel?: ReviewModuleContribution; @@ -1633,6 +1652,8 @@ export interface PaginatedSurfaceOptions { readonly revisionStyles?: RevisionStyles; readonly scale?: number; readonly tableInteractionLabel?: (key: 'table.insertRowBelow' | 'table.insertColumnRight') => string; + // (undocumented) + readonly textFormFieldTranslate?: (key: string) => string; readonly tocLabels?: { readonly title: string; }; @@ -1663,6 +1684,73 @@ export interface PaginatedSurfaceState { readonly selection: SemanticSelection; } +// @public +export interface ParagraphDialogFields { + // (undocumented) + alignment: 'left' | 'center' | 'right' | 'justify'; + clearedAllTabStops: boolean; + // (undocumented) + contextualSpacing: boolean; + // (undocumented) + indentLeft: number; + // (undocumented) + indentRight: number; + // (undocumented) + keepLines: boolean; + // (undocumented) + keepNext: boolean; + // (undocumented) + lineRule: 'multiple' | 'exact' | 'atLeast'; + // (undocumented) + lineValue: number; + // (undocumented) + pageBreakBefore: boolean; + // (undocumented) + spaceAfter: number; + // (undocumented) + spaceBefore: number; + // (undocumented) + special: SpecialIndent; + // (undocumented) + specialBy: number; + // (undocumented) + tabStops: readonly ParagraphTabStop[]; + // (undocumented) + widowControl: boolean; +} + +// @public +export interface ParagraphDialogMixed { + // (undocumented) + readonly alignment: boolean; + // (undocumented) + readonly contextualSpacing: boolean; + // (undocumented) + readonly indentLeft: boolean; + // (undocumented) + readonly indentRight: boolean; + // (undocumented) + readonly keepLines: boolean; + // (undocumented) + readonly keepNext: boolean; + // (undocumented) + readonly lineSpacing: boolean; + // (undocumented) + readonly pageBreakBefore: boolean; + // (undocumented) + readonly spaceAfter: boolean; + // (undocumented) + readonly spaceBefore: boolean; + // (undocumented) + readonly special: boolean; + readonly tabStops: boolean; + // (undocumented) + readonly widowControl: boolean; +} + +// @public +export type ParagraphFlagKey = 'contextualSpacing' | 'keepNext' | 'keepLines' | 'widowControl' | 'pageBreakBefore'; + // @public export interface ParagraphFlags { // (undocumented) @@ -1677,6 +1765,82 @@ export interface ParagraphFlags { readonly widowControl: boolean | null; } +// @public +export type ParagraphFlagState = boolean | null; + +// @public +export interface ParagraphFormatRead { + readonly alignment: 'left' | 'center' | 'right' | 'justify' | null; + // (undocumented) + readonly contextualSpacing: ParagraphFlagState; + readonly disagrees: { + readonly alignment: boolean; + readonly indentFirstLine: boolean; + readonly indentLeft: boolean; + readonly indentRight: boolean; + readonly lineSpacing: boolean; + readonly spaceAfterPt: boolean; + readonly spaceBeforePt: boolean; + readonly tabStops: boolean; + }; + readonly indentFirstLineTwips: number | null; + // (undocumented) + readonly indentLeftTwips: number | null; + // (undocumented) + readonly indentRightTwips: number | null; + readonly indentUnknown: boolean; + // (undocumented) + readonly keepLines: ParagraphFlagState; + // (undocumented) + readonly keepNext: ParagraphFlagState; + // (undocumented) + readonly lineSpacing: { + readonly rule: 'multiple' | 'exact' | 'atLeast'; + readonly value: number; + } | null; + // (undocumented) + readonly pageBreakBefore: ParagraphFlagState; + // (undocumented) + readonly spaceAfterPt: number | null; + // (undocumented) + readonly spaceBeforePt: number | null; + readonly tabStops: readonly ParagraphTabStop[] | null; + // (undocumented) + readonly widowControl: ParagraphFlagState; +} + +// @public +export interface ParagraphFormatUpdate { + // (undocumented) + readonly alignment?: 'left' | 'center' | 'right' | 'justify'; + // (undocumented) + readonly contextualSpacing?: boolean; + // (undocumented) + readonly indentFirstLineTwips?: number | null; + // (undocumented) + readonly indentLeftTwips?: number | null; + // (undocumented) + readonly indentRightTwips?: number | null; + // (undocumented) + readonly keepLines?: boolean; + // (undocumented) + readonly keepNext?: boolean; + // (undocumented) + readonly lineSpacing?: { + readonly rule: 'multiple' | 'exact' | 'atLeast'; + readonly value: number; + } | null; + // (undocumented) + readonly pageBreakBefore?: boolean; + // (undocumented) + readonly spaceAfterPt?: number | null; + // (undocumented) + readonly spaceBeforePt?: number | null; + readonly tabStops?: readonly ParagraphTabStop[]; + // (undocumented) + readonly widowControl?: boolean; +} + // @public export interface ParagraphPropertyEdit { readonly attributes?: Record; @@ -1897,6 +2061,9 @@ export function runTableCommand(editor: Editor | null, command: EditorCommand): export function runToolbarCommand(editor: Editor | null, id: ChromeSlotId, value?: unknown): ExecResult; +// @public +export function sameTabStops(a: readonly ParagraphTabStop[], b: readonly ParagraphTabStop[]): boolean; + // @public export function sameZoomMode(a: ZoomMode, b: ZoomMode): boolean; @@ -1945,6 +2112,9 @@ export interface SectionProperties { readonly titlePage: boolean; } +// @public +export function seedFields(format: ParagraphFormatRead): ParagraphDialogFields; + // @public export interface SelectedDrawingOverlayTarget { // (undocumented) @@ -2051,6 +2221,9 @@ export interface SemanticSelection { readonly head: SemanticPosition; } +// @public +export const signedFirstLineOf: (kind: SpecialIndent, magnitudeTwips: number) => number; + // @public export const SNAP_TWIPS_CM: number; @@ -2066,6 +2239,12 @@ export function sniffImageMime(bytes: Uint8Array): RenderableImageMime | Preserv // @public export function sourceCropFromCropPercent(crop: ImageCropPercent): SourceCrop; +// @public +export type SpecialIndent = 'none' | 'firstLine' | 'hanging'; + +// @public (undocumented) +export const specialOf: (signedTwips: number | null) => SpecialIndent; + // @public export type SupportedImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/bmp' | 'image/webp'; @@ -2214,6 +2393,18 @@ export interface SurfaceParagraphFormat { readonly widowControl?: boolean; } +// @public +export const TAB_ALIGNMENT_LABELS: { + readonly bar: "dialogs.paragraph.tabAlignBar"; + readonly center: "dialogs.paragraph.tabAlignCenter"; + readonly decimal: "dialogs.paragraph.tabAlignDecimal"; + readonly left: "dialogs.paragraph.tabAlignLeft"; + readonly right: "dialogs.paragraph.tabAlignRight"; +}; + +// @public (undocumented) +export type TabAlignment = 'left' | 'center' | 'right' | 'decimal' | 'bar'; + // @public export const TABLE_BORDER_STYLE_OPTIONS: readonly TableBorderStyleOption[]; @@ -2226,6 +2417,9 @@ export const TABLE_BORDER_WIDTH_OPTIONS: readonly TableBorderWidthOption[]; // @public export const TABLE_CHROME_SLOT_IDS: readonly TableChromeSlotId[]; +// @public (undocumented) +export type TabLeaderName = 'none' | 'dot' | 'hyphen' | 'underscore'; + // @public export interface TableBorderStyleOption { // (undocumented) @@ -2298,6 +2492,30 @@ export function tableCommandToolbarState(surface: PaginatedSurface | null, comma // @public export type TableInteractionLabelKey = 'table.insertRowBelow' | 'table.insertColumnRight'; +// @public +export const TEXT_FORM_FORMATS: { + readonly date: readonly ["", "M/d/yyyy", "MM/dd/yyyy", "d/M/yyyy", "dd/MM/yyyy", "yyyy-MM-dd", "d MMMM yyyy", "MMMM d, yyyy"]; + readonly number: readonly ["", "0", "0.00", "#,##0", "#,##0.00", "0%", "0.00%"]; + readonly regular: readonly ["", "Uppercase", "Lowercase", "First capital", "Title case"]; +}; + +// @public +export interface TextFormFieldChromeHandlers { + // (undocumented) + readonly onRequest?: (session: TextFormFieldDialogSession) => void; +} + +// @public +export interface TextFormFieldDialogSession { + apply(text: string, options: TextFormFieldOptions): boolean; + canApply(): boolean; + cancel(): void; + // (undocumented) + readonly field: TextFormFieldRange; + // (undocumented) + readonly signal: AbortSignal; +} + // @public export interface TextMeasurer { lineMetrics(style: ResolvedRunStyle): { @@ -2333,6 +2551,9 @@ export type TrackedChangeFilterMode = 'accept' | 'reject'; // @public export type TrackedChangePredicate = (revision: ReviewRevisionItem) => boolean; +// @public +export function trapTabWithin(panel: HTMLElement, event: KeyboardEvent): boolean; + // @public export interface TreeApplyResult { // (undocumented) @@ -2442,6 +2663,9 @@ export const TWIPS_PER_CM = 567; // @public export const TWIPS_PER_INCH = 1440; +// @public (undocumented) +export const twipsToInches: (twips: number) => number; + // @public export function validateDrawingPositionInput(position: DrawingPositionInput): boolean; @@ -2465,6 +2689,9 @@ export function validateThemeModifier(value: unknown): value is number; // @public export type VectorImageMime = 'image/svg+xml'; +// @public +export function withTabStop(stops: readonly ParagraphTabStop[], stop: ParagraphTabStop): readonly ParagraphTabStop[]; + // @public export const WORD_DEFAULT_FONT: FontConfiguration['defaultFont']; diff --git a/docs/api/docx-editor-core/index.api.md b/docs/api/docx-editor-core/index.api.md index f331f1072..2b0773785 100644 --- a/docs/api/docx-editor-core/index.api.md +++ b/docs/api/docx-editor-core/index.api.md @@ -1082,6 +1082,7 @@ export interface DocxEditorInstance extends Editor { setRemoteCaretLabelHost(host: RemoteCaretLabelHost | null): void; setReviewAuthorVisible(author: string, visible: boolean): void; setRevisionStyles(styles: RevisionStyles): void; + setTextFormFieldChrome(handlers: TextFormFieldChromeHandlers): Unsubscribe; setTranslate(translate: ((key: string, params?: Record) => string) | undefined): void; showAllReviewAuthors(): void; stateVersion(): number; diff --git a/docs/api/docx-editor-react/index.api.md b/docs/api/docx-editor-react/index.api.md index 2af7f7e35..b884ef89c 100644 --- a/docs/api/docx-editor-react/index.api.md +++ b/docs/api/docx-editor-react/index.api.md @@ -68,6 +68,12 @@ import { MAX_RESOLVER_FAMILIES } from '@docx-editor.dev/core/editor'; import { NavigationCommand } from '@docx-editor.dev/core/editor'; import { PageSetup } from '@docx-editor.dev/core/contracts/editor'; import { PaginatedSurfaceState } from '@docx-editor.dev/core/editor'; +import { ParagraphDialogFields } from '@docx-editor.dev/core/editor'; +import { ParagraphDialogMixed } from '@docx-editor.dev/core/editor'; +import { ParagraphFlagState } from '@docx-editor.dev/core/editor'; +import { ParagraphFormatRead } from '@docx-editor.dev/core/editor'; +import { ParagraphFormatUpdate } from '@docx-editor.dev/core/editor'; +import { ParagraphTabStop } from '@docx-editor.dev/core/editor'; import { PX_PER_CM } from '@docx-editor.dev/core/editor'; import { PX_PER_INCH } from '@docx-editor.dev/core/editor'; import * as react from 'react'; @@ -90,6 +96,7 @@ import { SupportedImageMime } from '@docx-editor.dev/core/editor'; import { SurfaceFormatting } from '@docx-editor.dev/core/editor'; import { SurfaceHyperlink } from '@docx-editor.dev/core/editor'; import { TableChromeSlotId } from '@docx-editor.dev/core/editor'; +import { TextFormFieldDialogSession } from '@docx-editor.dev/core/editor'; import { TextMatch } from '@docx-editor.dev/core/contracts/editor'; import { TextMeasurer } from '@docx-editor.dev/core/editor'; import { TFunction } from '@docx-editor.dev/i18n'; @@ -347,6 +354,31 @@ export { createFontSource } export { defineFontResolver } +// @public +export interface DialogCustomizationProps { + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + preset?: boolean; + // (undocumented) + style?: CSSProperties; +} + +// @public +export interface DialogPartProps { + // (undocumented) + asChild?: boolean; + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + hidden?: boolean; + // (undocumented) + style?: CSSProperties; +} + // @public @deprecated (undocumented) export function DocumentName(input: DocumentNameProps): react__default.JSX.Element; @@ -457,6 +489,16 @@ export interface DocxEditorContextMenuProps { t?: ToolbarTranslate; } +// @public +export interface DocxEditorDialogs { + // (undocumented) + pageSetup?: (props: DocxEditorPageSetupDialogProps) => DocxEditorChildren | null; + // (undocumented) + paragraph?: (props: DocxEditorParagraphDialogProps) => DocxEditorChildren | null; + // (undocumented) + textFormField?: (props: DocxEditorTextFormFieldDialogProps) => DocxEditorChildren | null; +} + // @public export function DocxEditorDocumentOutline(props: DocxEditorDocumentOutlineProps): ReactElement | null; @@ -635,9 +677,11 @@ export interface DocxEditorNamespace extends ForwardRefExoticComponent string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Body: (props: DialogPartProps & { + name?: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Cancel: (props: DialogPartProps & { + name?: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Error: (props: DialogPartProps & { + name?: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Field: (props: DialogPartProps & { + name: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop"; + }) => react.ReactNode; + Footer: (props: DialogPartProps & { + name?: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Header: (props: DialogPartProps & { + name?: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Title: (props: DialogPartProps & { + name?: "scope" | "orientation" | "pageSize" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; +}; // @public -export interface DocxEditorPageSetupDialogProps { - // (undocumented) - className?: string; +export interface DocxEditorPageSetupDialogProps extends DialogCustomizationProps { onClose: () => void; open: boolean; } // @public -export function DocxEditorParagraphDialog(input: DocxEditorParagraphDialogProps): ReactElement | null; +export const DocxEditorParagraphDialog: typeof ParagraphDialogRoot & { + Apply: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Body: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Cancel: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Error: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Field: (props: DialogPartProps & { + name: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue"; + }) => react.ReactNode; + Footer: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Header: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Title: (props: DialogPartProps & { + name?: "alignment" | "keepNext" | "keepLines" | "pageBreakBefore" | "widowControl" | "contextualSpacing" | "lineRule" | "tabStops" | "special" | "spaceBefore" | "spaceAfter" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; +}; // @public -export interface DocxEditorParagraphDialogProps { - // (undocumented) - className?: string; +export interface DocxEditorParagraphDialogProps extends DialogCustomizationProps { onClose: () => void; open: boolean; } @@ -732,6 +822,7 @@ export interface DocxEditorProps { readonly colorMode?: 'light' | 'dark' | 'system'; contextMenu?: boolean | DocxEditorContextMenuProps; dateInputOrder?: 'mdy' | 'dmy'; + dialogs?: DocxEditorDialogs; document?: DocumentSource; fonts?: FontConfiguration | FontConfigurationFragment | FontResolver; hyperlinkPopup?: boolean; @@ -792,6 +883,7 @@ export interface DocxEditorRootProps { // (undocumented) children?: DocxEditorChildren; dateInputOrder?: 'mdy' | 'dmy'; + dialogs?: DocxEditorDialogs; document?: DocumentSource; fonts?: FontConfiguration | FontConfigurationFragment | FontResolver; imageDecodePort?: ImageDecodePort; @@ -853,6 +945,40 @@ export function DocxEditorShell(input: { verticalRulerProps: VerticalRulerProps$1; }): react.JSX.Element; +// @public +export const DocxEditorTextFormFieldDialog: typeof TextFormFieldDialogRoot & { + Apply: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Body: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Cancel: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Error: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Field: (props: DialogPartProps & { + name: keyof TextFormFieldDialogFields; + }) => react.ReactNode; + Footer: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Header: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Title: (props: DialogPartProps & { + name?: keyof TextFormFieldDialogFields | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; +}; + +// @public +export interface DocxEditorTextFormFieldDialogProps extends DialogCustomizationProps { + // (undocumented) + session: TextFormFieldDialogSession | null; +} + // @public export const DocxEditorToolbar: DocxEditorToolbarNamespace; @@ -1530,6 +1656,26 @@ export const PageNumberTranslationContext: react.Context<((key: string) => strin export { PageSetup } +// @public +export interface PageSetupDialogFields { + // (undocumented) + marginBottom: number; + // (undocumented) + marginLeft: number; + // (undocumented) + marginRight: number; + // (undocumented) + marginTop: number; + // (undocumented) + orientation: 'portrait' | 'landscape'; + // (undocumented) + pageHeight: number; + // (undocumented) + pageWidth: number; + // (undocumented) + scope: 'document' | 'section'; +} + // @public export interface PageSetupUpdate { // (undocumented) @@ -1624,81 +1770,11 @@ export interface PaginatedDocxEditorShellProps { readonly source: Uint8Array; } -// @public -export type ParagraphFlagState = boolean | null; +export { ParagraphFlagState } -// @public -export interface ParagraphFormatRead { - readonly alignment: 'left' | 'center' | 'right' | 'justify' | null; - // (undocumented) - readonly contextualSpacing: ParagraphFlagState; - readonly disagrees: { - readonly alignment: boolean; - readonly indentFirstLine: boolean; - readonly indentLeft: boolean; - readonly indentRight: boolean; - readonly lineSpacing: boolean; - readonly spaceAfterPt: boolean; - readonly spaceBeforePt: boolean; - readonly tabStops: boolean; - }; - readonly indentFirstLineTwips: number | null; - // (undocumented) - readonly indentLeftTwips: number | null; - // (undocumented) - readonly indentRightTwips: number | null; - readonly indentUnknown: boolean; - // (undocumented) - readonly keepLines: ParagraphFlagState; - // (undocumented) - readonly keepNext: ParagraphFlagState; - // (undocumented) - readonly lineSpacing: { - readonly rule: 'multiple' | 'exact' | 'atLeast'; - readonly value: number; - } | null; - // (undocumented) - readonly pageBreakBefore: ParagraphFlagState; - // (undocumented) - readonly spaceAfterPt: number | null; - // (undocumented) - readonly spaceBeforePt: number | null; - readonly tabStops: readonly ParagraphTabStop[] | null; - // (undocumented) - readonly widowControl: ParagraphFlagState; -} +export { ParagraphFormatRead } -// @public -export interface ParagraphFormatUpdate { - // (undocumented) - readonly alignment?: 'left' | 'center' | 'right' | 'justify'; - // (undocumented) - readonly contextualSpacing?: boolean; - // (undocumented) - readonly indentFirstLineTwips?: number | null; - // (undocumented) - readonly indentLeftTwips?: number | null; - // (undocumented) - readonly indentRightTwips?: number | null; - // (undocumented) - readonly keepLines?: boolean; - // (undocumented) - readonly keepNext?: boolean; - // (undocumented) - readonly lineSpacing?: { - readonly rule: 'multiple' | 'exact' | 'atLeast'; - readonly value: number; - } | null; - // (undocumented) - readonly pageBreakBefore?: boolean; - // (undocumented) - readonly spaceAfterPt?: number | null; - // (undocumented) - readonly spaceBeforePt?: number | null; - readonly tabStops?: readonly ParagraphTabStop[]; - // (undocumented) - readonly widowControl?: boolean; -} +export { ParagraphFormatUpdate } // @public export interface ParagraphStyleItemProps extends ParagraphStylePartProps { @@ -1749,15 +1825,7 @@ export interface ParagraphStyleProps extends ParagraphStylePartProps { hidden?: boolean; } -// @public -export interface ParagraphTabStop { - // (undocumented) - readonly alignment: 'left' | 'center' | 'right' | 'decimal' | 'bar'; - // (undocumented) - readonly leader?: 'none' | 'dot' | 'hyphen' | 'underscore' | 'heavy' | 'middleDot'; - // (undocumented) - readonly positionTwips: number; -} +export { ParagraphTabStop } // @public export function provideDocxEditor(options: DocxEditorRootProps): ProvideDocxEditorResult; @@ -1929,6 +1997,20 @@ export interface TableChromePartProps { hidden?: boolean; } +// @public +export interface TextFormFieldDialogFields { + // (undocumented) + defaultText: string; + // (undocumented) + enabled: boolean; + // (undocumented) + format: string; + // (undocumented) + maxLength: number; + // (undocumented) + type: string; +} + // @public @deprecated (undocumented) export function TitleBar(input: TitleBarProps): react__default.JSX.Element; @@ -2158,6 +2240,22 @@ export interface UseContentControlResult { // @public export function useContextMenuTarget(): HTMLElement | null; +// @public +export interface UseDialogReturn { + // (undocumented) + apply(): void; + // (undocumented) + cancel(): void; + // (undocumented) + readonly errors: Readonly>>; + // (undocumented) + readonly isEnabled: boolean; + // (undocumented) + setValue(name: K, value: Fields[K]): void; + // (undocumented) + readonly values: Fields; +} + // @public export function useDocumentOutline(): UseDocumentOutlineResult; @@ -2332,6 +2430,13 @@ export function useNoteScopeState(): Extract { +} + // @public export interface UsePageSetupReturn { readonly apply: (update: PageSetupUpdate) => boolean; @@ -2339,6 +2444,15 @@ export interface UsePageSetupReturn { readonly pageSetup: PageSetup | null; } +// @public +export function useParagraphDialog(): UseParagraphDialogReturn; + +// @public +export interface UseParagraphDialogReturn extends UseDialogReturn { + // (undocumented) + readonly mixed: ParagraphDialogMixed; +} + // @public export function useParagraphFormat(): UseParagraphFormatReturn; @@ -2385,6 +2499,13 @@ export function useScopedChromeAnchor(findAnchor: (viewport: HTMLElement) => HTM // @public export function useTableBorderTargetLabel(): string; +// @public +export function useTextFormFieldDialog(): UseTextFormFieldDialogReturn; + +// @public +export interface UseTextFormFieldDialogReturn extends UseDialogReturn { +} + // @public export function useToolbarContext(): ToolbarContextValue; diff --git a/docs/api/docx-editor-vue/index.api.md b/docs/api/docx-editor-vue/index.api.md index aa64b2fd1..179853719 100644 --- a/docs/api/docx-editor-vue/index.api.md +++ b/docs/api/docx-editor-vue/index.api.md @@ -72,6 +72,12 @@ import { MaybeRefOrGetter as MaybeRefOrGetter_2 } from 'vue'; import { NavigationCommand } from '@docx-editor.dev/core/editor'; import { PageSetup } from '@docx-editor.dev/core/contracts/editor'; import { PaginatedSurfaceState } from '@docx-editor.dev/core/editor'; +import { ParagraphDialogFields } from '@docx-editor.dev/core/editor'; +import { ParagraphDialogMixed } from '@docx-editor.dev/core/editor'; +import { ParagraphFlagState } from '@docx-editor.dev/core/editor'; +import { ParagraphFormatRead } from '@docx-editor.dev/core/editor'; +import { ParagraphFormatUpdate } from '@docx-editor.dev/core/editor'; +import { ParagraphTabStop } from '@docx-editor.dev/core/editor'; import { PropType } from 'vue'; import { PX_PER_CM } from '@docx-editor.dev/core/editor'; import { PX_PER_INCH } from '@docx-editor.dev/core/editor'; @@ -91,6 +97,7 @@ import { SupportedImageMime } from '@docx-editor.dev/core/editor'; import { SurfaceFormatting } from '@docx-editor.dev/core/editor'; import { SurfaceHyperlink } from '@docx-editor.dev/core/editor'; import { TableChromeSlotId } from '@docx-editor.dev/core/editor'; +import { TextFormFieldDialogSession } from '@docx-editor.dev/core/editor'; import { TextMatch } from '@docx-editor.dev/core/contracts/editor'; import { TextMeasurer } from '@docx-editor.dev/core/editor'; import { TFunction } from '@docx-editor.dev/i18n'; @@ -1994,6 +2001,32 @@ export { createFontSource } export { defineFontResolver } +// @public +export interface DialogCustomizationProps { + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + preset?: boolean; + // (undocumented) + style?: CSSProperties; +} + +// @public +export interface DialogPartProps { + // (undocumented) + asChild?: boolean; + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + hidden?: boolean; + // (undocumented) + style?: CSSProperties; +} + // @public @deprecated (undocumented) export const DocumentName: vue.DefineComponent DocxEditorChildren | null; + // (undocumented) + paragraph?: (props: DocxEditorParagraphDialogProps) => DocxEditorChildren | null; + // (undocumented) + textFormField?: (props: DocxEditorTextFormFieldDialogProps) => DocxEditorChildren | null; +} + // @public (undocumented) export const DocxEditorDocumentOutline: vue.DefineComponent void>; + }; + open: { + required: true; + type: BooleanConstructor; + }; + preset: { + default: boolean; + type: BooleanConstructor; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, vue.PublicProps, { + className: string; + preset: boolean; + }, true, {}, {}, vue.GlobalComponents, vue.GlobalDirectives, string, {}, any, vue.ComponentProvideOptions, { + B: {}; + C: {}; + D: {}; + Defaults: {}; + M: {}; + P: {}; + }, Readonly void>; + }; + open: { + required: true; + type: BooleanConstructor; + }; + preset: { + default: boolean; + type: BooleanConstructor; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, { + className: string; + preset: boolean; + }>; + __isFragment?: never; + __isTeleport?: never; + __isSuspense?: never; +} & vue.ComponentOptionsBase, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly void>; - }; - open: { - required: true; + preset: { + default: boolean; type: BooleanConstructor; }; -}>> & Readonly<{}>, { + style: PropType; +}>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, { className: string; -}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + preset: boolean; +}, {}, string, {}, vue.GlobalComponents, vue.GlobalDirectives, string, vue.ComponentProvideOptions> & vue.VNodeProps & vue.AllowedComponentProps & vue.ComponentCustomProps & Record<"Title" | "Cancel" | "Apply" | "Header" | "Footer" | "Body" | "Error", vue.DefineComponent> & { + Field: vue.DefineComponent; +}; // @public (undocumented) -export interface DocxEditorPageSetupDialogProps { - // (undocumented) - className?: string; +export interface DocxEditorPageSetupDialogProps extends DialogCustomizationProps { // (undocumented) onClose: () => void; // (undocumented) @@ -2808,7 +2902,61 @@ export interface DocxEditorPageSetupDialogProps { } // @public -export const DocxEditorParagraphDialog: vue.DefineComponent void>; + }; + open: { + required: true; + type: BooleanConstructor; + }; + preset: { + default: boolean; + type: BooleanConstructor; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, vue.PublicProps, { + className: string; + preset: boolean; + }, true, {}, {}, vue.GlobalComponents, vue.GlobalDirectives, string, {}, any, vue.ComponentProvideOptions, { + B: {}; + C: {}; + D: {}; + Defaults: {}; + M: {}; + P: {}; + }, Readonly void>; + }; + open: { + required: true; + type: BooleanConstructor; + }; + preset: { + default: boolean; + type: BooleanConstructor; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, { + className: string; + preset: boolean; + }>; + __isFragment?: never; + __isTeleport?: never; + __isSuspense?: never; +} & vue.ComponentOptionsBase, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly void>; - }; - open: { - required: true; + preset: { + default: boolean; type: BooleanConstructor; }; -}>> & Readonly<{}>, { + style: PropType; +}>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, { className: string; -}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + preset: boolean; +}, {}, string, {}, vue.GlobalComponents, vue.GlobalDirectives, string, vue.ComponentProvideOptions> & vue.VNodeProps & vue.AllowedComponentProps & vue.ComponentCustomProps & Record<"Title" | "Cancel" | "Apply" | "Header" | "Footer" | "Body" | "Error", vue.DefineComponent> & { + Field: vue.DefineComponent; +}; // @public -export interface DocxEditorParagraphDialogProps { - // (undocumented) - className?: string; +export interface DocxEditorParagraphDialogProps extends DialogCustomizationProps { // (undocumented) onClose: () => void; // (undocumented) @@ -2862,6 +3005,8 @@ export interface DocxEditorProps { contextMenu?: boolean | DocxEditorContextMenuProps; dateInputOrder?: 'mdy' | 'dmy'; // (undocumented) + dialogs?: DocxEditorDialogs; + // (undocumented) document?: DocumentSource; // (undocumented) fonts?: FontConfiguration | FontConfigurationFragment | FontResolver; @@ -2919,6 +3064,7 @@ export const DocxEditorRoot: vue.DefineComponent; }; + dialogs: PropType; document: { default: undefined; type: PropType; @@ -2974,6 +3120,7 @@ export const DocxEditorRoot: vue.DefineComponent; }; + dialogs: PropType; document: { default: undefined; type: PropType; @@ -3025,7 +3172,7 @@ export const DocxEditorRoot: vue.DefineComponent string) | undefined; translate: ((key: string, params?: Record) => string) | undefined; @@ -3050,6 +3197,8 @@ export interface DocxEditorRootProps { children?: DocxEditorChildren; dateInputOrder?: 'mdy' | 'dmy'; // (undocumented) + dialogs?: DocxEditorDialogs; + // (undocumented) document?: DocumentSource; // (undocumented) fonts?: FontConfiguration | FontConfigurationFragment | FontResolver; @@ -3086,6 +3235,73 @@ export interface DocxEditorRulerProps { // @public @deprecated (undocumented) export const DocxEditorShell: DocxEditorNamespace; +// @public +export const DocxEditorTextFormFieldDialog: { + new (...args: any[]): vue.CreateComponentPublicInstanceWithMixins; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, vue.PublicProps, { + preset: boolean; + session: TextFormFieldDialogSession | null; + }, true, {}, {}, vue.GlobalComponents, vue.GlobalDirectives, string, {}, any, vue.ComponentProvideOptions, { + B: {}; + C: {}; + D: {}; + Defaults: {}; + M: {}; + P: {}; + }, Readonly; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, { + preset: boolean; + session: TextFormFieldDialogSession | null; + }>; + __isFragment?: never; + __isTeleport?: never; + __isSuspense?: never; +} & vue.ComponentOptionsBase; + }; + style: PropType; +}>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, { + preset: boolean; + session: TextFormFieldDialogSession | null; +}, {}, string, {}, vue.GlobalComponents, vue.GlobalDirectives, string, vue.ComponentProvideOptions> & vue.VNodeProps & vue.AllowedComponentProps & vue.ComponentCustomProps & Record<"Title" | "Cancel" | "Apply" | "Header" | "Footer" | "Body" | "Error", vue.DefineComponent> & { + Field: vue.DefineComponent; +}; + +// @public +export interface DocxEditorTextFormFieldDialogProps extends DialogCustomizationProps { + // (undocumented) + session: TextFormFieldDialogSession | null; +} + // @public (undocumented) export const DocxEditorToolbar: DocxEditorToolbarNamespace; @@ -4317,6 +4533,26 @@ export const PageNumberTranslationContext: InjectionKey<((key: string) => string export { PageSetup } +// @public +export interface PageSetupDialogFields { + // (undocumented) + marginBottom: number; + // (undocumented) + marginLeft: number; + // (undocumented) + marginRight: number; + // (undocumented) + marginTop: number; + // (undocumented) + orientation: 'portrait' | 'landscape'; + // (undocumented) + pageHeight: number; + // (undocumented) + pageWidth: number; + // (undocumented) + scope: 'document' | 'section'; +} + // @public (undocumented) export interface PageSetupUpdate { // (undocumented) @@ -4552,7 +4788,7 @@ export const PaginatedDocxEditorShell: vue.DefineComponent> & Readonly<{}>, { className: string; - colorMode: "light" | "dark"; + colorMode: "dark" | "light"; documentFontFamily: string; documentName: string; measurer: TextMeasurer; @@ -4595,81 +4831,11 @@ export interface PaginatedDocxEditorShellProps { readonly source: Uint8Array; } -// @public -export type ParagraphFlagState = boolean | null; +export { ParagraphFlagState } -// @public -export interface ParagraphFormatRead { - readonly alignment: 'left' | 'center' | 'right' | 'justify' | null; - // (undocumented) - readonly contextualSpacing: ParagraphFlagState; - readonly disagrees: { - readonly alignment: boolean; - readonly indentFirstLine: boolean; - readonly indentLeft: boolean; - readonly indentRight: boolean; - readonly lineSpacing: boolean; - readonly spaceAfterPt: boolean; - readonly spaceBeforePt: boolean; - readonly tabStops: boolean; - }; - readonly indentFirstLineTwips: number | null; - // (undocumented) - readonly indentLeftTwips: number | null; - // (undocumented) - readonly indentRightTwips: number | null; - readonly indentUnknown: boolean; - // (undocumented) - readonly keepLines: ParagraphFlagState; - // (undocumented) - readonly keepNext: ParagraphFlagState; - // (undocumented) - readonly lineSpacing: { - readonly rule: 'multiple' | 'exact' | 'atLeast'; - readonly value: number; - } | null; - // (undocumented) - readonly pageBreakBefore: ParagraphFlagState; - // (undocumented) - readonly spaceAfterPt: number | null; - // (undocumented) - readonly spaceBeforePt: number | null; - readonly tabStops: readonly ParagraphTabStop[] | null; - // (undocumented) - readonly widowControl: ParagraphFlagState; -} +export { ParagraphFormatRead } -// @public -export interface ParagraphFormatUpdate { - // (undocumented) - readonly alignment?: 'left' | 'center' | 'right' | 'justify'; - // (undocumented) - readonly contextualSpacing?: boolean; - // (undocumented) - readonly indentFirstLineTwips?: number | null; - // (undocumented) - readonly indentLeftTwips?: number | null; - // (undocumented) - readonly indentRightTwips?: number | null; - // (undocumented) - readonly keepLines?: boolean; - // (undocumented) - readonly keepNext?: boolean; - // (undocumented) - readonly lineSpacing?: { - readonly rule: 'multiple' | 'exact' | 'atLeast'; - readonly value: number; - } | null; - // (undocumented) - readonly pageBreakBefore?: boolean; - // (undocumented) - readonly spaceAfterPt?: number | null; - // (undocumented) - readonly spaceBeforePt?: number | null; - readonly tabStops?: readonly ParagraphTabStop[]; - // (undocumented) - readonly widowControl?: boolean; -} +export { ParagraphFormatUpdate } // @public (undocumented) export interface ParagraphStyleItemProps extends ParagraphStylePartProps { @@ -4723,15 +4889,7 @@ export interface ParagraphStyleProps extends ParagraphStylePartProps { hidden?: boolean; } -// @public -export interface ParagraphTabStop { - // (undocumented) - readonly alignment: 'left' | 'center' | 'right' | 'decimal' | 'bar'; - // (undocumented) - readonly leader?: 'none' | 'dot' | 'hyphen' | 'underscore' | 'heavy' | 'middleDot'; - // (undocumented) - readonly positionTwips: number; -} +export { ParagraphTabStop } // @public export function provideDocxEditor(options: DocxEditorRootProps): ProvideDocxEditorResult; @@ -4944,6 +5102,20 @@ export interface TableChromePartProps { hidden?: boolean; } +// @public +export interface TextFormFieldDialogFields { + // (undocumented) + defaultText: string; + // (undocumented) + enabled: boolean; + // (undocumented) + format: string; + // (undocumented) + maxLength: number; + // (undocumented) + type: string; +} + // @public @deprecated (undocumented) export const TitleBar: vue.DefineComponent<{}, () => vue.VNode { + // (undocumented) + apply(): void; + // (undocumented) + cancel(): void; + // (undocumented) + readonly errors: Readonly>>>>; + // (undocumented) + readonly isEnabled: Readonly>; + // (undocumented) + setValue(name: K, value: T[K]): void; + // (undocumented) + readonly values: Readonly>; +} + // @public (undocumented) export function useDocumentOutline(): UseDocumentOutlineResult; @@ -5589,6 +5777,13 @@ export function useNoteScopeState(): ShallowRef { +} + // @public (undocumented) export interface UsePageSetupReturn { // (undocumented) @@ -5599,6 +5794,15 @@ export interface UsePageSetupReturn { readonly pageSetup: ComputedRef; } +// @public +export function useParagraphDialog(): UseParagraphDialogReturn; + +// @public +export interface UseParagraphDialogReturn extends UseDialogReturn { + // (undocumented) + readonly mixed: Readonly>; +} + // @public export function useParagraphFormat(): UseParagraphFormatReturn; @@ -5655,6 +5859,13 @@ export function useScopedChromeAnchor(findAnchor: (viewport: HTMLElement) => HTM // @public (undocumented) export function useTableBorderTargetLabel(): ComputedRef; +// @public +export function useTextFormFieldDialog(): UseTextFormFieldDialogReturn; + +// @public +export interface UseTextFormFieldDialogReturn extends UseDialogReturn { +} + // @public (undocumented) export function useToolbarContext(): ComputedRef; diff --git a/docs/site/content/guides/customize-dialogs.mdx b/docs/site/content/guides/customize-dialogs.mdx new file mode 100644 index 000000000..a64d39946 --- /dev/null +++ b/docs/site/content/guides/customize-dialogs.mdx @@ -0,0 +1,200 @@ +--- +title: 'Customize dialogs' +description: 'Style parts, replace controls, and arrange dialogs in React and Vue.' +category: 'Guides' +--- + +Customize Page Setup, Paragraph Options, and legacy text Field Options through +named parts. The library retains draft state, validation, commands, and the +native modal shell. + +| React component | Vue component | +| -------------------------------- | ------------------------------- | +| `DocxEditor.PageSetupDialog` | `DocxEditorPageSetupDialog` | +| `DocxEditor.ParagraphDialog` | `DocxEditorParagraphDialog` | +| `DocxEditor.TextFormFieldDialog` | `DocxEditorTextFormFieldDialog` | + +Each exposes `Header`, `Title`, `Body`, `Footer`, `Apply`, `Cancel`, `Error`, and +`Field`. Parts accept `className`, `style`, `hidden`, and `asChild`. + +Run the [React example](https://github.com/eigenpal/docx-editor/tree/main/examples/vite) +or [Vue example](https://github.com/eigenpal/docx-editor/tree/main/examples/vue) +with `?dialogs=1` to try two independently themed editors. + +## Replace a button + +Pass `dialogs` to the editor or `Root` to customize automatically opened dialogs. +Its optional callbacks are `pageSetup`, `paragraph`, and `textFormField`. +Omitted callbacks use the default dialog. An existing `onPageSetup` callback +retains control of Page Setup opening. + +Forward the callback props to the dialog. Page Setup and Paragraph Options +receive `open` and `onClose`. Field Options receives an editor-owned `session`. + +These examples replace **Apply** while keeping its command wiring: + + + + +```tsx +import { DocxEditor } from '@docx-editor.dev/react'; +import type { DocxEditorDialogs } from '@docx-editor.dev/react'; +import '@docx-editor.dev/core/styles/editor.css'; + +const dialogs: DocxEditorDialogs = { + pageSetup: (props) => ( + + + + + + ), +}; + +export function Editor({ document }: { document: ArrayBuffer }) { + return ; +} +``` + + + + +```vue + + + +``` + + + + +Custom button components must forward refs, attributes, and listeners to their +native button. Preserve the supplied handler and disabled state. + +## Arrange parts + +With `preset={true}`, named children replace matching default parts. Use `hidden` +to remove a part. Set `preset={false}` to supply the complete content arrangement. +Keep a title, error region, and accessible Apply and Cancel controls. + +For example, replace the React callback with this arrangement: + +```tsx +const Dialog = DocxEditor.PageSetupDialog; +const dialogs: DocxEditorDialogs = { + pageSetup: (props) => ( + + + + + + + + + + + + + + + ), +}; +``` + +In Vue, use the same parts through the dialog's default slot. For example, this +callback uses `h()` to arrange the equivalent layout: + +```ts +const Dialog = DocxEditorPageSetupDialog; +const dialogs: DocxEditorDialogs = { + pageSetup: (props) => + h( + Dialog, + { ...props, preset: false }, + { + default: () => [ + h(Dialog.Header, null, { default: () => h(Dialog.Title) }), + h(Dialog.Body, null, { + default: () => [ + h(Dialog.Field, { name: 'orientation' }), + h(Dialog.Field, { name: 'pageSize' }), + ], + }), + h(Dialog.Footer, null, { + default: () => [h(Dialog.Error), h(Dialog.Cancel), h(Dialog.Apply)], + }), + ], + } + ), +}; +``` + +Omitted controls retain their draft values. `Field` names are typed. +Page Setup's `pageSize` part is its size selector. Paragraph's `tabStops` part +contains the complete collection control; `specialBy` appears when relevant. + +## Connect custom fields + +Call `usePageSetupDialog()`, `useParagraphDialog()`, or `useTextFormFieldDialog()` +inside a child of the corresponding dialog. Each provides `values`, +`setValue(name, value)`, `errors`, `isEnabled`, `apply()`, and `cancel()`. +Vue exposes state as refs; use `.value` in JavaScript. + +Use `setValue` for drafts and `apply` for validated writes. A refused write keeps +the dialog open. Show errors in an accessible region. Paragraph also exposes +mixed-selection state. Page Setup dimensions and margins use twips: 1,440 per inch. + +A `Field` represents the labeled row. Supply a custom labeled input as its child. +`asChild` replaces the row element; it does not translate value-change events. +For exact types, see [React hooks](/docs/2.x/react/hooks#dialog-draft-contexts) or +[Vue composables](/docs/2.x/vue/composables#dialog-draft-contexts). + +## Style and accessibility + +Import your application stylesheet after the core stylesheet. Dialogs inherit +`--doc-*` colors. Optional tokens are `--doc-dialog-font-family`, +`--doc-dialog-font-size`, `--doc-dialog-radius`, `--doc-dialog-padding`, and +`--doc-dialog-gap`. Unset tokens retain each part's default: 13 px body text, 16 px headings, +an 8 px panel radius, and 16 px × 20 px body padding. + +Use `data-docx-dialog` values `pageSetup`, `paragraph`, or `textFormField`. +`data-docx-part` uses lowercase part names; fields also expose `data-docx-field`. +For example, style the replacement button without `!important`: + +```css +.docx-editor [data-docx-dialog='pageSetup'] .app-apply { + border-radius: 999px; + padding-inline: 1.25rem; +} +``` + +Automatic dialogs mount within their editor's theme scope, outside editable +content. For external portals or Vue `Teleport`, provide the theme at the +destination. Framework context does not transfer CSS inheritance. + +The native shell contains focus, handles Escape, and restores focus to a surviving +opener without scrolling. Page Setup and Paragraph Options dismiss on outside +clicks; Field Options does not. Preserve labels, keyboard behavior, and input-method +composition when you replace controls. diff --git a/docs/site/content/guides/meta.json b/docs/site/content/guides/meta.json index b936ba6f5..a0c57220a 100644 --- a/docs/site/content/guides/meta.json +++ b/docs/site/content/guides/meta.json @@ -4,6 +4,7 @@ "loading-and-saving", "toolbar", "chrome-slots", + "customize-dialogs", "zoom", "content-controls", "headers-footers", diff --git a/docs/site/content/meta.json b/docs/site/content/meta.json index 62dbccb6b..66ba9ccca 100644 --- a/docs/site/content/meta.json +++ b/docs/site/content/meta.json @@ -33,6 +33,7 @@ "guides/loading-and-saving", "guides/toolbar", "guides/chrome-slots", + "guides/customize-dialogs", "guides/zoom", "guides/content-controls", "guides/headers-footers", diff --git a/docs/site/content/react/composition.mdx b/docs/site/content/react/composition.mdx index f03de7a00..7de1a71a6 100644 --- a/docs/site/content/react/composition.mdx +++ b/docs/site/content/react/composition.mdx @@ -428,6 +428,11 @@ const [open, setOpen] = useState(false); ``` The dialog and `usePageSetup()` use the same engine command. + +Page Setup, Paragraph Options, and legacy text Field Options expose replaceable +parts and dialog draft contexts. Pass `dialogs` to the editor or `Root` to customize +automatically opened instances. For examples, see +[Customize dialogs](/docs/2.x/guides/customize-dialogs). Use that hook to build a custom form. ## Content-control panel diff --git a/docs/site/content/react/hooks.mdx b/docs/site/content/react/hooks.mdx index e14221313..08e7768f9 100644 --- a/docs/site/content/react/hooks.mdx +++ b/docs/site/content/react/hooks.mdx @@ -337,6 +337,15 @@ These hooks provide context-free variants of the corresponding hooks. `useReviewOf`, `useReviewItem`, and `useReviewAuthor`. [`@docx-editor.dev/pro/vue`](/docs/2.x/pro) provides the Vue equivalents. +## Dialog draft contexts + +Use `usePageSetupDialog()`, `useParagraphDialog()`, or `useTextFormFieldDialog()` +inside a child of the corresponding dialog. Each exposes `values`, +`setValue(name, value)`, `errors`, `isEnabled`, `apply()`, and `cancel()`. + +Paragraph also exposes mixed-selection state. Page Setup dimensions use twips. +For examples, see [Customize dialogs](/docs/2.x/guides/customize-dialogs). + ## Next steps - [React composition](/docs/2.x/react/composition) diff --git a/docs/site/content/react/props.mdx b/docs/site/content/react/props.mdx index 709645e72..7c06559ac 100644 --- a/docs/site/content/react/props.mdx +++ b/docs/site/content/react/props.mdx @@ -241,6 +241,13 @@ The ref has seven methods: For exact signatures, see the [React API reference](/docs/2.x/api/react). +## `dialogs` + +Configure automatic dialogs on the editor or `Root` with `pageSetup`, `paragraph`, +and `textFormField` render callbacks. Forward their supplied props to the dialog. +Omitted callbacks use defaults. For examples and callback props, see +[Customize dialogs](/docs/2.x/guides/customize-dialogs). + ## Next steps - [React package overview](/docs/2.x/react) diff --git a/docs/site/content/vue/composables.mdx b/docs/site/content/vue/composables.mdx index 67d4b1734..e1fedfc1f 100644 --- a/docs/site/content/vue/composables.mdx +++ b/docs/site/content/vue/composables.mdx @@ -441,6 +441,16 @@ Install `@docx-editor.dev/pro` and import these APIs from The package also exports `DocxEditorReview`. Mount it inside `DocxEditor.Viewport`, beside `DocxEditor.Content`. +## Dialog draft contexts + +Use `usePageSetupDialog()`, `useParagraphDialog()`, or `useTextFormFieldDialog()` +inside a child of the corresponding dialog. Each exposes `values`, +`setValue(name, value)`, `errors`, `isEnabled`, `apply()`, and `cancel()`. +Vue exposes state as refs; use `.value` in JavaScript. + +Paragraph also exposes mixed-selection state. Page Setup dimensions use twips. +For examples, see [Customize dialogs](/docs/2.x/guides/customize-dialogs). + ## Next steps - [Vue composition](/docs/2.x/vue/composition): place custom controls. diff --git a/docs/site/content/vue/composition.mdx b/docs/site/content/vue/composition.mdx index 1779b4ddd..7fdf2a830 100644 --- a/docs/site/content/vue/composition.mdx +++ b/docs/site/content/vue/composition.mdx @@ -361,6 +361,11 @@ const pageSetupOpen = ref(false); Use `usePageSetup()` to build a different form. +Page Setup, Paragraph Options, and legacy text Field Options expose replaceable +parts and dialog draft contexts. Pass `dialogs` to the editor or `Root` to customize +automatically opened instances. For examples, see +[Customize dialogs](/docs/2.x/guides/customize-dialogs). + ## Content-control panel `DocxEditorContentControl` inspects the content control at the caret. diff --git a/docs/site/content/vue/props.mdx b/docs/site/content/vue/props.mdx index 597673db8..7dd45438e 100644 --- a/docs/site/content/vue/props.mdx +++ b/docs/site/content/vue/props.mdx @@ -261,6 +261,13 @@ The ref exposes seven methods: Use `exec()` for commands that must use the same validation as packaged chrome. Use `snapshot()` for a synchronous state read. +## `dialogs` + +Configure automatic dialogs on the editor or `Root` with `pageSetup`, `paragraph`, +and `textFormField` render callbacks. Forward their supplied props to the dialog. +Omitted callbacks use defaults. For examples and callback props, see +[Customize dialogs](/docs/2.x/guides/customize-dialogs). + ## Next steps - [Vue quickstart](/docs/2.x/vue) diff --git a/e2e/dialog-customization.interaction.spec.ts b/e2e/dialog-customization.interaction.spec.ts new file mode 100644 index 000000000..9211a943f --- /dev/null +++ b/e2e/dialog-customization.interaction.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; +for (const [adapter, port] of [ + ['React', 5273], + ['Vue', 5274], +] as const) { + test(`${adapter}: custom dialogs inherit their own theme and restore menu focus`, async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/?dialogs=1`); + const editors = page.locator('.dialog-demo-editors > .docx-editor'); + await expect(editors).toHaveCount(2); + for (const [index, color] of [ + [0, 'rgb(89, 69, 184)'], + [1, 'rgb(22, 115, 66)'], + ] as const) { + const editor = editors.nth(index); + const file = editor.locator('[data-menu="file"] > [role="menuitem"]'); + await file.click(); + await editor.getByRole('menuitem', { name: /Page setup/i }).click(); + const dialog = page.locator('dialog[data-docx-dialog="pageSetup"]'); + await expect(dialog).toHaveCount(1); + await expect(dialog).toBeVisible(); + const save = dialog.getByRole('button', { name: 'Save settings' }); + await expect(save).toHaveCSS('background-color', color); + await expect(save).toHaveCSS('padding-left', '18px'); + if (index === 0) await dialog.screenshot({ path: `test-results/dialog-${adapter}.png` }); + await dialog.getByLabel('Top', { exact: true }).fill('0.5'); + await save.focus(); + await page.keyboard.press('Tab'); + await expect + .poll(() => dialog.evaluate((el) => el.contains(el.ownerDocument.activeElement))) + .toBe(true); + await save.click(); + await expect(dialog).toHaveCount(0); + await expect(file).toBeFocused(); + await file.click(); + await editor.getByRole('menuitem', { name: /Page setup/i }).click(); + await expect(dialog.getByLabel('Top', { exact: true })).toHaveValue('0.5'); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(file).toBeFocused(); + } + }); +} + +for (const [adapter, port] of [ + ['React', 5273], + ['Vue', 5274], +] as const) { + test(`${adapter}: Field Options cancels without changing the selected field`, async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/?fixture=formtext-selection.docx`); + const field = page.locator('[data-field-atom="form"]').first(); + await expect(field).toBeVisible(); + const before = await field.textContent(); + await field.click({ button: 'right' }); + await page.locator('[data-slot="field.edit"]').click(); + const dialog = page.locator('dialog[data-docx-dialog="textFormField"]'); + await expect(dialog).toBeVisible(); + await dialog.locator('[data-docx-field="defaultText"] input').fill('Different field default'); + await dialog.locator('[data-docx-part="cancel"]').click(); + await expect(dialog).toHaveCount(0); + await expect(field).toHaveText(before!); + await field.click({ button: 'right' }); + await page.locator('[data-slot="field.edit"]').click(); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('[data-docx-field="defaultText"] input')).not.toHaveValue( + 'Different field default' + ); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + }); +} diff --git a/examples/shared/dialog-customization.css b/examples/shared/dialog-customization.css new file mode 100644 index 000000000..a73fb2564 --- /dev/null +++ b/examples/shared/dialog-customization.css @@ -0,0 +1,45 @@ +.dialog-demo { + padding: 20px; + font-family: system-ui, sans-serif; + height: 100%; + box-sizing: border-box; +} +.dialog-demo h1 { + font-size: 20px; + margin: 0 0 8px; +} +.dialog-demo-editors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + height: calc(100% - 80px); +} +.dialog-demo .brand-indigo { + --doc-primary: #5945b8; + --doc-primary-hover: #463494; + --doc-dialog-radius: 12px; +} +.dialog-demo .brand-green { + --doc-primary: #167342; + --doc-primary-hover: #115b34; + --doc-dialog-radius: 4px; +} +.brand-dialog-button { + border: 0; + border-radius: 6px; + background: var(--doc-primary); + color: var(--doc-on-primary); + padding: 8px 18px; + font: + 600 14px system-ui, + sans-serif; + cursor: pointer; +} +@media (max-width: 800px) { + .dialog-demo-editors { + grid-template-columns: 1fr; + } + .dialog-demo-editors > * { + min-height: 360px; + } +} diff --git a/examples/vite/src/DialogCustomizationDemo.tsx b/examples/vite/src/DialogCustomizationDemo.tsx new file mode 100644 index 000000000..819082d8a --- /dev/null +++ b/examples/vite/src/DialogCustomizationDemo.tsx @@ -0,0 +1,45 @@ +import { + DocxEditor, + DocxEditorPageSetupDialog, + DocxEditorParagraphDialog, + DocxEditorTextFormFieldDialog, +} from '@docx-editor.dev/react'; +import type { DocxEditorDialogs } from '@docx-editor.dev/react'; +import '../../shared/dialog-customization.css'; + +const dialogs: DocxEditorDialogs = { + pageSetup: (props) => ( + + + + + + ), + paragraph: (props) => ( + + + + + + ), + textFormField: (props) => ( + + + + + + ), +}; +/** Two independently themed editors using the same custom button. */ +export function DialogCustomizationDemo() { + return ( +
+

Customize dialogs

+

Open File → Page setup or Format → Paragraph in either editor.

+
+ + +
+
+ ); +} diff --git a/examples/vite/src/main.tsx b/examples/vite/src/main.tsx index c845a24d4..afeb4b55c 100644 --- a/examples/vite/src/main.tsx +++ b/examples/vite/src/main.tsx @@ -30,13 +30,16 @@ const container = document.getElementById('app'); if (container) { const root = createRoot(container); void (async () => { - const View = treeHarness - ? (await import('./test-harness/TreeSurfaceHarness.tsx')).TreeSurfaceHarness - : performanceE2E - ? (await import('./test-harness/PerformanceE2EHarness.tsx')).PerformanceE2EHarness - : tableE2E - ? (await import('./test-harness/TableEditingE2EHarness.tsx')).TableEditingE2EHarness - : (await import('./ComposedEditorDemo.tsx')).ComposedEditorDemo; + const View = + params.get('dialogs') === '1' + ? (await import('./DialogCustomizationDemo')).DialogCustomizationDemo + : treeHarness + ? (await import('./test-harness/TreeSurfaceHarness.tsx')).TreeSurfaceHarness + : performanceE2E + ? (await import('./test-harness/PerformanceE2EHarness.tsx')).PerformanceE2EHarness + : tableE2E + ? (await import('./test-harness/TableEditingE2EHarness.tsx')).TableEditingE2EHarness + : (await import('./ComposedEditorDemo.tsx')).ComposedEditorDemo; root.render(
diff --git a/examples/vue/src/DialogCustomizationDemo.vue b/examples/vue/src/DialogCustomizationDemo.vue new file mode 100644 index 000000000..c4e735198 --- /dev/null +++ b/examples/vue/src/DialogCustomizationDemo.vue @@ -0,0 +1,37 @@ + + diff --git a/examples/vue/src/main.ts b/examples/vue/src/main.ts index 95d80c4d4..3e6d7e076 100644 --- a/examples/vue/src/main.ts +++ b/examples/vue/src/main.ts @@ -9,7 +9,10 @@ const fixtureParam = params.get('fixture') ?? ''; const documentName = /^[\w.-]+\.docx$/.test(fixtureParam) ? fixtureParam : DEFAULT_DOCUMENT; void (async () => { - const ComposedEditorDemo = (await import('./ComposedEditorDemo.vue')).default; + const ComposedEditorDemo = + params.get('dialogs') === '1' + ? (await import('./DialogCustomizationDemo.vue')).default + : (await import('./ComposedEditorDemo.vue')).default; createApp({ setup() { const fixtureUrl = `${base}${documentName}`; diff --git a/packages/core/src/editor/__tests__/text-form-field-chrome.test.ts b/packages/core/src/editor/__tests__/text-form-field-chrome.test.ts new file mode 100644 index 000000000..eae305546 --- /dev/null +++ b/packages/core/src/editor/__tests__/text-form-field-chrome.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'bun:test'; +import { createTextFormFieldChrome } from '../text-form-field-chrome.ts'; +import type { TextFormFieldDialogSession } from '../text-form-field-session.ts'; + +function session(): TextFormFieldDialogSession { + const controller = new AbortController(); + return { + field: {} as TextFormFieldDialogSession['field'], + signal: controller.signal, + canApply: () => !controller.signal.aborted, + apply: () => false, + cancel: () => controller.abort(), + }; +} + +test('Field Options chrome supports reused handlers and out-of-order disposal', () => { + const chrome = createTextFormFieldChrome(); + const requests: TextFormFieldDialogSession[] = []; + const handlers = { onRequest: (request: TextFormFieldDialogSession) => requests.push(request) }; + expect(chrome.request(session())).toBe(false); + const disposeFirst = chrome.register(handlers); + const first = session(); + expect(chrome.request(first)).toBe(true); + const disposeSecond = chrome.register(handlers); + const second = session(); + expect(chrome.request(second)).toBe(true); + disposeFirst(); + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(false); + disposeFirst(); + const third = session(); + expect(chrome.request(third)).toBe(true); + disposeSecond(); + expect(second.signal.aborted).toBe(true); + expect(third.signal.aborted).toBe(true); + expect(chrome.request(session())).toBe(false); + expect(requests).toEqual([first, second, third]); +}); diff --git a/packages/core/src/editor/__tests__/text-form-field-interaction.test.ts b/packages/core/src/editor/__tests__/text-form-field-interaction.test.ts index fcf954e56..4610c17c0 100644 --- a/packages/core/src/editor/__tests__/text-form-field-interaction.test.ts +++ b/packages/core/src/editor/__tests__/text-form-field-interaction.test.ts @@ -1,3 +1,4 @@ +import type { TextFormFieldDialogSession } from '../text-form-field-session.ts'; import { applyProtectedTextFormEdit } from '../../store/store/tree-op-field-results.ts'; import { textFormFieldForEdit } from '../../store/store/text-form-fields.ts'; import type { TreeDocOp } from '@docx-editor.dev/core/store'; @@ -15,7 +16,8 @@ function setup( protectedForm = false, emptyFirst = false, separator = ' and ', - emptySecond = false + emptySecond = false, + onRequest?: (session: TextFormFieldDialogSession) => boolean ) { const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const field = (name: string) => @@ -51,6 +53,7 @@ function setup( let commits = 0; let rejectDelete = false; const interaction = createTextFormFieldInteraction({ + onRequest, dateInputOrder: () => dateInputOrder, container, pagesLayer, @@ -76,6 +79,19 @@ function setup( span, interaction, commits: () => commits, + setProtected: (value: boolean) => { + protectedForm = value; + }, + deleteFirstField: () => { + const result = applyTreeOp(part, { + op: 'deleteText', + paragraphId: paragraph.id, + start: 0, + end: 6, + }); + if (!result.ok) throw new Error(result.reason); + part = result.part; + }, pagesLayer, rejectDelete: () => { rejectDelete = true; @@ -359,3 +375,77 @@ for (const scenario of ['changed type', 'refused deletion'] as const) { } }); } + +test('host Field Options session owns accepted and refused writes without native UI', () => { + const sessions: TextFormFieldDialogSession[] = []; + const host = setup(false, false, ' and ', false, (session) => { + sessions.push(session); + return true; + }); + try { + expect(host.interaction.edit()).toBe(true); + const session = sessions[0]!; + expect(host.container.querySelector('dialog')).toBeNull(); + const options = { type: 'regular' as const, maxLength: 0, format: '', enabled: true }; + expect(session.apply('bad\nvalue', options)).toBe(false); + expect(session.signal.aborted).toBe(false); + expect(session.apply('Changed', options)).toBe(true); + expect(session.signal.aborted).toBe(true); + expect(session.canApply()).toBe(false); + expect(session.apply('Stale', options)).toBe(false); + expect(paragraphTextOf(host.part(), host.selection().head.paragraphId)).toContain('Changed'); + } finally { + host.interaction.destroy(); + host.container.remove(); + } +}); + +test('reopening and destruction invalidate retained host Field Options callbacks', () => { + const sessions: TextFormFieldDialogSession[] = []; + const host = setup(false, false, ' and ', false, (session) => { + sessions.push(session); + return true; + }); + const options = { type: 'regular' as const, maxLength: 0, format: '', enabled: true }; + try { + host.interaction.edit(); + host.interaction.edit(); + expect(sessions[0]!.signal.aborted).toBe(true); + sessions[0]!.cancel(); + expect(sessions[1]!.signal.aborted).toBe(false); + host.interaction.destroy(); + expect(sessions[1]!.signal.aborted).toBe(true); + expect(sessions[1]!.apply('Stale', options)).toBe(false); + expect(paragraphTextOf(host.part(), host.selection().head.paragraphId)).toBe( + 'Sample and Sample' + ); + } finally { + host.container.remove(); + } +}); + +test('host Field Options rechecks protection and deleted targets at apply time', () => { + const sessions: TextFormFieldDialogSession[] = []; + const host = setup(false, false, ' and ', false, (session) => { + sessions.push(session); + return true; + }); + const options = { type: 'regular' as const, maxLength: 0, format: '', enabled: true }; + try { + host.interaction.edit(); + expect(sessions[0]!.canApply()).toBe(true); + host.setProtected(true); + expect(sessions[0]!.canApply()).toBe(false); + expect(sessions[0]!.apply('Refused', options)).toBe(false); + expect(sessions[0]!.signal.aborted).toBe(false); + host.setProtected(false); + expect(sessions[0]!.canApply()).toBe(true); + host.deleteFirstField(); + expect(sessions[0]!.canApply()).toBe(false); + expect(sessions[0]!.apply('Deleted', options)).toBe(false); + expect(paragraphTextOf(host.part(), host.selection().head.paragraphId)).toBe(' and Sample'); + } finally { + host.interaction.destroy(); + host.container.remove(); + } +}); diff --git a/packages/core/src/editor/docx-editor-host-config.ts b/packages/core/src/editor/docx-editor-host-config.ts index 6f350f953..4f6e51790 100644 --- a/packages/core/src/editor/docx-editor-host-config.ts +++ b/packages/core/src/editor/docx-editor-host-config.ts @@ -37,6 +37,7 @@ function localeState(locale: string | undefined): { code: LocaleCode; labels: To /** State that construction config and later instance setters share. */ export interface DocxEditorHostConfigState { + translate(key: string): string; mode(): HostEditingMode | undefined; modeForGate(): HostEditingMode; openingModeDecision(guards: OpeningModeGuards): OpeningModeDecision; @@ -65,6 +66,12 @@ export function createDocxEditorHostConfigState(initial: { let dateInputOrder: 'mdy' | 'dmy' = initial.dateInputOrder === 'dmy' ? 'dmy' : 'mdy'; return { + translate: (key) => + translate?.(key) ?? + createT( + deepMerge(en, locales[locale.code]) as LocaleStrings, + locale.code + )(key as Parameters>[0]), mode: () => mode, modeForGate: () => mode ?? 'edit', openingModeDecision: (guards) => resolveOpeningEditingMode(mode, guards), diff --git a/packages/core/src/editor/docx-editor-types.ts b/packages/core/src/editor/docx-editor-types.ts index b1c9dcc5b..d1978ad96 100644 --- a/packages/core/src/editor/docx-editor-types.ts +++ b/packages/core/src/editor/docx-editor-types.ts @@ -1,3 +1,4 @@ +import type { TextFormFieldChromeHandlers } from './text-form-field-session.ts'; /** * Instance-level types for `createDocxEditor` — kept out of the composition root so * `docx-editor.ts` stays under the max-lines gate. Re-exported from `docx-editor.ts` @@ -194,6 +195,8 @@ export interface DocxEditorInstance extends Editor { * command needs. */ setHyperlinkChrome(handlers: HyperlinkChromeHandlers): Unsubscribe; + /** Register Field Options chrome. Disposal closes sessions owned by this registration. */ + setTextFormFieldChrome(handlers: TextFormFieldChromeHandlers): Unsubscribe; /** Wire the host equation popover to painted equation clicks. */ setEquationChrome(handlers: EquationChromeHandlers): Unsubscribe; /** diff --git a/packages/core/src/editor/docx-editor.ts b/packages/core/src/editor/docx-editor.ts index e123a4f08..97151fb6a 100644 --- a/packages/core/src/editor/docx-editor.ts +++ b/packages/core/src/editor/docx-editor.ts @@ -1,3 +1,4 @@ +import { createTextFormFieldChrome } from './text-form-field-chrome.ts'; // The `Editor` facade over the paginated surface. // // `createDocxEditor` implements the FULL `Editor` contract over the paginated surface — @@ -347,6 +348,7 @@ export function createDocxEditor(config: DocxEditorConfig): DocxEditorInstance { // quiet — and one that moved only surface state does not. See `surface-publish-signal.ts`. const publishSignal = createPublishSignal(); let remountDrawingIntent: DrawingSelectionIntent = { kind: 'none' }; + const textFormFieldChrome = createTextFormFieldChrome(); const hyperlinkChrome = createChromeHandlerStack({}); const equationChrome = createChromeHandlerStack({}); let destroyed = false; @@ -569,6 +571,8 @@ export function createDocxEditor(config: DocxEditorConfig): DocxEditorInstance { // exists (the provider-first shape), and a document that reloads must not leave the // host's chrome wired to the surface it replaced. onHyperlinkPopover: (activation) => hyperlinkChrome.current().onPopover?.(activation), + textFormFieldTranslate: (key) => hostConfig.translate(key), + onRequestTextFormField: textFormFieldChrome.request, onRequestHyperlink: () => hyperlinkChrome.current().onRequest?.(), onEquationPopover: (activation) => equationChrome.current().onPopover?.(activation), onTrackedChange: () => { @@ -1754,6 +1758,7 @@ export function createDocxEditor(config: DocxEditorConfig): DocxEditorInstance { return surface; }, + setTextFormFieldChrome: textFormFieldChrome.register, setHyperlinkChrome: hyperlinkChrome.push, setEquationChrome: equationChrome.push, diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 74851f26f..46d9d2054 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -324,3 +324,36 @@ export type { RevisionAuthorStyle, RevisionStyles, } from '../output/revision-presentation.ts'; + +export type { + TextFormFieldDialogSession, + TextFormFieldChromeHandlers, +} from './text-form-field-session.ts'; + +export type { + ParagraphFlagState, + ParagraphFormatRead, + ParagraphFormatUpdate, +} from './paragraph-dialog-types.ts'; +export { + twipsToInches, + formatInches, + inchesToTwips, + type TabAlignment, + type TabLeaderName, + TAB_ALIGNMENT_LABELS, + type SpecialIndent, + specialOf, + signedFirstLineOf, + type ParagraphDialogFields, + seedFields, + type ParagraphDialogMixed, + type ParagraphFlagKey, + NO_MIXED_FIELDS, + mixedFieldsOf, + sameTabStops, + changedFields, + withTabStop, + trapTabWithin, +} from './paragraph-dialog-fields.ts'; +export { TEXT_FORM_FORMATS } from '../store/store/text-form-field-options.ts'; diff --git a/packages/core/src/editor/paginated-surface-options.ts b/packages/core/src/editor/paginated-surface-options.ts index e6671ebfb..b48198cec 100644 --- a/packages/core/src/editor/paginated-surface-options.ts +++ b/packages/core/src/editor/paginated-surface-options.ts @@ -116,6 +116,11 @@ export interface PaginatedSurfaceOptions { * rather than doing something surprising with it. */ readonly onRequestHyperlink?: () => void; + /** Return true when host chrome handles this session. */ + readonly onRequestTextFormField?: ( + session: import('./text-form-field-session.ts').TextFormFieldDialogSession + ) => boolean; + readonly textFormFieldTranslate?: (key: string) => string; /** * Localized accessible names for core-owned table insertion furniture. * Defaults to English from `@docx-editor.dev/i18n` when omitted. diff --git a/packages/core/src/editor/paginated-surface.ts b/packages/core/src/editor/paginated-surface.ts index 0139e1535..722ea9726 100644 --- a/packages/core/src/editor/paginated-surface.ts +++ b/packages/core/src/editor/paginated-surface.ts @@ -5905,19 +5905,17 @@ export function mountPaginatedSurface( selectionSync.onCompositionStart(...args); }; - /** - * The pointer lane's handle, assigned once the surface it drives exists. - * - * Read by the selection mirror: the browser keeps reporting its own idea of the selection - * while a gesture runs, and adopting one of those mid-drag snaps the caret back to whatever - * the DOM guessed. - */ + // The selection mirror checks this handle to avoid adopting browser selection mid-drag. + // It is assigned once the surface exists. let pointer: PointerController | null = null; textFormInteraction = createTextFormFieldInteraction({ + onRequest: options.onRequestTextFormField, + translate: options.textFormFieldTranslate, dateInputOrder: () => dateInputOrder, pagesLayer, container, - part: () => partOfNodeId(session, selection.head.paragraphId) ?? session.part(), + part: (paragraphId?: string) => + partOfNodeId(session, paragraphId ?? selection.head.paragraphId) ?? session.part(), protected: (paragraphId = selection.head.paragraphId) => formsProtectionEnabled(session.settingsRoot()) && sectionProtectsForms(partOfNodeId(session, paragraphId) ?? session.part(), paragraphId), diff --git a/packages/core/src/editor/paragraph-dialog-fields.ts b/packages/core/src/editor/paragraph-dialog-fields.ts new file mode 100644 index 000000000..0ff726e18 --- /dev/null +++ b/packages/core/src/editor/paragraph-dialog-fields.ts @@ -0,0 +1,370 @@ +/** Shared draft conversion and mixed-value handling for Paragraph dialogs. */ +import type { ParagraphFormatRead, ParagraphFormatUpdate } from './paragraph-dialog-types.ts'; +import type { ParagraphTabStop } from '../contracts/types.ts'; + +/** @public */ +export const TWIPS_PER_INCH = 1440; + +/** @public */ +export const twipsToInches = (twips: number): number => + Math.round((twips / TWIPS_PER_INCH) * 100) / 100; + +/** + * Inches for DISPLAY, in the BROWSER's number format. + * + * `0.5` and `0,5` are the same measurement, and roughly half the locales this ships with + * write the second one. Interpolating a raw `Number` into a string picks the first for + * everyone. The catalogue supplies the surrounding words; this supplies the number. + * + * Not the editor's locale: nothing in the i18n layer exposes one to read, so a German + * editor in an American browser still renders `0.5`. The browser's guess beats a hardcoded + * `.` for every reader whose browser matches their language, which is most of them. + * @public + */ +export const formatInches = (twips: number): string => + twipsToInches(twips).toLocaleString(undefined, { maximumFractionDigits: 2 }); + +/** @public */ +export const inchesToTwips = (inches: number): number => Math.round(inches * TWIPS_PER_INCH); + +/** @public */ +export type TabAlignment = 'left' | 'center' | 'right' | 'decimal' | 'bar'; +/** @public */ +export type TabLeaderName = 'none' | 'dot' | 'hyphen' | 'underscore'; + +/** One label key per alignment, so the rows read as words rather than as `w:val` values. * @public + */ +export const TAB_ALIGNMENT_LABELS = { + left: 'dialogs.paragraph.tabAlignLeft', + center: 'dialogs.paragraph.tabAlignCenter', + right: 'dialogs.paragraph.tabAlignRight', + decimal: 'dialogs.paragraph.tabAlignDecimal', + // Unreachable today — the reader never yields `bar`, the dialog does not offer it and + // `classifyCommand` refuses it — but `ParagraphTabStop` admits it, so the map that types + // itself against that union has to carry it. A row with no label is worse than a spare one. + bar: 'dialogs.paragraph.tabAlignBar', +} as const satisfies Record; + +/** The "Special" pair: the signed first-line offset, split into a kind and a magnitude. * @public + */ +export type SpecialIndent = 'none' | 'firstLine' | 'hanging'; + +/** @public */ +export const specialOf = (signedTwips: number | null): SpecialIndent => { + if (signedTwips === null || signedTwips === 0) return 'none'; + return signedTwips < 0 ? 'hanging' : 'firstLine'; +}; + +/** Fold the "Special" pair back into the ONE signed value the engine takes. * @public + */ +export const signedFirstLineOf = (kind: SpecialIndent, magnitudeTwips: number): number => { + if (kind === 'none') return 0; + return kind === 'hanging' ? -Math.abs(magnitudeTwips) : Math.abs(magnitudeTwips); +}; + +/** + * Every field of the form, in the shape the controls hold it. + * + * Deliberately flat and all-defined: this is what the dialog SHOWS, and a control cannot + * show "mixed" and a number at once. The disagreement itself is remembered by comparing + * against the seed, not by keeping a null in here. + * @public + */ +export interface ParagraphDialogFields { + alignment: 'left' | 'center' | 'right' | 'justify'; + indentLeft: number; + indentRight: number; + special: SpecialIndent; + specialBy: number; + spaceBefore: number; + spaceAfter: number; + lineRule: 'multiple' | 'exact' | 'atLeast'; + lineValue: number; + contextualSpacing: boolean; + keepNext: boolean; + keepLines: boolean; + widowControl: boolean; + pageBreakBefore: boolean; + tabStops: readonly ParagraphTabStop[]; + /** The user pressed "Clear all", which is a decision even when the list already looked empty. */ + clearedAllTabStops: boolean; +} + +/** + * Open the form on the selection. + * + * A `null` field means the selection DISAGREES about that setting. There is no third + * checkbox state to show it with, so the control opens on the least surprising value and + * {@link changedFields} keeps the disagreement alive by not writing what the user did not + * touch. `widowControl` opens ON because that is the Word default a document inherits + * when nothing says otherwise. + * @public + */ +export function seedFields(format: ParagraphFormatRead): ParagraphDialogFields { + const firstLine = format.indentFirstLineTwips; + return { + alignment: format.alignment ?? 'left', + indentLeft: format.indentLeftTwips ?? 0, + indentRight: format.indentRightTwips ?? 0, + special: specialOf(firstLine), + specialBy: Math.abs(firstLine ?? 0), + spaceBefore: format.spaceBeforePt ?? 0, + spaceAfter: format.spaceAfterPt ?? 0, + lineRule: format.lineSpacing?.rule ?? 'multiple', + lineValue: format.lineSpacing?.value ?? 1.08, + contextualSpacing: format.contextualSpacing === true, + keepNext: format.keepNext === true, + keepLines: format.keepLines === true, + widowControl: format.widowControl !== false, + pageBreakBefore: format.pageBreakBefore === true, + tabStops: format.tabStops ?? [], + clearedAllTabStops: false, + }; +} + +/** + * Which settings the selection DISAGREES about, so a control can show it. + * + * The value fields need this as much as the checkboxes do. A control that renders a + * disagreement as a plausible-looking number is not just unhelpful — it makes the + * disagreement uncorrectable, because the value that would fix it is the one already on + * screen, so `changedFields` sees nothing move and writes nothing. Four paragraphs at + * mixed alignments showed "Left" and could not be set to Left. + * @public + */ +export interface ParagraphDialogMixed { + readonly contextualSpacing: boolean; + readonly keepNext: boolean; + readonly keepLines: boolean; + readonly widowControl: boolean; + readonly pageBreakBefore: boolean; + /** The selection's paragraphs carry DIFFERENT tab stops, so the list shows none of them. */ + readonly tabStops: boolean; + readonly alignment: boolean; + readonly indentLeft: boolean; + readonly indentRight: boolean; + readonly special: boolean; + readonly spaceBefore: boolean; + readonly spaceAfter: boolean; + readonly lineSpacing: boolean; +} + +/** The five members that are checkboxes, and so share a label key with their control. * @public + */ +export type ParagraphFlagKey = + | 'contextualSpacing' + | 'keepNext' + | 'keepLines' + | 'widowControl' + | 'pageBreakBefore'; + +/** @public */ +export const NO_MIXED_FIELDS: ParagraphDialogMixed = { + contextualSpacing: false, + keepNext: false, + keepLines: false, + widowControl: false, + pageBreakBefore: false, + tabStops: false, + alignment: false, + indentLeft: false, + indentRight: false, + special: false, + spaceBefore: false, + spaceAfter: false, + lineSpacing: false, +}; + +/** + * A checkbox over a setting the selection disagrees about is INDETERMINATE, not unchecked + * — unchecked would claim the paragraphs agree it is off. The read reports `null` for + * exactly this, and a control that collapses it to a boolean throws the distinction away. + * @public + */ +export function mixedFieldsOf(format: ParagraphFormatRead): ParagraphDialogMixed { + return { + contextualSpacing: format.contextualSpacing === null, + keepNext: format.keepNext === null, + keepLines: format.keepLines === null, + widowControl: format.widowControl === null, + pageBreakBefore: format.pageBreakBefore === null, + tabStops: format.disagrees.tabStops, + // Asked, not guessed. A `null` value means BOTH "the paragraphs disagree" and "nothing + // states it", and treating the second as the first told a single paragraph — the + // commonest case in a real document — that it disagreed with itself. + alignment: format.disagrees.alignment, + // `indentUnknown` is a table paragraph: the engine measures indents from the cell's + // content edge and reports none, because a ruler drawn against the page margin cannot + // place them. The control cannot show a value either, so it shows none — blank, and + // written only if the user types. It is not a disagreement, and the placeholder says so. + indentLeft: format.disagrees.indentLeft || format.indentUnknown, + indentRight: format.disagrees.indentRight || format.indentUnknown, + special: format.disagrees.indentFirstLine || format.indentUnknown, + spaceBefore: format.disagrees.spaceBeforePt, + spaceAfter: format.disagrees.spaceAfterPt, + lineSpacing: format.disagrees.lineSpacing, + }; +} + +/** Whether two stop lists say the same thing. A list needs more than reference equality. * @public + */ +export function sameTabStops( + a: readonly ParagraphTabStop[], + b: readonly ParagraphTabStop[] +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + return a.every((stop, index) => { + const other = b[index]; + return ( + other !== undefined && + stop.positionTwips === other.positionTwips && + stop.alignment === other.alignment && + (stop.leader ?? 'none') === (other.leader ?? 'none') + ); + }); +} + +/** + * The submission: ONLY the fields that moved since the dialog opened. + * + * Sending the whole form would flatten every setting the selection disagrees about. A + * mixed `keepNext` would become off on every paragraph, and a mixed left indent would + * become an explicit zero — which is worse than wrong, because a zero BLOCKS the style + * cascade where leaving the setting alone would let the style keep supplying it. An + * untouched field is not a decision, so it is not written. + * + * Returns null when nothing moved, which the caller treats as "just close": an empty + * write would still push an undo entry that restores nothing. + * @public + */ +export function changedFields( + seed: ParagraphDialogFields, + current: ParagraphDialogFields, + /** What the selection disagreed about when the dialog opened. */ + seedMixed: ParagraphDialogMixed = NO_MIXED_FIELDS, + /** What it still disagrees about now. A field that left this set was RESOLVED. */ + currentMixed: ParagraphDialogMixed = seedMixed +): ParagraphFormatUpdate | null { + const update: { + -readonly [K in keyof ParagraphFormatUpdate]: ParagraphFormatUpdate[K]; + } = {}; + let moved = false; + const take = ( + key: K, + value: ParagraphFormatUpdate[K] + ): void => { + update[key] = value; + moved = true; + }; + /** + * A setting the selection DISAGREED about, that it no longer disagrees about. + * + * Comparing values alone is not enough for these. The control opened on one of the two + * answers, so a user resolving the disagreement TO that answer — clicking a mixed box on + * and off again, which is how you say "off, for all of them" — leaves the value equal to + * the seed while the box now reads as settled. Writing nothing there would leave the + * paragraphs still disagreeing under a control claiming they agree, and making the + * selection agree is the whole job. + */ + const resolved = (key: keyof ParagraphDialogMixed): boolean => + seedMixed[key] && !currentMixed[key]; + + if (seed.alignment !== current.alignment || resolved('alignment')) + take('alignment', current.alignment); + if (seed.indentLeft !== current.indentLeft || resolved('indentLeft')) + take('indentLeftTwips', current.indentLeft); + if (seed.indentRight !== current.indentRight || resolved('indentRight')) + take('indentRightTwips', current.indentRight); + // The kind and the magnitude are two controls over ONE value, so either one moving + // rewrites it. Note that `none` and a magnitude of zero fold to the same signed zero, + // which is why the comparison is on the controls and not on the folded result. + if ( + seed.special !== current.special || + seed.specialBy !== current.specialBy || + resolved('special') + ) + take('indentFirstLineTwips', signedFirstLineOf(current.special, current.specialBy)); + if (seed.spaceBefore !== current.spaceBefore || resolved('spaceBefore')) + take('spaceBeforePt', current.spaceBefore); + if (seed.spaceAfter !== current.spaceAfter || resolved('spaceAfter')) + take('spaceAfterPt', current.spaceAfter); + // Never while the rule is still unknown. `lineSpacing` is a rule AND a value, and a value + // without its rule is meaningless: typing 16 into "At" over a mixed selection wrote + // sixteen line-heights, because the seed's `multiple` fallback supplied a unit the user + // never chose. Picking a rule clears the disagreement and unlocks the pair. + if ( + !currentMixed.lineSpacing && + (seed.lineRule !== current.lineRule || + seed.lineValue !== current.lineValue || + resolved('lineSpacing')) + ) + take('lineSpacing', { rule: current.lineRule, value: current.lineValue }); + if (seed.contextualSpacing !== current.contextualSpacing || resolved('contextualSpacing')) + take('contextualSpacing', current.contextualSpacing); + if (seed.keepNext !== current.keepNext || resolved('keepNext')) + take('keepNext', current.keepNext); + if (seed.keepLines !== current.keepLines || resolved('keepLines')) + take('keepLines', current.keepLines); + if (seed.widowControl !== current.widowControl || resolved('widowControl')) + take('widowControl', current.widowControl); + if (seed.pageBreakBefore !== current.pageBreakBefore || resolved('pageBreakBefore')) + take('pageBreakBefore', current.pageBreakBefore); + // Same rule for the tab list, with one extra condition: the list must ALSO differ from + // the seed, or a net-zero gesture writes. "Clear all" over a mixed selection is a real + // decision and the list legitimately equals the seed there — but so does "add a stop, + // change your mind, remove it", and that used to clear every selected paragraph. + // `clearedAllTabStops` is set only by the button that says so. + if ( + !sameTabStops(seed.tabStops, current.tabStops) || + (resolved('tabStops') && current.clearedAllTabStops) + ) + take('tabStops', current.tabStops); + + return moved ? update : null; +} + +/** Add one stop, replacing any stop already at that position, and keep the list sorted. * @public + */ +export function withTabStop( + stops: readonly ParagraphTabStop[], + stop: ParagraphTabStop +): readonly ParagraphTabStop[] { + const kept = stops.filter((existing) => existing.positionTwips !== stop.positionTwips); + return [...kept, stop].sort((a, b) => a.positionTwips - b.positionTwips); +} + +/** + * Keep Tab inside the dialog. + * + * `aria-modal` tells assistive tech the rest of the page is inert; it does not stop Tab, + * so without this the third Tab lands on the document behind the dialog — which is the + * editable surface, so the next keystroke types into the paragraph being formatted. + * + * Returns true when the event was handled, so a caller only has to call `preventDefault`. + * @public + */ +export function trapTabWithin(panel: HTMLElement, event: KeyboardEvent): boolean { + if (event.key !== 'Tab') return false; + const focusable = [ + ...panel.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ), + ].filter((node) => !node.hasAttribute('disabled') && node.tabIndex !== -1); + if (focusable.length === 0) return false; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + const active = panel.ownerDocument.activeElement; + // Wrap at whichever end the user is walking off, and treat "focus is on the panel + // itself" as being before the first control — that is where it sits when the dialog + // has just opened. + if (event.shiftKey && (active === first || active === panel)) { + last.focus(); + return true; + } + if (!event.shiftKey && active === last) { + first.focus(); + return true; + } + return false; +} diff --git a/packages/core/src/editor/paragraph-dialog-types.ts b/packages/core/src/editor/paragraph-dialog-types.ts new file mode 100644 index 000000000..3f3e543fb --- /dev/null +++ b/packages/core/src/editor/paragraph-dialog-types.ts @@ -0,0 +1,93 @@ +import type { ParagraphTabStop } from '../contracts/types.ts'; + +/** One tri-state paragraph flag: on, off, or "the selection disagrees". @public */ +export type ParagraphFlagState = boolean | null; + +/** + * What the Paragraph dialog reads: every field, as the selection currently stands. + * + * A `null` means the selection's paragraphs DISAGREE about that field, which a control + * shows as an indeterminate checkbox or an empty box rather than as a value. `indent` is + * the exception the engine already documents — it reports the first touched paragraph and + * flags disagreement per field, because a ruler has to draw its handles somewhere. + * + * @public + */ +export interface ParagraphFormatRead { + /** + * `justify`, not OOXML's `both`. The engine speaks `w:jc` values; an adapter speaks the + * word its consumers write. Read and write use the SAME spelling here, so a value that + * comes out of `format` can go straight back into `apply`. + */ + readonly alignment: 'left' | 'center' | 'right' | 'justify' | null; + readonly spaceBeforePt: number | null; + readonly spaceAfterPt: number | null; + readonly lineSpacing: { + readonly rule: 'multiple' | 'exact' | 'atLeast'; + readonly value: number; + } | null; + readonly indentLeftTwips: number | null; + readonly indentRightTwips: number | null; + /** ONE signed first-line offset: negative is a hanging indent. */ + readonly indentFirstLineTwips: number | null; + readonly contextualSpacing: ParagraphFlagState; + readonly keepNext: ParagraphFlagState; + readonly keepLines: ParagraphFlagState; + readonly widowControl: ParagraphFlagState; + readonly pageBreakBefore: ParagraphFlagState; + /** Custom tab stops, cascade included. Null when the selection disagrees. */ + readonly tabStops: readonly ParagraphTabStop[] | null; + /** + * Which fields are `null` because the selection DISAGREES, as opposed to because nothing + * states them. + * + * A `null` alone cannot tell those apart, and both readings shipped as bugs: a + * disagreement rendered as a concrete value is uncorrectable, because the value that + * would fix it is the one already on screen; an absent value rendered as "mixed" tells a + * single paragraph it disagrees with itself. + */ + readonly disagrees: { + readonly alignment: boolean; + readonly spaceBeforePt: boolean; + readonly spaceAfterPt: boolean; + readonly lineSpacing: boolean; + readonly tabStops: boolean; + readonly indentLeft: boolean; + readonly indentRight: boolean; + readonly indentFirstLine: boolean; + }; + /** + * Whether the indent reads are UNKNOWN rather than disagreed. + * + * The engine reports no indent at all for a paragraph inside a table — correct, but not + * placeable on a ruler. A control must not call that "mixed": one paragraph cannot + * disagree with itself, and the commonest paragraph in a real document is in a cell. + */ + readonly indentUnknown: boolean; +} + +/** + * The fields `apply` accepts. Omitted fields are left as authored; `null` where allowed + * REMOVES the setting so the style supplies it again, which is not the same as a zero. + * + * @public + */ +export interface ParagraphFormatUpdate { + readonly alignment?: 'left' | 'center' | 'right' | 'justify'; + readonly spaceBeforePt?: number | null; + readonly spaceAfterPt?: number | null; + readonly lineSpacing?: { + readonly rule: 'multiple' | 'exact' | 'atLeast'; + readonly value: number; + } | null; + readonly indentLeftTwips?: number | null; + readonly indentRightTwips?: number | null; + readonly indentFirstLineTwips?: number | null; + readonly contextualSpacing?: boolean; + readonly keepNext?: boolean; + readonly keepLines?: boolean; + readonly widowControl?: boolean; + readonly pageBreakBefore?: boolean; + /** Replace the custom tab stops. An EMPTY list clears them; omit to leave them alone. */ + readonly tabStops?: readonly ParagraphTabStop[]; +} diff --git a/packages/core/src/editor/surface-text-form-fields.ts b/packages/core/src/editor/surface-text-form-fields.ts index fae387518..0bee97060 100644 --- a/packages/core/src/editor/surface-text-form-fields.ts +++ b/packages/core/src/editor/surface-text-form-fields.ts @@ -1,3 +1,4 @@ +import type { TextFormFieldDialogSession } from './text-form-field-session.ts'; import { supportsTextFormField, formatTextFormValue, @@ -20,7 +21,9 @@ interface Host { dateInputOrder?(): 'mdy' | 'dmy'; readonly pagesLayer: HTMLElement; readonly container: HTMLElement; - part(): OoxmlPart; + part(paragraphId?: string): OoxmlPart; + onRequest?: ((session: TextFormFieldDialogSession) => boolean) | undefined; + translate?: ((key: string) => string) | undefined; protected(paragraphId?: string): boolean; selection(): SemanticSelection; select(selection: SemanticSelection): void; @@ -42,7 +45,7 @@ export function createTextFormFieldInteraction(host: Host): { beforeSelect(next: SemanticSelection): SemanticSelection | null; destroy(): void; } { - const t = createT(en); + const t = host.translate ?? createT(en); const document = host.container.ownerDocument; let contextual: { paragraphId: string; fieldNodeId: string } | null | undefined; let committing = false; @@ -52,10 +55,20 @@ export function createTextFormFieldInteraction(host: Host): { (paragraphTextOf(host.part(), paragraphId) ?? '').slice(field.start, field.end); let dialog: HTMLDialogElement | null = null; let active: { paragraphId: string; fieldNodeId: string } | null = null; - const close = (): void => { - const selected = host.selection(); + let sessionController: AbortController | null = null; + let destroyed = false; + const invalidate = (): void => { + const controller = sessionController; + sessionController = null; dialog?.remove(); dialog = null; + controller?.abort(); + }; + const close = (): void => { + if (!dialog && !sessionController) return; + const selected = host.selection(); + invalidate(); + if (destroyed) return; host.pagesLayer.focus({ preventScroll: true }); // Native focus can collapse the DOM range at the start of the editable surface. host.select(selected); @@ -75,14 +88,27 @@ export function createTextFormFieldInteraction(host: Host): { incoming = undefined; } function open(paragraphId: string, field: TextFormFieldRange): void { + if (destroyed) return; close(); - dialog = textFormFieldDialog( - host.container, - field, - (text, options) => { + const controller = new AbortController(); + sessionController = controller; + const canApply = (): boolean => { + if (destroyed || controller.signal.aborted || sessionController !== controller) return false; + const target = findNode(host.part(paragraphId), paragraphId); + return ( + target?.kind === 'paragraph' && + textFormFieldsOf(target).some((entry) => entry.fieldNodeId === field.fieldNodeId) && + host.editable() && + !host.protected(paragraphId) + ); + }; + const session: TextFormFieldDialogSession = { + field: { ...field }, + signal: controller.signal, + canApply, + apply(text, options) { if ( - !host.editable() || - host.protected(paragraphId) || + !canApply() || !host.apply({ op: 'setTextFormFieldDefault', dateInputOrder: host.dateInputOrder?.() ?? 'mdy', @@ -93,17 +119,26 @@ export function createTextFormFieldInteraction(host: Host): { }) ) return false; - const p = findNode(host.part(), paragraphId); + // Applying can synchronously notify consumers that replace the document or UI. + if (destroyed || sessionController !== controller || controller.signal.aborted) return true; + const p = findNode(host.part(paragraphId), paragraphId); const current = p?.kind === 'paragraph' - ? textFormFieldsOf(p).find((f) => f.fieldNodeId === field.fieldNodeId) + ? textFormFieldsOf(p).find((entry) => entry.fieldNodeId === field.fieldNodeId) : null; if (current) select(paragraphId, current); + close(); return true; }, - close - ); + cancel() { + if (sessionController === controller && !controller.signal.aborted) close(); + }, + }; + if (host.onRequest?.(session)) return; + if (controller.signal.aborted) return; + dialog = textFormFieldDialog(host.container, field, session.apply, session.cancel, t); } + const fieldAtTarget = ( event: MouseEvent ): { paragraphId: string; field: TextFormFieldRange } | null => { @@ -572,6 +607,8 @@ export function createTextFormFieldInteraction(host: Host): { return true; }, destroy() { + destroyed = true; + invalidate(); document.removeEventListener('pointermove', move); document.removeEventListener('pointercancel', cancelPress); host.pagesLayer.removeEventListener('click', singleClick); diff --git a/packages/core/src/editor/text-form-field-chrome.ts b/packages/core/src/editor/text-form-field-chrome.ts new file mode 100644 index 000000000..3a8f48c59 --- /dev/null +++ b/packages/core/src/editor/text-form-field-chrome.ts @@ -0,0 +1,34 @@ +import { createChromeHandlerStack } from './chrome-handler-stack.ts'; +import type { + TextFormFieldChromeHandlers, + TextFormFieldDialogSession, +} from './text-form-field-session.ts'; + +/** Own each registration's sessions independently of surface replacement. */ +export function createTextFormFieldChrome() { + const stack = createChromeHandlerStack({}); + const registrations = new Map>(); + return { + request(session: TextFormFieldDialogSession): boolean { + const handlers = stack.current(); + if (!handlers.onRequest) return false; + const sessions = registrations.get(handlers)!; + sessions.add(session); + session.signal.addEventListener('abort', () => sessions.delete(session), { once: true }); + handlers.onRequest(session); + return true; + }, + register(handlers: TextFormFieldChromeHandlers): () => void { + // A caller may reuse the same handlers for separate registrations. + const registration = { ...handlers }; + const sessions = new Set(); + registrations.set(registration, sessions); + const dispose = stack.push(registration); + return () => { + dispose(); + for (const session of sessions) session.cancel(); + registrations.delete(registration); + }; + }, + }; +} diff --git a/packages/core/src/editor/text-form-field-dialog.ts b/packages/core/src/editor/text-form-field-dialog.ts index ddc9076be..38156f415 100644 --- a/packages/core/src/editor/text-form-field-dialog.ts +++ b/packages/core/src/editor/text-form-field-dialog.ts @@ -11,9 +11,10 @@ export function textFormFieldDialog( container: HTMLElement, field: TextFormFieldRange, save: (text: string, options: TextFormFieldOptions) => boolean, - close: () => void + close: () => void, + translate?: ReturnType ): HTMLDialogElement { - const t = createT(en); + const t = translate ?? createT(en); const document = container.ownerDocument; const panel = document.createElement('dialog'); panel.className = 'docx-text-form-dialog'; @@ -131,7 +132,7 @@ export function textFormFieldDialog( }; apply.addEventListener('click', submit); panel.addEventListener('keydown', (event) => { - if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + if (event.key === 'Enter' && !event.isComposing && event.target instanceof HTMLInputElement) { event.preventDefault(); submit(); } diff --git a/packages/core/src/editor/text-form-field-session.ts b/packages/core/src/editor/text-form-field-session.ts new file mode 100644 index 000000000..dd45857b6 --- /dev/null +++ b/packages/core/src/editor/text-form-field-session.ts @@ -0,0 +1,18 @@ +import type { TextFormFieldOptions, TextFormFieldRange } from '@docx-editor.dev/core/store'; + +/** A core-owned options edit. Its signal aborts when the dialog must close. @public */ +export interface TextFormFieldDialogSession { + readonly field: TextFormFieldRange; + readonly signal: AbortSignal; + /** Whether the current target exists and permits editing. Draft validation happens on apply. */ + canApply(): boolean; + /** Save one undoable edit. A refused write leaves the session open. */ + apply(text: string, options: TextFormFieldOptions): boolean; + /** Close without changing the document. Safe to call more than once. */ + cancel(): void; +} + +/** Framework-owned presentation for core-owned Field Options sessions. @public */ +export interface TextFormFieldChromeHandlers { + readonly onRequest?: (session: TextFormFieldDialogSession) => void; +} diff --git a/packages/core/src/store/store/text-form-field-options.ts b/packages/core/src/store/store/text-form-field-options.ts index 190d0deed..fe566e2cb 100644 --- a/packages/core/src/store/store/text-form-field-options.ts +++ b/packages/core/src/store/store/text-form-field-options.ts @@ -17,6 +17,7 @@ export interface TextFormFieldOptions { readonly enabled: boolean; } +/** Supported format choices for legacy text input fields. @public */ export const TEXT_FORM_FORMATS = { regular: ['', 'Uppercase', 'Lowercase', 'First capital', 'Title case'], number: ['', '0', '0.00', '#,##0', '#,##0.00', '0%', '0.00%'], diff --git a/packages/core/src/styles/editor.css b/packages/core/src/styles/editor.css index 3f384f5b7..1f065e045 100644 --- a/packages/core/src/styles/editor.css +++ b/packages/core/src/styles/editor.css @@ -6667,3 +6667,271 @@ a.docx-hyperlink:focus-visible { background: var(--doc-bg); color: var(--doc-text); } + +/* Dialog parts are a public styling contract. */ +:where([data-docx-dialog='pageSetup'].docx-dialog, [data-docx-dialog='textFormField'].docx-dialog) { + background-color: var(--doc-surface); + border-radius: var(--doc-dialog-radius, 8px); + box-shadow: 0 4px 20px var(--doc-shadow); + max-width: 480px; +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__header, + [data-docx-dialog='textFormField'] .docx-dialog__header +) { + padding: var(--doc-dialog-padding, 16px 20px 12px); + border-bottom: 1px solid var(--doc-border); + font-size: var(--doc-dialog-font-size, 16px); + font-weight: 600; + color: var(--doc-text); +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__body, + [data-docx-dialog='textFormField'] .docx-dialog__body +) { + padding: var(--doc-dialog-padding, 16px 20px); + display: flex; + flex-direction: column; + gap: var(--doc-dialog-gap, 14px); +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__section-label, + [data-docx-dialog='textFormField'] .docx-dialog__section-label +) { + font-size: var(--doc-dialog-font-size, 12px); + font-weight: 600; + color: var(--doc-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__row, + [data-docx-dialog='textFormField'] .docx-dialog__row +) { + display: flex; + align-items: center; + gap: var(--doc-dialog-gap, 12px); +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__label, + [data-docx-dialog='textFormField'] .docx-dialog__label +) { + width: 80px; + font-size: var(--doc-dialog-font-size, 13px); + color: var(--doc-text-muted); +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__input, + [data-docx-dialog='textFormField'] .docx-dialog__input +) { + flex: 1; + padding: var(--doc-dialog-padding, 6px 8px); + border: 1px solid var(--doc-border); + border-radius: var(--doc-dialog-radius, 4px); + font-size: var(--doc-dialog-font-size, 13px); + background-color: var(--doc-surface); + color: var(--doc-text); +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__unit, + [data-docx-dialog='textFormField'] .docx-dialog__unit +) { + font-size: var(--doc-dialog-font-size, 11px); + color: var(--doc-text-muted); + width: 16px; +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__footer, + [data-docx-dialog='textFormField'] .docx-dialog__footer +) { + padding: var(--doc-dialog-padding, 12px 20px 16px); + border-top: 1px solid var(--doc-border); + display: flex; + justify-content: flex-end; + gap: var(--doc-dialog-gap, 8px); +} +:where( + [data-docx-dialog='pageSetup'] .docx-dialog__button, + [data-docx-dialog='textFormField'] .docx-dialog__button +) { + padding: var(--doc-dialog-padding, 6px 16px); + font-size: var(--doc-dialog-font-size, 13px); + border: 1px solid var(--doc-border); + border-radius: var(--doc-dialog-radius, 4px); + cursor: pointer; + background-color: var(--doc-surface); + color: var(--doc-text); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__error) { + margin-right: auto; + font-size: var(--doc-dialog-font-size, 12px); + color: var(--doc-danger); +} +:where([data-docx-dialog='paragraph'].docx-dialog) { + background-color: var(--doc-surface); + border-radius: var(--doc-dialog-radius, 8px); + box-shadow: 0 4px 20px var(--doc-shadow); + max-width: 720px; + max-height: 90vh; + display: flex; + flex-direction: column; + min-height: 0px; +} +:where([data-docx-dialog='paragraph'] .docx-dialog__header) { + padding: var(--doc-dialog-padding, 16px 20px 12px); + border-bottom: 1px solid var(--doc-border); + flex-shrink: 0; + font-size: var(--doc-dialog-font-size, 16px); + font-weight: 600; + color: var(--doc-text); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__body) { + padding: var(--doc-dialog-padding, 16px 20px); + overflow-y: auto; + min-height: 0px; +} +:where([data-docx-dialog='paragraph'] .docx-dialog__columns) { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--doc-dialog-gap, 28px); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__column) { + display: flex; + flex-direction: column; + gap: var(--doc-dialog-gap, 14px); + min-width: 0px; +} +:where([data-docx-dialog='paragraph'] .docx-dialog__section-label) { + font-size: var(--doc-dialog-font-size, 12px); + font-weight: 600; + color: var(--doc-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} +:where([data-docx-dialog='paragraph'] .docx-dialog__row) { + display: flex; + align-items: center; + gap: var(--doc-dialog-gap, 12px); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__label) { + width: 92px; + font-size: var(--doc-dialog-font-size, 13px); + color: var(--doc-text-muted); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__input) { + flex: 1; + padding: var(--doc-dialog-padding, 6px 8px); + border: 1px solid var(--doc-border); + border-radius: var(--doc-dialog-radius, 4px); + font-size: var(--doc-dialog-font-size, 13px); + background-color: var(--doc-surface); + color: var(--doc-text); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__unit) { + font-size: var(--doc-dialog-font-size, 11px); + color: var(--doc-text-muted); + width: 20px; +} +:where([data-docx-dialog='paragraph'] .docx-dialog__checkbox-row) { + display: flex; + align-items: center; + gap: var(--doc-dialog-gap, 8px); + font-size: var(--doc-dialog-font-size, 13px); + color: var(--doc-text); +} +:where([data-docx-dialog='paragraph'] .docx-dialog__footer) { + padding: var(--doc-dialog-padding, 12px 20px 16px); + border-top: 1px solid var(--doc-border); + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--doc-dialog-gap, 8px); + flex-shrink: 0; +} +:where([data-docx-dialog='paragraph'] .docx-dialog__button) { + padding: var(--doc-dialog-padding, 6px 16px); + font-size: var(--doc-dialog-font-size, 13px); + border: 1px solid var(--doc-border); + border-radius: var(--doc-dialog-radius, 4px); + cursor: pointer; + background-color: var(--doc-surface); + color: var(--doc-text); +} + +:where(.docx-dialog) { + position: fixed; + inset: 0; + margin: auto; + width: calc(100vw - 40px); + max-width: 480px; + max-height: 90dvh; + padding: 0; + border: 0; + overflow: hidden; + color: var(--doc-text); + background: var(--doc-surface); + font: var(--doc-dialog-font-size, 13px)/1.5 + var(--doc-dialog-font-family, var(--doc-font-ui, system-ui, sans-serif)); +} +:where(.docx-dialog[open]) { + display: flex; + flex-direction: column; +} +:where(.docx-dialog)::backdrop { + background: var(--doc-overlay); +} +:where(.docx-dialog__body) { + min-height: 0; + overflow-y: auto; +} +:where(.docx-dialog__input) { + min-width: 0; + font-family: inherit; +} +:where(.docx-dialog__button) { + font-family: inherit; +} +:where(.docx-dialog__apply) { + background: var(--doc-primary); + color: var(--doc-on-primary); + border-color: var(--doc-primary); +} +:where(.docx-dialog__button:disabled) { + opacity: 0.5; + cursor: default; +} +:where(.docx-dialog__error:empty) { + display: none; +} +:where(.docx-dialog :where(button, input, select, textarea):focus-visible) { + outline: 2px solid var(--doc-primary); + outline-offset: 2px; +} +@media (max-width: 640px) { + :where([data-docx-dialog='paragraph'] .docx-dialog__columns) { + grid-template-columns: 1fr; + } +} + +:where(.docx-dialog__note) { + font-size: 12px; + color: var(--doc-text-muted); +} +:where(.docx-dialog__tab-description) { + flex: 1; + font-size: 13px; + color: var(--doc-text-muted); +} +:where(.docx-dialog[data-docx-dialog='paragraph']) { + max-width: 720px; +} + +:where(.docx-dialog__section-label--spaced) { + margin-top: 4px; +} +:where(.docx-dialog__tab-position) { + color: var(--doc-text); +} +:where(.docx-dialog__row--actions) { + justify-content: flex-end; +} diff --git a/packages/nuxt/src/vue-composables.generated.ts b/packages/nuxt/src/vue-composables.generated.ts index 17cb613a9..9eb29c338 100644 --- a/packages/nuxt/src/vue-composables.generated.ts +++ b/packages/nuxt/src/vue-composables.generated.ts @@ -24,6 +24,8 @@ export const VUE_COMPOSABLES = [ 'useNotePropertiesState', 'useNoteScopeState', 'usePageSetup', + 'usePageSetupDialog', + 'useParagraphDialog', 'useParagraphFormat', 'useParagraphIndent', 'useParagraphStyle', @@ -32,6 +34,7 @@ export const VUE_COMPOSABLES = [ 'useScopeClassName', 'useScopedChromeAnchor', 'useTableBorderTargetLabel', + 'useTextFormFieldDialog', 'useToolbarContext', 'useToolbarLabel', 'useToolbarLabelFor', diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index b174465e9..2d598e4d3 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -1,3 +1,4 @@ +import { DocxEditorTextFormFieldDialog } from '../editor/DocxEditorTextFormFieldDialog'; import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import type { CSSProperties, ForwardRefExoticComponent, RefAttributes } from 'react'; import type { Editor } from '@docx-editor.dev/core/contracts/editor'; @@ -417,6 +418,7 @@ const DocxEditorFrame = forwardRef( // through `setZoom`, callbacks are read at their latest identity. return ( + {editor ? : null}
); diff --git a/packages/react/src/editor/DocxEditorPageSetup.tsx b/packages/react/src/editor/DocxEditorPageSetup.tsx index 7bbeae914..b90ff7af4 100644 --- a/packages/react/src/editor/DocxEditorPageSetup.tsx +++ b/packages/react/src/editor/DocxEditorPageSetup.tsx @@ -1,3 +1,10 @@ +import { + createDialogParts, + useDialogDocument, + DialogFrame, + type DialogCustomizationProps, + type UseDialogReturn, +} from './dialog-parts'; // The Page Setup dialog as a context-fed part (`DocxEditor.PageSetupDialog`). // // Size preset, orientation and margins — the fields Word's dialog and the reference @@ -6,7 +13,7 @@ // owns visibility (`open`/`onClose`); the engine owns everything else. import { useCallback, useEffect, useRef, useState } from 'react'; -import type { CSSProperties, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import { useTranslation } from '../i18n'; import { usePageSetup } from './usePageSetup'; @@ -38,103 +45,13 @@ function findPageSizeIndex(w: number, h: number): number { } /** Props for `DocxEditor.PageSetupDialog`. @public */ -export interface DocxEditorPageSetupDialogProps { +export interface DocxEditorPageSetupDialogProps extends DialogCustomizationProps { /** Whether the dialog is shown. The host owns this state. */ open: boolean; /** Called on Cancel, Escape, overlay click, and after a successful Apply. */ onClose: () => void; - className?: string; } -const overlayStyle: CSSProperties = { - position: 'fixed', - inset: 0, - backgroundColor: 'var(--doc-overlay)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: 10000, -}; - -const dialogStyle: CSSProperties = { - backgroundColor: 'var(--doc-surface)', - borderRadius: 8, - boxShadow: '0 4px 20px var(--doc-shadow)', - minWidth: 400, - maxWidth: 480, - width: '100%', - margin: 20, -}; - -const headerStyle: CSSProperties = { - padding: '16px 20px 12px', - borderBottom: '1px solid var(--doc-border)', - fontSize: 16, - fontWeight: 600, - color: 'var(--doc-text)', -}; - -const bodyStyle: CSSProperties = { - padding: '16px 20px', - display: 'flex', - flexDirection: 'column', - gap: 14, -}; - -const sectionLabelStyle: CSSProperties = { - fontSize: 12, - fontWeight: 600, - color: 'var(--doc-text-muted)', - textTransform: 'uppercase', - letterSpacing: '0.5px', -}; - -const rowStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 12, -}; - -const labelStyle: CSSProperties = { - width: 80, - fontSize: 13, - color: 'var(--doc-text-muted)', -}; - -const inputStyle: CSSProperties = { - flex: 1, - padding: '6px 8px', - border: '1px solid var(--doc-border)', - borderRadius: 4, - fontSize: 13, - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', -}; - -const unitStyle: CSSProperties = { - fontSize: 11, - color: 'var(--doc-text-muted)', - width: 16, -}; - -const footerStyle: CSSProperties = { - padding: '12px 20px 16px', - borderTop: '1px solid var(--doc-border)', - display: 'flex', - justifyContent: 'flex-end', - gap: 8, -}; - -const btnStyle: CSSProperties = { - padding: '6px 16px', - fontSize: 13, - border: '1px solid var(--doc-border)', - borderRadius: 4, - cursor: 'pointer', - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', -}; - const DEFAULT_WIDTH = 12240; const DEFAULT_HEIGHT = 15840; const DEFAULT_MARGIN = 1440; @@ -145,12 +62,16 @@ const DEFAULT_MARGIN = 1440; * * @public */ -export function DocxEditorPageSetupDialog({ +function PageSetupDialogRoot({ open, onClose, className, + style, + children, + preset = true, }: DocxEditorPageSetupDialogProps): ReactElement | null { const { t } = useTranslation(); + const validDocument = useDialogDocument(open, onClose); const { pageSetup, isEnabled, apply } = usePageSetup(); const [pageWidth, setPageWidth] = useState(DEFAULT_WIDTH); const [pageHeight, setPageHeight] = useState(DEFAULT_HEIGHT); @@ -159,8 +80,9 @@ export function DocxEditorPageSetupDialog({ const [marginBottom, setMarginBottom] = useState(DEFAULT_MARGIN); const [marginLeft, setMarginLeft] = useState(DEFAULT_MARGIN); const [marginRight, setMarginRight] = useState(DEFAULT_MARGIN); + const [refused, setRefused] = useState(false); const [scope, setScope] = useState<'document' | 'section'>('document'); - const panelRef = useRef(null); + const panelRef = useRef(null); // Seed the form from the document when the dialog OPENS — not on every section tick, // or a concurrent edit would fight the user's typing. `'loading'` covers a dialog @@ -180,6 +102,7 @@ export function DocxEditorPageSetupDialog({ return; } if (seeded.current === 'yes' || (seeded.current === 'loading' && pageSetup === null)) return; + setRefused(false); setPageWidth(pageSetup?.pageWidthTwips ?? DEFAULT_WIDTH); setPageHeight(pageSetup?.pageHeightTwips ?? DEFAULT_HEIGHT); setOrientation(pageSetup?.orientation ?? 'portrait'); @@ -192,9 +115,6 @@ export function DocxEditorPageSetupDialog({ }, [open, pageSetup]); // Focus the panel on open so Escape works before any field is clicked. - useEffect(() => { - if (open) panelRef.current?.focus(); - }, [open]); const handlePageSizeChange = useCallback( (index: number) => { @@ -218,6 +138,11 @@ export function DocxEditorPageSetupDialog({ ); const handleApply = useCallback(() => { + if (!isEnabled) return; + if (!validDocument()) { + onClose(); + return; + } // A refused write (margins that swallow the page) keeps the dialog OPEN: `apply` // is honest about op-layer rejections, so closing here would claim success. const accepted = apply({ @@ -230,8 +155,11 @@ export function DocxEditorPageSetupDialog({ marginLeftTwips: marginLeft, scope, }); + setRefused(!accepted); if (accepted) onClose(); }, [ + isEnabled, + validDocument, apply, pageWidth, pageHeight, @@ -253,11 +181,15 @@ export function DocxEditorPageSetupDialog({ value: number, set: (twips: number) => void ) => ( -
- +
+ set(Math.max(0, inchesToTwips(Number(event.target.value) || 0)))} aria-label={t(`dialogs.pageSetup.${labelKey}`)} /> - in + in
); + const values: PageSetupDialogFields = { + pageWidth, + pageHeight, + orientation, + marginTop, + marginBottom, + marginLeft, + marginRight, + scope, + }; + const setters = { + pageWidth: setPageWidth, + pageHeight: setPageHeight, + orientation: handleOrientationChange, + marginTop: setMarginTop, + marginBottom: setMarginBottom, + marginLeft: setMarginLeft, + marginRight: setMarginRight, + scope: setScope, + }; + const state: UsePageSetupDialogReturn = { + values, + setValue(name, value) { + (setters[name] as (next: typeof value) => void)(value); + }, + errors: refused ? { form: t('dialogs.paragraph.refused') } : {}, + isEnabled, + apply: handleApply, + cancel: onClose, + }; return ( -
{ if (event.key === 'Escape') onClose(); - if (event.key === 'Enter') handleApply(); + if (event.key === 'Enter' && event.target instanceof HTMLInputElement && isEnabled) { + event.preventDefault(); + handleApply(); + } }} > -
event.stopPropagation()} - // A mousedown that reaches the painted pages moves the caret; the inputs still - // need theirs, and stopping propagation (not preventing default) gives them that. - onMouseDown={(event) => event.stopPropagation()} - role="dialog" - aria-modal="true" - aria-label={t('dialogs.pageSetup.title')} + +
+ + {t('dialogs.pageSetup.title')} + +
+ +
+
{t('dialogs.pageSetup.pageSize')}
+ +
+ + +
+ +
+ + +
+ +
+ {t('dialogs.pageSetup.margins')} +
+ {marginRow('top', marginTop, setMarginTop)} + {marginRow('bottom', marginBottom, setMarginBottom)} + {marginRow('left', marginLeft, setMarginLeft)} + {marginRow('right', marginRight, setMarginRight)} + +
+ + +
+
+ +
+ + {refused ? t('dialogs.paragraph.refused') : null} + + + +
+ + } > -
{t('dialogs.pageSetup.title')}
- -
-
{t('dialogs.pageSetup.pageSize')}
- -
- - -
- -
- - -
- -
{t('dialogs.pageSetup.margins')}
- {marginRow('top', marginTop, setMarginTop)} - {marginRow('bottom', marginBottom, setMarginBottom)} - {marginRow('left', marginLeft, setMarginLeft)} - {marginRow('right', marginRight, setMarginRight)} - -
- - -
-
- -
- - -
-
-
+ {children} + + ); } + +/** Draft page dimensions and margins use twips. @public */ +export interface PageSetupDialogFields { + pageWidth: number; + pageHeight: number; + orientation: 'portrait' | 'landscape'; + marginTop: number; + marginBottom: number; + marginLeft: number; + marginRight: number; + scope: 'document' | 'section'; +} +/** Page Setup draft and actions. @public */ +export interface UsePageSetupDialogReturn extends UseDialogReturn {} +const parts = createDialogParts< + Exclude | 'pageSize', + UsePageSetupDialogReturn +>(); +/** Read the enclosing Page Setup dialog draft. @public */ +export function usePageSetupDialog(): UsePageSetupDialogReturn { + return parts.useState(); +} +/** Page Setup with replaceable controls and layout. @public */ +export const DocxEditorPageSetupDialog = Object.assign(PageSetupDialogRoot, { + Header: parts.Header, + Title: parts.Title, + Body: parts.Body, + Footer: parts.Footer, + Apply: parts.Apply, + Cancel: parts.Cancel, + Error: parts.Error, + Field: parts.Field, +}); diff --git a/packages/react/src/editor/DocxEditorParagraphDialog.tsx b/packages/react/src/editor/DocxEditorParagraphDialog.tsx index 1b9a7865f..ff3bdd0aa 100644 --- a/packages/react/src/editor/DocxEditorParagraphDialog.tsx +++ b/packages/react/src/editor/DocxEditorParagraphDialog.tsx @@ -1,3 +1,10 @@ +import { + createDialogParts, + useDialogDocument, + DialogFrame, + type DialogCustomizationProps, + type UseDialogReturn, +} from './dialog-parts'; // The Paragraph dialog as a context-fed part (`DocxEditor.ParagraphDialog`). // // Alignment, indentation with its Special/By pair, spacing with its line-spacing rule and @@ -13,8 +20,7 @@ // problem. import { useCallback, useEffect, useId, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import type { CSSProperties, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import { useTranslation } from '../i18n'; import { useParagraphFormat, type ParagraphTabStop } from './useParagraphFormat'; import { @@ -24,7 +30,6 @@ import { mixedFieldsOf, NO_MIXED_FIELDS, seedFields, - trapTabWithin, TAB_ALIGNMENT_LABELS, twipsToInches, withTabStop, @@ -37,123 +42,16 @@ import { } from './paragraph-dialog-fields'; /** Props for `DocxEditor.ParagraphDialog`. @public */ -export interface DocxEditorParagraphDialogProps { +export interface DocxEditorParagraphDialogProps extends DialogCustomizationProps { /** Whether the dialog is shown. The host owns this state. */ open: boolean; /** Called on Cancel, Escape, overlay click, and after a successful OK. */ onClose: () => void; - className?: string; } -const refusedStyle: CSSProperties = { - marginRight: 'auto', - fontSize: '12px', - color: 'var(--doc-danger)', -}; - -const overlayStyle: CSSProperties = { - position: 'fixed', - inset: 0, - backgroundColor: 'var(--doc-overlay)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: 10000, -}; - -const dialogStyle: CSSProperties = { - backgroundColor: 'var(--doc-surface)', - borderRadius: 8, - boxShadow: '0 4px 20px var(--doc-shadow)', - minWidth: 620, - maxWidth: 720, - width: '100%', - margin: 20, - maxHeight: '90vh', - // The panel is a column with a scrolling middle, NOT one scrolling box. Scrolling the - // whole panel put OK and Cancel below the fold on an ordinary laptop viewport: the form - // simply ended mid-control with no button and no scrollbar cue that more existed. - display: 'flex', - flexDirection: 'column', - minHeight: 0, -}; - -const headerStyle: CSSProperties = { - padding: '16px 20px 12px', - borderBottom: '1px solid var(--doc-border)', - flexShrink: 0, - fontSize: 16, - fontWeight: 600, - color: 'var(--doc-text)', -}; - -const bodyStyle: CSSProperties = { - padding: '16px 20px', - // The one part that scrolls, so the header and the buttons stay put. - overflowY: 'auto', - minHeight: 0, -}; - // Two columns, the way Word lays this dialog out: General and Indentation and the tab // stops on the left, Spacing and Pagination on the right. It halves the height, so the // whole form fits an ordinary viewport without scrolling. -const columnsStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: 28, -}; -const columnStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', - gap: 14, - minWidth: 0, -}; - -const sectionLabelStyle: CSSProperties = { - fontSize: 12, - fontWeight: 600, - color: 'var(--doc-text-muted)', - textTransform: 'uppercase', - letterSpacing: '0.5px', -}; - -const rowStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: 12 }; -const labelStyle: CSSProperties = { width: 92, fontSize: 13, color: 'var(--doc-text-muted)' }; -const inputStyle: CSSProperties = { - flex: 1, - padding: '6px 8px', - border: '1px solid var(--doc-border)', - borderRadius: 4, - fontSize: 13, - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', -}; -const unitStyle: CSSProperties = { fontSize: 11, color: 'var(--doc-text-muted)', width: 20 }; -const checkRowStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 8, - fontSize: 13, - color: 'var(--doc-text)', -}; -const footerStyle: CSSProperties = { - padding: '12px 20px 16px', - borderTop: '1px solid var(--doc-border)', - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - gap: 8, - flexShrink: 0, -}; -const btnStyle: CSSProperties = { - padding: '6px 16px', - fontSize: 13, - border: '1px solid var(--doc-border)', - borderRadius: 4, - cursor: 'pointer', - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', -}; /** * The Paragraph dialog. Reads the selection through `useParagraphFormat()` and applies @@ -161,12 +59,16 @@ const btnStyle: CSSProperties = { * * @public */ -export function DocxEditorParagraphDialog({ +function ParagraphDialogRoot({ open, onClose, className, + style, + children, + preset = true, }: DocxEditorParagraphDialogProps): ReactElement | null { const { t } = useTranslation(); + const validDocument = useDialogDocument(open, onClose); const { format, isEnabled, apply } = useParagraphFormat(); const [alignment, setAlignment] = useState<'left' | 'center' | 'right' | 'justify'>('left'); @@ -203,7 +105,7 @@ export function DocxEditorParagraphDialog({ // checkbox, and `aria-label` alone does not give them that. const fieldId = useId(); const [refused, setRefused] = useState(false); - const panelRef = useRef(null); + const panelRef = useRef(null); // Seed from the selection when the dialog OPENS — not on every tick, or a concurrent // edit would fight the user's typing. The same rule `DocxEditorPageSetupDialog` follows. @@ -266,11 +168,13 @@ export function DocxEditorParagraphDialog({ // intact; the cost is one click before typing resumes. That is the smallest of the four // behaviours and the only one that cannot lose work. `e2e/paragraph-dialog.interaction.spec.ts` // holds the two invariants that matter: closing moves neither the scroll nor the text. - useEffect(() => { - if (open) panelRef.current?.focus(); - }, [open]); const handleApply = useCallback(() => { + if (!isEnabled) return; + if (!validDocument()) { + onClose(); + return; + } const seed = seedRef.current; const update = seed === null @@ -313,6 +217,8 @@ export function DocxEditorParagraphDialog({ } setRefused(true); }, [ + isEnabled, + validDocument, apply, onClose, // `mixed` decides which settings count as RESOLVED, so a stale copy would drop exactly @@ -379,14 +285,14 @@ export function DocxEditorParagraphDialog({ set: (twips: number) => void, mixedKey: keyof ParagraphDialogMixed ) => ( -
-
+ + } + > + {children} + + ); } + +/** Paragraph dialog draft, mixed values, and actions. @public */ +export interface UseParagraphDialogReturn extends UseDialogReturn { + readonly mixed: ParagraphDialogMixed; +} +const parts = createDialogParts< + Exclude, + UseParagraphDialogReturn +>(); +/** Read the enclosing Paragraph Options draft. @public */ +export function useParagraphDialog(): UseParagraphDialogReturn { + return parts.useState(); +} +/** Paragraph Options with replaceable controls and layout. @public */ +export const DocxEditorParagraphDialog = Object.assign(ParagraphDialogRoot, { + Header: parts.Header, + Title: parts.Title, + Body: parts.Body, + Footer: parts.Footer, + Apply: parts.Apply, + Cancel: parts.Cancel, + Error: parts.Error, + Field: parts.Field, +}); diff --git a/packages/react/src/editor/DocxEditorRoot.tsx b/packages/react/src/editor/DocxEditorRoot.tsx index 131d1c2db..e5cd70b21 100644 --- a/packages/react/src/editor/DocxEditorRoot.tsx +++ b/packages/react/src/editor/DocxEditorRoot.tsx @@ -1,3 +1,4 @@ +import { DialogProvider, type DocxEditorDialogs } from './dialog-host'; import type { DocxEditorChildren } from '../docx-editor-children'; // Provider-first host for the docx editor facade. // @@ -59,6 +60,8 @@ import { * @public */ export interface DocxEditorRootProps { + /** Customize dialogs opened by editor controls. */ + dialogs?: DocxEditorDialogs; /** A document to load: DOCX bytes, `'blank'` for an empty one, or an existing handle. * Identity change remounts; `'blank'` is a constant, so holding it across renders does * not. Omitting this mounts NO document, which is not the same as an empty one. */ @@ -435,11 +438,13 @@ export function DocxEditorRoot(props: DocxEditorRootProps) { {/* ONE link-popover state per editor, published here so a TOOLBAR button and the popover panel — which are siblings, not ancestor and descendant — see the same open/closed state and only one of them registers with the engine's gestures. */} - - - {children} - - + + + + {children} + + + diff --git a/packages/react/src/editor/DocxEditorTextFormFieldDialog.tsx b/packages/react/src/editor/DocxEditorTextFormFieldDialog.tsx new file mode 100644 index 000000000..2ba4742a0 --- /dev/null +++ b/packages/react/src/editor/DocxEditorTextFormFieldDialog.tsx @@ -0,0 +1,257 @@ +import { useEditorState } from './useEditorState'; +import { useEffect, useState } from 'react'; +import type { TextFormFieldDialogSession } from '@docx-editor.dev/core/editor'; +import { TEXT_FORM_FORMATS } from '@docx-editor.dev/core/editor'; +import type { TextFormFieldOptions } from '@docx-editor.dev/core/store'; +import { useTranslation } from '../i18n'; +import { + createDialogParts, + DialogFrame, + type DialogCustomizationProps, + type UseDialogReturn, +} from './dialog-parts'; + +/** Draft values for legacy text Field Options. @public */ +export interface TextFormFieldDialogFields { + defaultText: string; + type: string; + maxLength: number; + format: string; + enabled: boolean; +} +/** Field Options session and presentation. @public */ +export interface DocxEditorTextFormFieldDialogProps extends DialogCustomizationProps { + session: TextFormFieldDialogSession | null; +} +/** Field Options draft and actions. @public */ +export interface UseTextFormFieldDialogReturn extends UseDialogReturn {} +const parts = createDialogParts(); +/** Read the enclosing Field Options draft. @public */ +export function useTextFormFieldDialog(): UseTextFormFieldDialogReturn { + return parts.useState(); +} + +function TextFormFieldDialogRoot({ session, ...props }: DocxEditorTextFormFieldDialogProps) { + // A new request creates a new draft even if the same field opens again. + const [current, setCurrent] = useState(session); + const [generation, setGeneration] = useState(0); + if (current !== session) { + setCurrent(session); + setGeneration(generation + 1); + } + return session ? : null; +} +function TextFormFieldForm({ + session, + children, + preset, + className, + style, +}: DialogCustomizationProps & { session: TextFormFieldDialogSession }) { + const { t } = useTranslation(); + const isEnabled = useEditorState(() => session.canApply()); + const [values, setValues] = useState(() => ({ + defaultText: session.field.defaultText, + type: session.field.type, + maxLength: session.field.maxLength, + format: session.field.format, + enabled: session.field.enabled, + })); + const [closed, setClosed] = useState(session.signal.aborted); + const [refused, setRefused] = useState(false); + useEffect(() => { + const close = () => setClosed(true); + session.signal.addEventListener('abort', close); + if (session.signal.aborted) close(); + return () => session.signal.removeEventListener('abort', close); + }, [session]); + const apply = () => { + if (session.signal.aborted) return; + const accepted = + Number.isInteger(values.maxLength) && + values.maxLength >= 0 && + values.maxLength <= 32767 && + session.apply(values.defaultText, { + type: values.type as TextFormFieldOptions['type'], + maxLength: values.maxLength, + format: values.format, + enabled: values.enabled, + }); + setRefused(!accepted); + }; + const state: UseTextFormFieldDialogReturn = { + values, + setValue(name, value) { + setValues((previous) => ({ + ...previous, + [name]: value, + ...(name === 'type' ? { format: '' } : {}), + })); + setRefused(false); + }, + errors: refused ? { form: t('textFormField.invalidOptions') } : {}, + isEnabled: !closed && isEnabled, + apply, + cancel: session.cancel, + }; + if (closed) return null; + const formats: readonly string[] = + TEXT_FORM_FORMATS[values.type as keyof typeof TEXT_FORM_FORMATS] ?? []; + const formatKeys = { + Uppercase: 'textFormField.uppercase', + Lowercase: 'textFormField.lowercase', + 'First capital': 'textFormField.firstCapital', + 'Title case': 'textFormField.titleCase', + } as const; + const row = (name: keyof TextFormFieldDialogFields, control: React.ReactNode) => ( + + ); + return ( + { + if (event.key === 'Escape') { + event.preventDefault(); + session.cancel(); + } + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + event.preventDefault(); + apply(); + } + }} + > + +
+ + {t('textFormField.title')} + +
+
+ {row( + 'defaultText', + state.setValue('defaultText', e.target.value)} + /> + )} + {row( + 'type', + + )} + {row( + 'maxLength', + state.setValue('maxLength', e.target.valueAsNumber)} + /> + )} + {row( + 'format', + + )} + +
+
+ + {state.errors.form} + + + +
+ + } + > + {children} +
+
+ ); +} +/** Legacy text Field Options with replaceable controls and layout. @public */ +export const DocxEditorTextFormFieldDialog = Object.assign(TextFormFieldDialogRoot, { + Header: parts.Header, + Title: parts.Title, + Body: parts.Body, + Footer: parts.Footer, + Apply: parts.Apply, + Cancel: parts.Cancel, + Error: parts.Error, + Field: parts.Field, +}); diff --git a/packages/react/src/editor/dialog-host.tsx b/packages/react/src/editor/dialog-host.tsx new file mode 100644 index 000000000..5bbc6fb0a --- /dev/null +++ b/packages/react/src/editor/dialog-host.tsx @@ -0,0 +1,117 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { DocxEditorChildren } from '../docx-editor-children'; +import { useEditorMountGeneration } from './dialog-parts'; +import type { ReactNode } from 'react'; +import type { TextFormFieldDialogSession } from '@docx-editor.dev/core/editor'; +import { useDocxEditor } from './context'; +import { + DocxEditorPageSetupDialog, + type DocxEditorPageSetupDialogProps, +} from './DocxEditorPageSetup'; +import { + DocxEditorParagraphDialog, + type DocxEditorParagraphDialogProps, +} from './DocxEditorParagraphDialog'; +import { + DocxEditorTextFormFieldDialog, + type DocxEditorTextFormFieldDialogProps, +} from './DocxEditorTextFormFieldDialog'; + +/** Renderers for dialogs opened through packaged controls and engine gestures. @public */ +export interface DocxEditorDialogs { + pageSetup?: (props: DocxEditorPageSetupDialogProps) => DocxEditorChildren | null; + paragraph?: (props: DocxEditorParagraphDialogProps) => DocxEditorChildren | null; + textFormField?: (props: DocxEditorTextFormFieldDialogProps) => DocxEditorChildren | null; +} +interface DialogHost { + open(kind: 'pageSetup' | 'paragraph', returnFocusTo?: HTMLElement | null): void; + setContainer(container: HTMLElement | null): void; +} +const Context = createContext(null); +export const useDialogHost = () => useContext(Context); +export function DialogProvider({ + dialogs, + children, +}: { + dialogs?: DocxEditorDialogs; + children?: ReactNode; +}) { + const editor = useDocxEditor(); + const generation = useEditorMountGeneration(); + const [container, setContainer] = useState(null); + const [active, setActive] = useState<'pageSetup' | 'paragraph' | null>(null); + const [session, setSession] = useState(null); + const opener = useRef(null); + const sessionRef = useRef(session); + sessionRef.current = session; + const close = () => setActive(null); + useEffect(() => { + if (active !== null) return; + const target = opener.current; + opener.current = null; + if (target?.isConnected) target.focus({ preventScroll: true }); + }, [active]); + useEffect(() => { + if (!editor) return; + return editor.setTextFormFieldChrome({ + onRequest(request) { + setActive(null); + setSession(request); + request.signal.addEventListener( + 'abort', + () => setSession((previous) => (previous === request ? null : previous)), + { once: true } + ); + }, + }); + }, [editor]); + useEffect(() => { + setActive(null); + setSession(null); + }, [editor, generation]); + const host = useMemo( + () => ({ + setContainer, + open(kind, returnFocusTo) { + sessionRef.current?.cancel(); + opener.current = + returnFocusTo ?? (container?.ownerDocument.activeElement as HTMLElement | null); + setActive(kind); + }, + }), + [container] + ); + const props = { open: true, onClose: close }; + const content = + active === 'pageSetup' ? ( + dialogs?.pageSetup ? ( + dialogs.pageSetup(props) + ) : ( + + ) + ) : active === 'paragraph' ? ( + dialogs?.paragraph ? ( + dialogs.paragraph(props) + ) : ( + + ) + ) : session ? ( + dialogs?.textFormField ? ( + dialogs.textFormField({ session }) + ) : ( + + ) + ) : null; + return ( + + {children} + {container ? createPortal(content, container) : null} + + ); +} +/** The stable mount belongs to Content, outside the engine-owned DOM. */ +export function DialogMount() { + const host = useDialogHost(); + return
; +} diff --git a/packages/react/src/editor/dialog-parts.tsx b/packages/react/src/editor/dialog-parts.tsx new file mode 100644 index 000000000..d65409d77 --- /dev/null +++ b/packages/react/src/editor/dialog-parts.tsx @@ -0,0 +1,316 @@ +import { trapTabWithin } from '@docx-editor.dev/core/editor'; +import { useDocxEditor } from './context'; +import { deferredNotifier } from './useEditorState'; +import { + Children, + Fragment, + cloneElement, + createContext, + isValidElement, + useContext, + useLayoutEffect, + useEffect, + useCallback, + useSyncExternalStore, + useRef, +} from 'react'; +import type { CSSProperties, KeyboardEvent, ReactElement, ReactNode, RefObject } from 'react'; +import { Slot } from './toolbar/Slot'; +import type { DocxEditorChildren } from '../docx-editor-children'; + +/** Presentation overrides for a dialog part. @public */ +export interface DialogPartProps { + className?: string; + style?: CSSProperties; + hidden?: boolean; + asChild?: boolean; + children?: DocxEditorChildren; +} + +/** Layout customization for a packaged dialog. @public */ +export interface DialogCustomizationProps { + className?: string; + style?: CSSProperties; + /** Render the default arrangement, replacing named children in place. Defaults to true. */ + preset?: boolean; + children?: DocxEditorChildren; +} + +/** State shared by a dialog's controls. @public */ +export interface UseDialogReturn { + readonly values: Fields; + setValue(name: K, value: Fields[K]): void; + readonly errors: Readonly>>; + readonly isEnabled: boolean; + apply(): void; + cancel(): void; +} + +type NodeProps = Record & { children?: ReactNode }; +const marker = (props: NodeProps): string | null => { + const part = props['data-docx-part']; + return typeof part === 'string' + ? part === 'field' + ? `field:${props['data-docx-field']}` + : part + : null; +}; + +/** Adapter-local renderer; document state remains in the dialog controller. */ +export function createDialogParts() { + const Context = createContext<{ + defaults: Map>; + overrides: Map>; + state: State; + } | null>(null); + const identities = new Map(); + const keyOf = (node: ReactElement) => { + const part = identities.get(node.type); + return part === 'field' ? `field:${node.props.name}` : part; + }; + function visit(nodes: ReactNode, callback: (node: ReactElement) => void) { + Children.forEach(nodes, (node) => { + if (!isValidElement(node)) return; + callback(node); + visit(node.props.children, callback); + }); + } + function useState(): State { + const context = useContext(Context); + if (!context) + throw new globalThis.Error('Dialog controls must be rendered inside their dialog.'); + return context.state; + } + function replace( + nodes: ReactNode, + overrides: Map> + ): ReactNode { + return Children.map(nodes, (node) => { + if (!isValidElement(node)) return node; + const key = marker(node.props); + if (key && overrides.has(key)) return overrides.get(key); + return node.props.children === undefined + ? node + : cloneElement(node, { + children: replace(node.props.children, overrides), + }); + }); + } + function makePart(part: string) { + function Part(props: DialogPartProps & { name?: Name }) { + const context = useContext(Context); + if (!context || props.hidden) return null; + const key = part === 'field' ? `field:${props.name}` : part; + const original = context.defaults.get(key); + if (!original) return props.children ?? null; + const { children, asChild, className, style } = props; + const shared = { + ...original.props, + className: [original.props.className, className].filter(Boolean).join(' '), + style: { ...(original.props.style as CSSProperties), ...style }, + }; + if (asChild) { + const { children: _defaultChildren, ...wiring } = shared; + return {children}; + } + return cloneElement( + original, + shared, + children === undefined ? replace(original.props.children, context.overrides) : children + ); + } + identities.set(Part, part); + return Part; + } + const Header = makePart('header'); + const Title = makePart('title'); + const Body = makePart('body'); + const Footer = makePart('footer'); + const Apply = makePart('apply'); + const Cancel = makePart('cancel'); + const Error = makePart('error'); + const Field = makePart('field') as (props: DialogPartProps & { name: Name }) => ReactNode; + function Composition({ + defaults, + children, + preset = true, + state, + }: { + defaults: ReactNode; + children?: ReactNode; + preset?: boolean; + state: State; + }) { + const defaultMap = new Map>(); + visit(defaults, (node) => { + const key = marker(node.props); + if (key) defaultMap.set(key, node); + }); + const overrides = new Map>(); + visit(children, (node) => { + const key = keyOf(node); + if (key) overrides.set(key, node as ReactElement); + }); + const flatten = (nodes: ReactNode): ReactNode[] => + Children.toArray(nodes).flatMap((node) => + isValidElement(node) && node.type === Fragment + ? flatten(node.props.children) + : [node] + ); + const extras = flatten(children).filter( + (node) => !isValidElement(node) || !keyOf(node) + ); + return ( + + {preset ? ( + <> + {replace(defaults, overrides)} + {extras} + + ) : ( + children + )} + + ); + } + return { Header, Title, Body, Footer, Apply, Cancel, Error, Field, Composition, useState }; +} + +/** Native modal lifecycle shared by packaged dialog renderers. */ +export function DialogFrame({ + kind, + className, + style, + label, + onClose, + onKeyDown, + panelRef, + children, + dismissOutside = true, + sessionSignal, + restoreFocus = true, +}: { + kind: 'pageSetup' | 'paragraph' | 'textFormField'; + className?: string; + style?: CSSProperties; + label: string; + onClose(): void; + children: ReactNode; + onKeyDown?: (event: KeyboardEvent) => void; + panelRef?: RefObject; + dismissOutside?: boolean; + sessionSignal?: AbortSignal; + restoreFocus?: boolean; +}) { + const ownRef = useRef(null); + const ref = panelRef ?? ownRef; + const closeRef = useRef(onClose); + closeRef.current = onClose; + useLayoutEffect(() => { + const panel = ref.current; + if (!panel) return; + if (!panel.parentElement?.closest('.docx-editor')) panel.classList.add('docx-editor'); + const opener = panel.ownerDocument.activeElement as HTMLElement | null; + const closeNative = () => { + if (panel.open) panel.close?.(); + }; + sessionSignal?.addEventListener('abort', closeNative); + if (typeof panel.showModal === 'function') panel.showModal(); + else panel.setAttribute('open', ''); // DOM test environments; browsers use native modality. + panel + .querySelector( + 'input:not([disabled]),select:not([disabled]),button:not([disabled]),[tabindex="0"]' + ) + ?.focus({ preventScroll: true }); + return () => { + sessionSignal?.removeEventListener('abort', closeNative); + if (typeof panel.close === 'function' && panel.open) panel.close(); + if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true }); + }; + }, [ref, sessionSignal, restoreFocus]); + return ( + { + event.preventDefault(); + closeRef.current(); + }} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => { + if (dismissOutside && event.target === event.currentTarget) { + const rect = event.currentTarget.getBoundingClientRect(); + if ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ) + closeRef.current(); + } + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape' && !event.nativeEvent.isComposing) { + event.preventDefault(); + closeRef.current(); + } else if (trapTabWithin(event.currentTarget, event.nativeEvent)) event.preventDefault(); + else if (!event.nativeEvent.isComposing && !event.defaultPrevented) onKeyDown?.(event); + event.stopPropagation(); + }} + > + {children} + + ); +} + +/** Prevent a retained draft from editing a replacement document. */ +export function useDialogDocument(open: boolean, onClose: () => void): () => boolean { + const editor = useDocxEditor(); + const generation = useEditorMountGeneration(); + const draft = useRef(null); + useEffect(() => { + if (!open) { + draft.current = null; + return; + } + if (!editor?.surface) return; + if (draft.current === null) draft.current = editor.mountGeneration; + else if (draft.current !== editor.mountGeneration) onClose(); + }, [open, editor, generation, onClose]); + return () => !!editor?.surface && draft.current === editor.mountGeneration; +} + +/** Mount identity is independent of equal public snapshots after a reload. */ +export function useEditorMountGeneration(): number { + const editor = useDocxEditor(); + const subscribe = useCallback( + (changed: () => void) => { + if (!editor) return () => {}; + let active = true; + const notify = deferredNotifier(() => { + if (active) changed(); + }); + const off = [ + editor.on('change', notify), + editor.on('selectionChange', notify), + editor.on('error', notify), + ]; + return () => { + active = false; + off.forEach((dispose) => dispose()); + }; + }, + [editor] + ); + return useSyncExternalStore( + subscribe, + () => editor?.mountGeneration ?? 0, + () => 0 + ); +} diff --git a/packages/react/src/editor/menu/DocxEditorMenu.tsx b/packages/react/src/editor/menu/DocxEditorMenu.tsx index 83e76fde7..cd5a79c09 100644 --- a/packages/react/src/editor/menu/DocxEditorMenu.tsx +++ b/packages/react/src/editor/menu/DocxEditorMenu.tsx @@ -1,3 +1,4 @@ +import { useDialogHost } from '../dialog-host'; import type { DocxEditorChildren } from '../../docx-editor-children'; import type { ReactNode } from 'react'; // The compound menu bar: File · Format · Insert · Review · Help, derived FROM the chrome registry. @@ -156,6 +157,7 @@ function menuOfChild(child: ReactNode): ChromeMenuId | null { } function DocxEditorMenuRoot(props: DocxEditorMenuProps) { + const dialogs = useDialogHost(); // Skip the scope class when the packaged wrapper already carries it. const scopeClassName = useScopeClassName(); const { @@ -229,7 +231,16 @@ function DocxEditorMenuRoot(props: DocxEditorMenuProps) { }); }, [editor, fileName, openedName]); - const packagedPageSetup = useCallback(() => setPageSetupOpen(true), []); + const packagedPageSetup = useCallback( + () => + dialogs + ? dialogs.open( + 'pageSetup', + rootRef.current?.querySelector('[data-menu="file"] > [role="menuitem"]') + ) + : setPageSetupOpen(true), + [dialogs] + ); // The resolved actions, host override first. Each is undefined without an editor, which // is what disables the row before the document is ready. @@ -278,7 +289,15 @@ function DocxEditorMenuRoot(props: DocxEditorMenuProps) { onOpen: resolvedOpen, onSave: resolvedSave, onPageSetup: resolvedPageSetup, - onParagraphDialog: () => setParagraphDialogOpen(true), + onParagraphDialog: () => + dialogs + ? dialogs.open( + 'paragraph', + rootRef.current?.querySelector( + '[data-menu="format"] > [role="menuitem"]' + ) + ) + : setParagraphDialogOpen(true), onReportIssue, reportIssue, }), @@ -290,6 +309,7 @@ function DocxEditorMenuRoot(props: DocxEditorMenuProps) { resolvedOpen, resolvedSave, resolvedPageSetup, + dialogs, onReportIssue, reportIssue, ] diff --git a/packages/react/src/editor/paragraph-dialog-fields.ts b/packages/react/src/editor/paragraph-dialog-fields.ts index 48c0b7330..282f5b491 100644 --- a/packages/react/src/editor/paragraph-dialog-fields.ts +++ b/packages/react/src/editor/paragraph-dialog-fields.ts @@ -1,360 +1,23 @@ -// The Paragraph dialog's field logic, with no framework in it. -// -// Byte-identical between the React and Vue adapters (enforced by -// `scripts/check-adapter-mirror.mjs`). The dialog is a form over ~15 settings, and every -// rule about what those settings MEAN — how a signed first-line indent splits into a kind -// and a magnitude, which fields a submission may name — is the engine's contract, not a -// framework's. Two hand-written copies drifted; one shared module cannot. - -import type { - ParagraphFormatRead, - ParagraphFormatUpdate, - ParagraphTabStop, -} from './useParagraphFormat'; - -export const TWIPS_PER_INCH = 1440; - -export const twipsToInches = (twips: number): number => - Math.round((twips / TWIPS_PER_INCH) * 100) / 100; - -/** - * Inches for DISPLAY, in the BROWSER's number format. - * - * `0.5` and `0,5` are the same measurement, and roughly half the locales this ships with - * write the second one. Interpolating a raw `Number` into a string picks the first for - * everyone. The catalogue supplies the surrounding words; this supplies the number. - * - * Not the editor's locale: nothing in the i18n layer exposes one to read, so a German - * editor in an American browser still renders `0.5`. The browser's guess beats a hardcoded - * `.` for every reader whose browser matches their language, which is most of them. - */ -export const formatInches = (twips: number): string => - twipsToInches(twips).toLocaleString(undefined, { maximumFractionDigits: 2 }); - -export const inchesToTwips = (inches: number): number => Math.round(inches * TWIPS_PER_INCH); - -export type TabAlignment = 'left' | 'center' | 'right' | 'decimal' | 'bar'; -export type TabLeaderName = 'none' | 'dot' | 'hyphen' | 'underscore'; - -/** One label key per alignment, so the rows read as words rather than as `w:val` values. */ -export const TAB_ALIGNMENT_LABELS = { - left: 'dialogs.paragraph.tabAlignLeft', - center: 'dialogs.paragraph.tabAlignCenter', - right: 'dialogs.paragraph.tabAlignRight', - decimal: 'dialogs.paragraph.tabAlignDecimal', - // Unreachable today — the reader never yields `bar`, the dialog does not offer it and - // `classifyCommand` refuses it — but `ParagraphTabStop` admits it, so the map that types - // itself against that union has to carry it. A row with no label is worse than a spare one. - bar: 'dialogs.paragraph.tabAlignBar', -} as const satisfies Record; - -/** The "Special" pair: the signed first-line offset, split into a kind and a magnitude. */ -export type SpecialIndent = 'none' | 'firstLine' | 'hanging'; - -export const specialOf = (signedTwips: number | null): SpecialIndent => { - if (signedTwips === null || signedTwips === 0) return 'none'; - return signedTwips < 0 ? 'hanging' : 'firstLine'; -}; - -/** Fold the "Special" pair back into the ONE signed value the engine takes. */ -export const signedFirstLineOf = (kind: SpecialIndent, magnitudeTwips: number): number => { - if (kind === 'none') return 0; - return kind === 'hanging' ? -Math.abs(magnitudeTwips) : Math.abs(magnitudeTwips); -}; - -/** - * Every field of the form, in the shape the controls hold it. - * - * Deliberately flat and all-defined: this is what the dialog SHOWS, and a control cannot - * show "mixed" and a number at once. The disagreement itself is remembered by comparing - * against the seed, not by keeping a null in here. - */ -export interface ParagraphDialogFields { - alignment: 'left' | 'center' | 'right' | 'justify'; - indentLeft: number; - indentRight: number; - special: SpecialIndent; - specialBy: number; - spaceBefore: number; - spaceAfter: number; - lineRule: 'multiple' | 'exact' | 'atLeast'; - lineValue: number; - contextualSpacing: boolean; - keepNext: boolean; - keepLines: boolean; - widowControl: boolean; - pageBreakBefore: boolean; - tabStops: readonly ParagraphTabStop[]; - /** The user pressed "Clear all", which is a decision even when the list already looked empty. */ - clearedAllTabStops: boolean; -} - -/** - * Open the form on the selection. - * - * A `null` field means the selection DISAGREES about that setting. There is no third - * checkbox state to show it with, so the control opens on the least surprising value and - * {@link changedFields} keeps the disagreement alive by not writing what the user did not - * touch. `widowControl` opens ON because that is the Word default a document inherits - * when nothing says otherwise. - */ -export function seedFields(format: ParagraphFormatRead): ParagraphDialogFields { - const firstLine = format.indentFirstLineTwips; - return { - alignment: format.alignment ?? 'left', - indentLeft: format.indentLeftTwips ?? 0, - indentRight: format.indentRightTwips ?? 0, - special: specialOf(firstLine), - specialBy: Math.abs(firstLine ?? 0), - spaceBefore: format.spaceBeforePt ?? 0, - spaceAfter: format.spaceAfterPt ?? 0, - lineRule: format.lineSpacing?.rule ?? 'multiple', - lineValue: format.lineSpacing?.value ?? 1.08, - contextualSpacing: format.contextualSpacing === true, - keepNext: format.keepNext === true, - keepLines: format.keepLines === true, - widowControl: format.widowControl !== false, - pageBreakBefore: format.pageBreakBefore === true, - tabStops: format.tabStops ?? [], - clearedAllTabStops: false, - }; -} - -/** - * Which settings the selection DISAGREES about, so a control can show it. - * - * The value fields need this as much as the checkboxes do. A control that renders a - * disagreement as a plausible-looking number is not just unhelpful — it makes the - * disagreement uncorrectable, because the value that would fix it is the one already on - * screen, so `changedFields` sees nothing move and writes nothing. Four paragraphs at - * mixed alignments showed "Left" and could not be set to Left. - */ -export interface ParagraphDialogMixed { - readonly contextualSpacing: boolean; - readonly keepNext: boolean; - readonly keepLines: boolean; - readonly widowControl: boolean; - readonly pageBreakBefore: boolean; - /** The selection's paragraphs carry DIFFERENT tab stops, so the list shows none of them. */ - readonly tabStops: boolean; - readonly alignment: boolean; - readonly indentLeft: boolean; - readonly indentRight: boolean; - readonly special: boolean; - readonly spaceBefore: boolean; - readonly spaceAfter: boolean; - readonly lineSpacing: boolean; -} - -/** The five members that are checkboxes, and so share a label key with their control. */ -export type ParagraphFlagKey = - | 'contextualSpacing' - | 'keepNext' - | 'keepLines' - | 'widowControl' - | 'pageBreakBefore'; - -export const NO_MIXED_FIELDS: ParagraphDialogMixed = { - contextualSpacing: false, - keepNext: false, - keepLines: false, - widowControl: false, - pageBreakBefore: false, - tabStops: false, - alignment: false, - indentLeft: false, - indentRight: false, - special: false, - spaceBefore: false, - spaceAfter: false, - lineSpacing: false, -}; - -/** - * A checkbox over a setting the selection disagrees about is INDETERMINATE, not unchecked - * — unchecked would claim the paragraphs agree it is off. The read reports `null` for - * exactly this, and a control that collapses it to a boolean throws the distinction away. - */ -export function mixedFieldsOf(format: ParagraphFormatRead): ParagraphDialogMixed { - return { - contextualSpacing: format.contextualSpacing === null, - keepNext: format.keepNext === null, - keepLines: format.keepLines === null, - widowControl: format.widowControl === null, - pageBreakBefore: format.pageBreakBefore === null, - tabStops: format.disagrees.tabStops, - // Asked, not guessed. A `null` value means BOTH "the paragraphs disagree" and "nothing - // states it", and treating the second as the first told a single paragraph — the - // commonest case in a real document — that it disagreed with itself. - alignment: format.disagrees.alignment, - // `indentUnknown` is a table paragraph: the engine measures indents from the cell's - // content edge and reports none, because a ruler drawn against the page margin cannot - // place them. The control cannot show a value either, so it shows none — blank, and - // written only if the user types. It is not a disagreement, and the placeholder says so. - indentLeft: format.disagrees.indentLeft || format.indentUnknown, - indentRight: format.disagrees.indentRight || format.indentUnknown, - special: format.disagrees.indentFirstLine || format.indentUnknown, - spaceBefore: format.disagrees.spaceBeforePt, - spaceAfter: format.disagrees.spaceAfterPt, - lineSpacing: format.disagrees.lineSpacing, - }; -} - -/** Whether two stop lists say the same thing. A list needs more than reference equality. */ -export function sameTabStops( - a: readonly ParagraphTabStop[], - b: readonly ParagraphTabStop[] -): boolean { - if (a === b) return true; - if (a.length !== b.length) return false; - return a.every((stop, index) => { - const other = b[index]; - return ( - other !== undefined && - stop.positionTwips === other.positionTwips && - stop.alignment === other.alignment && - (stop.leader ?? 'none') === (other.leader ?? 'none') - ); - }); -} - -/** - * The submission: ONLY the fields that moved since the dialog opened. - * - * Sending the whole form would flatten every setting the selection disagrees about. A - * mixed `keepNext` would become off on every paragraph, and a mixed left indent would - * become an explicit zero — which is worse than wrong, because a zero BLOCKS the style - * cascade where leaving the setting alone would let the style keep supplying it. An - * untouched field is not a decision, so it is not written. - * - * Returns null when nothing moved, which the caller treats as "just close": an empty - * write would still push an undo entry that restores nothing. - */ -export function changedFields( - seed: ParagraphDialogFields, - current: ParagraphDialogFields, - /** What the selection disagreed about when the dialog opened. */ - seedMixed: ParagraphDialogMixed = NO_MIXED_FIELDS, - /** What it still disagrees about now. A field that left this set was RESOLVED. */ - currentMixed: ParagraphDialogMixed = seedMixed -): ParagraphFormatUpdate | null { - const update: { - -readonly [K in keyof ParagraphFormatUpdate]: ParagraphFormatUpdate[K]; - } = {}; - let moved = false; - const take = ( - key: K, - value: ParagraphFormatUpdate[K] - ): void => { - update[key] = value; - moved = true; - }; - /** - * A setting the selection DISAGREED about, that it no longer disagrees about. - * - * Comparing values alone is not enough for these. The control opened on one of the two - * answers, so a user resolving the disagreement TO that answer — clicking a mixed box on - * and off again, which is how you say "off, for all of them" — leaves the value equal to - * the seed while the box now reads as settled. Writing nothing there would leave the - * paragraphs still disagreeing under a control claiming they agree, and making the - * selection agree is the whole job. - */ - const resolved = (key: keyof ParagraphDialogMixed): boolean => - seedMixed[key] && !currentMixed[key]; - - if (seed.alignment !== current.alignment || resolved('alignment')) - take('alignment', current.alignment); - if (seed.indentLeft !== current.indentLeft || resolved('indentLeft')) - take('indentLeftTwips', current.indentLeft); - if (seed.indentRight !== current.indentRight || resolved('indentRight')) - take('indentRightTwips', current.indentRight); - // The kind and the magnitude are two controls over ONE value, so either one moving - // rewrites it. Note that `none` and a magnitude of zero fold to the same signed zero, - // which is why the comparison is on the controls and not on the folded result. - if ( - seed.special !== current.special || - seed.specialBy !== current.specialBy || - resolved('special') - ) - take('indentFirstLineTwips', signedFirstLineOf(current.special, current.specialBy)); - if (seed.spaceBefore !== current.spaceBefore || resolved('spaceBefore')) - take('spaceBeforePt', current.spaceBefore); - if (seed.spaceAfter !== current.spaceAfter || resolved('spaceAfter')) - take('spaceAfterPt', current.spaceAfter); - // Never while the rule is still unknown. `lineSpacing` is a rule AND a value, and a value - // without its rule is meaningless: typing 16 into "At" over a mixed selection wrote - // sixteen line-heights, because the seed's `multiple` fallback supplied a unit the user - // never chose. Picking a rule clears the disagreement and unlocks the pair. - if ( - !currentMixed.lineSpacing && - (seed.lineRule !== current.lineRule || - seed.lineValue !== current.lineValue || - resolved('lineSpacing')) - ) - take('lineSpacing', { rule: current.lineRule, value: current.lineValue }); - if (seed.contextualSpacing !== current.contextualSpacing || resolved('contextualSpacing')) - take('contextualSpacing', current.contextualSpacing); - if (seed.keepNext !== current.keepNext || resolved('keepNext')) - take('keepNext', current.keepNext); - if (seed.keepLines !== current.keepLines || resolved('keepLines')) - take('keepLines', current.keepLines); - if (seed.widowControl !== current.widowControl || resolved('widowControl')) - take('widowControl', current.widowControl); - if (seed.pageBreakBefore !== current.pageBreakBefore || resolved('pageBreakBefore')) - take('pageBreakBefore', current.pageBreakBefore); - // Same rule for the tab list, with one extra condition: the list must ALSO differ from - // the seed, or a net-zero gesture writes. "Clear all" over a mixed selection is a real - // decision and the list legitimately equals the seed there — but so does "add a stop, - // change your mind, remove it", and that used to clear every selected paragraph. - // `clearedAllTabStops` is set only by the button that says so. - if ( - !sameTabStops(seed.tabStops, current.tabStops) || - (resolved('tabStops') && current.clearedAllTabStops) - ) - take('tabStops', current.tabStops); - - return moved ? update : null; -} - -/** Add one stop, replacing any stop already at that position, and keep the list sorted. */ -export function withTabStop( - stops: readonly ParagraphTabStop[], - stop: ParagraphTabStop -): readonly ParagraphTabStop[] { - const kept = stops.filter((existing) => existing.positionTwips !== stop.positionTwips); - return [...kept, stop].sort((a, b) => a.positionTwips - b.positionTwips); -} - -/** - * Keep Tab inside the dialog. - * - * `aria-modal` tells assistive tech the rest of the page is inert; it does not stop Tab, - * so without this the third Tab lands on the document behind the dialog — which is the - * editable surface, so the next keystroke types into the paragraph being formatted. - * - * Returns true when the event was handled, so a caller only has to call `preventDefault`. - */ -export function trapTabWithin(panel: HTMLElement, event: KeyboardEvent): boolean { - if (event.key !== 'Tab') return false; - const focusable = [ - ...panel.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ), - ].filter((node) => !node.hasAttribute('disabled') && node.tabIndex !== -1); - if (focusable.length === 0) return false; - const first = focusable[0]!; - const last = focusable[focusable.length - 1]!; - const active = panel.ownerDocument.activeElement; - // Wrap at whichever end the user is walking off, and treat "focus is on the panel - // itself" as being before the first control — that is where it sits when the dialog - // has just opened. - if (event.shiftKey && (active === first || active === panel)) { - last.focus(); - return true; - } - if (!event.shiftKey && active === last) { - first.focus(); - return true; - } - return false; -} +// Shared Paragraph dialog behavior is owned by core. +export { + TWIPS_PER_INCH, + twipsToInches, + formatInches, + inchesToTwips, + type TabAlignment, + type TabLeaderName, + TAB_ALIGNMENT_LABELS, + type SpecialIndent, + specialOf, + signedFirstLineOf, + type ParagraphDialogFields, + seedFields, + type ParagraphDialogMixed, + type ParagraphFlagKey, + NO_MIXED_FIELDS, + mixedFieldsOf, + sameTabStops, + changedFields, + withTabStop, + trapTabWithin, +} from '@docx-editor.dev/core/editor'; diff --git a/packages/react/src/editor/paragraph-dialog-host.tsx b/packages/react/src/editor/paragraph-dialog-host.tsx index 10a0460f3..2a04b0b9a 100644 --- a/packages/react/src/editor/paragraph-dialog-host.tsx +++ b/packages/react/src/editor/paragraph-dialog-host.tsx @@ -1,3 +1,4 @@ +import { useDialogHost } from './dialog-host'; // Who owns the Paragraph dialog's mount. // // Not the control that opens it. The line-spacing part moves between the formatting bar and @@ -36,17 +37,22 @@ const ParagraphDialogContext = createContext(null) * cannot find a host simply renders nothing rather than mounting a dialog that will vanish. */ export function ParagraphDialogHost({ children }: { children: ReactNode }): ReactElement { + const dialogs = useDialogHost(); const [open, setOpen] = useState(false); const openerRef = useRef(null); const handle = useMemo( () => ({ open: (returnFocusTo?: HTMLElement | null) => { + if (dialogs) { + dialogs.open('paragraph', returnFocusTo); + return; + } openerRef.current = returnFocusTo ?? document.activeElement; setOpen(true); }, }), - [] + [dialogs] ); const close = useCallback(() => { diff --git a/packages/react/src/editor/useParagraphFormat.ts b/packages/react/src/editor/useParagraphFormat.ts index e8449a842..a70fb34b5 100644 --- a/packages/react/src/editor/useParagraphFormat.ts +++ b/packages/react/src/editor/useParagraphFormat.ts @@ -10,104 +10,13 @@ import type { EditorSnapshot, RunFormatting } from '@docx-editor.dev/core/contra import { useDocxEditor } from './context'; import { useEditorState } from './useEditorState'; -/** One tri-state paragraph flag: on, off, or "the selection disagrees". */ -export type ParagraphFlagState = boolean | null; - -/** One custom tab stop, as a control reads and writes it. @public */ -export interface ParagraphTabStop { - readonly positionTwips: number; - readonly alignment: 'left' | 'center' | 'right' | 'decimal' | 'bar'; - readonly leader?: 'none' | 'dot' | 'hyphen' | 'underscore' | 'heavy' | 'middleDot'; -} - -/** - * What the Paragraph dialog reads: every field, as the selection currently stands. - * - * A `null` means the selection's paragraphs DISAGREE about that field, which a control - * shows as an indeterminate checkbox or an empty box rather than as a value. `indent` is - * the exception the engine already documents — it reports the first touched paragraph and - * flags disagreement per field, because a ruler has to draw its handles somewhere. - * - * @public - */ -export interface ParagraphFormatRead { - /** - * `justify`, not OOXML's `both`. The engine speaks `w:jc` values; an adapter speaks the - * word its consumers write. Read and write use the SAME spelling here, so a value that - * comes out of `format` can go straight back into `apply`. - */ - readonly alignment: 'left' | 'center' | 'right' | 'justify' | null; - readonly spaceBeforePt: number | null; - readonly spaceAfterPt: number | null; - readonly lineSpacing: { - readonly rule: 'multiple' | 'exact' | 'atLeast'; - readonly value: number; - } | null; - readonly indentLeftTwips: number | null; - readonly indentRightTwips: number | null; - /** ONE signed first-line offset: negative is a hanging indent. */ - readonly indentFirstLineTwips: number | null; - readonly contextualSpacing: ParagraphFlagState; - readonly keepNext: ParagraphFlagState; - readonly keepLines: ParagraphFlagState; - readonly widowControl: ParagraphFlagState; - readonly pageBreakBefore: ParagraphFlagState; - /** Custom tab stops, cascade included. Null when the selection disagrees. */ - readonly tabStops: readonly ParagraphTabStop[] | null; - /** - * Which fields are `null` because the selection DISAGREES, as opposed to because nothing - * states them. - * - * A `null` alone cannot tell those apart, and both readings shipped as bugs: a - * disagreement rendered as a concrete value is uncorrectable, because the value that - * would fix it is the one already on screen; an absent value rendered as "mixed" tells a - * single paragraph it disagrees with itself. - */ - readonly disagrees: { - readonly alignment: boolean; - readonly spaceBeforePt: boolean; - readonly spaceAfterPt: boolean; - readonly lineSpacing: boolean; - readonly tabStops: boolean; - readonly indentLeft: boolean; - readonly indentRight: boolean; - readonly indentFirstLine: boolean; - }; - /** - * Whether the indent reads are UNKNOWN rather than disagreed. - * - * The engine reports no indent at all for a paragraph inside a table — correct, but not - * placeable on a ruler. A control must not call that "mixed": one paragraph cannot - * disagree with itself, and the commonest paragraph in a real document is in a cell. - */ - readonly indentUnknown: boolean; -} - -/** - * The fields `apply` accepts. Omitted fields are left as authored; `null` where allowed - * REMOVES the setting so the style supplies it again, which is not the same as a zero. - * - * @public - */ -export interface ParagraphFormatUpdate { - readonly alignment?: 'left' | 'center' | 'right' | 'justify'; - readonly spaceBeforePt?: number | null; - readonly spaceAfterPt?: number | null; - readonly lineSpacing?: { - readonly rule: 'multiple' | 'exact' | 'atLeast'; - readonly value: number; - } | null; - readonly indentLeftTwips?: number | null; - readonly indentRightTwips?: number | null; - readonly indentFirstLineTwips?: number | null; - readonly contextualSpacing?: boolean; - readonly keepNext?: boolean; - readonly keepLines?: boolean; - readonly widowControl?: boolean; - readonly pageBreakBefore?: boolean; - /** Replace the custom tab stops. An EMPTY list clears them; omit to leave them alone. */ - readonly tabStops?: readonly ParagraphTabStop[]; -} +import type { ParagraphFormatRead, ParagraphFormatUpdate } from '@docx-editor.dev/core/editor'; +export type { + ParagraphFlagState, + ParagraphTabStop, + ParagraphFormatRead, + ParagraphFormatUpdate, +} from '@docx-editor.dev/core/editor'; /** What `useParagraphFormat` returns. @public */ export interface UseParagraphFormatReturn { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7f11cccd6..b123a3854 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -436,3 +436,26 @@ export { type RulerUnit, } from './rulerTicks'; export { useEditorSnapshot } from './useEditorSnapshot'; + +export type { + DialogPartProps, + DialogCustomizationProps, + UseDialogReturn, +} from './editor/dialog-parts'; +export type { DocxEditorDialogs } from './editor/dialog-host'; +export { + usePageSetupDialog, + type PageSetupDialogFields, + type UsePageSetupDialogReturn, +} from './editor/DocxEditorPageSetup'; +export { + useParagraphDialog, + type UseParagraphDialogReturn, +} from './editor/DocxEditorParagraphDialog'; +export { + DocxEditorTextFormFieldDialog, + useTextFormFieldDialog, + type TextFormFieldDialogFields, + type DocxEditorTextFormFieldDialogProps, + type UseTextFormFieldDialogReturn, +} from './editor/DocxEditorTextFormFieldDialog'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index ff2ecaeb8..00b5a06b6 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,3 +1,4 @@ +import type { DocxEditorDialogs } from './editor/dialog-host'; import type { DocxEditorChildren } from './docx-editor-children'; import type { DocumentChange, @@ -44,6 +45,8 @@ export type { * imports ProseMirror or OOXML feature logic. */ export interface DocxEditorProps { + /** Customize dialogs opened by editor controls. */ + dialogs?: DocxEditorDialogs; /** * Immutable byte-backed font sources sampled at mount. Remount to replace this * configuration atomically. diff --git a/packages/react/test/context-menu.test.tsx b/packages/react/test/context-menu.test.tsx index b64710719..92c8a4351 100644 --- a/packages/react/test/context-menu.test.tsx +++ b/packages/react/test/context-menu.test.tsx @@ -56,8 +56,7 @@ const TABLE_2X2 = docx( 'B2' ); -const STYLE_REL = - 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles'; +const STYLE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles'; /** A package with a styles part, so a heading style resolves to an outline level. */ function docxWithStyles(body: string): Uint8Array { @@ -811,7 +810,9 @@ describe('table context rows (Task 10)', () => { expect(rowNamed(view, slot).querySelector('svg')).not.toBeNull(); } expect(rowNamed(view, 'toc.refresh').textContent).toContain('toc.refresh'); - expect(rowNamed(view, 'toc.refreshPageNumbers').textContent).toContain('toc.refreshPageNumbers'); + expect(rowNamed(view, 'toc.refreshPageNumbers').textContent).toContain( + 'toc.refreshPageNumbers' + ); }); test('the rows are there on the FIRST open, right after a menu over ordinary text', () => { @@ -826,10 +827,11 @@ describe('table context rows (Task 10)', () => { fireEvent.keyDown(document, { key: 'Escape' }); }); rightClickOn(tocRow(view)); - expect(rows(view).map((row) => row.dataset.slot).slice(-2)).toEqual([ - 'toc.refresh', - 'toc.refreshPageNumbers', - ]); + expect( + rows(view) + .map((row) => row.dataset.slot) + .slice(-2) + ).toEqual(['toc.refresh', 'toc.refreshPageNumbers']); }); test('the rows keep addressing the TOC the open captured, not the caret', () => { @@ -868,21 +870,33 @@ describe('table context rows (Task 10)', () => { }); }); - test('field context action opens shared options and saves all controls', () => { - const { view, editor } = mountDocument(docx(' FORMTEXT Sample')); + const { view, editor } = mountDocument( + docx( + ' FORMTEXT Sample' + ) + ); select(editor(), 0, 0); const field = view.container.querySelector('[data-field-atom="form"]')!; - act(() => { fireEvent.contextMenu(field, { button: 2, clientX: 30, clientY: 30 }); }); + act(() => { + fireEvent.contextMenu(field, { button: 2, clientX: 30, clientY: 30 }); + }); expect(rowNamed(view, 'field.edit').textContent).toContain('textFormField.edit'); - act(() => { fireEvent.click(rowNamed(view, 'field.edit')); }); + act(() => { + fireEvent.click(rowNamed(view, 'field.edit')); + }); const dialog = view.container.querySelector('dialog')!; expect(dialog).not.toBeNull(); const [type, format] = dialog.querySelectorAll('select'); const [text, max, enabled] = dialog.querySelectorAll('input'); act(() => { fireEvent.change(type!, { target: { value: 'number' } }); - text!.value = '12.5'; max!.value = '4'; format!.value = '0.00'; enabled!.checked = false; + fireEvent.change(text!, { target: { value: '12.5' } }); + fireEvent.change(max!, { target: { value: '4' } }); + fireEvent.change(format!, { target: { value: '0.00' } }); + fireEvent.click(enabled!); + }); + act(() => { fireEvent.click(dialog.querySelectorAll('button')[1]!); }); expect(view.container.querySelector('dialog')).toBeNull(); @@ -890,10 +904,18 @@ test('field context action opens shared options and saves all controls', () => { }); test('Shift F10 exposes field options from the keyboard selection', () => { - const { view, editor } = mountDocument(docx(' FORMTEXT Sample')); + const { view, editor } = mountDocument( + docx( + ' FORMTEXT Sample' + ) + ); select(editor(), 1, 1); - act(() => { fireEvent.keyDown(view.container.querySelector('.docx-pages')!, { key: 'F10', shiftKey: true }); }); + act(() => { + fireEvent.keyDown(view.container.querySelector('.docx-pages')!, { key: 'F10', shiftKey: true }); + }); expect(rowNamed(view, 'field.edit')).toBeDefined(); - act(() => { fireEvent.click(rowNamed(view, 'field.edit')); }); + act(() => { + fireEvent.click(rowNamed(view, 'field.edit')); + }); expect(view.container.querySelector('dialog')).not.toBeNull(); }); diff --git a/packages/react/test/dialog-customization.test.tsx b/packages/react/test/dialog-customization.test.tsx new file mode 100644 index 000000000..664db4e28 --- /dev/null +++ b/packages/react/test/dialog-customization.test.tsx @@ -0,0 +1,264 @@ +import './dom-setup.ts'; +import { afterEach, expect, test } from 'bun:test'; +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import type { ReactNode } from 'react'; +import type { DocxEditorInstance } from '@docx-editor.dev/core/editor'; +import { DocxEditorRoot } from '../src/editor/DocxEditorRoot'; +import { DocxEditorContent } from '../src/editor/DocxEditorContent'; +import { DocxEditorViewport } from '../src/editor/DocxEditorViewport'; +import { + DocxEditorPageSetupDialog as Page, + usePageSetupDialog, +} from '../src/editor/DocxEditorPageSetup'; +import { + DocxEditorParagraphDialog as Paragraph, + useParagraphDialog, +} from '../src/editor/DocxEditorParagraphDialog'; +import { DocxEditorMenu } from '../src/editor/menu/DocxEditorMenu'; +import type { DocxEditorDialogs } from '../src/editor/dialog-host'; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +afterEach(cleanup); +function mount(children?: ReactNode, dialogs?: DocxEditorDialogs) { + let editor: DocxEditorInstance; + const view = render( + { + editor = value as DocxEditorInstance; + }} + > + + + + {children} + + + ); + return { view, editor: () => editor! }; +} +test('custom Apply preserves one default action and writes one undo step', async () => { + let clicked = 0, + closed = 0; + const { view, editor } = mount( + closed++}> + + + + + ); + expect(view.container.querySelectorAll('[data-docx-part="apply"]').length).toBe(1); + await act(async () => { + fireEvent.change(view.getByLabelText('Top'), { target: { value: '0.5' } }); + }); + await act(async () => { + fireEvent.click(view.getByText('Save page')); + }); + expect(clicked).toBe(1); + expect(closed).toBe(1); + expect(editor().getPageSetup()!.marginsTwips.top).toBe(720); + await act(async () => { + editor().exec({ type: 'undo' }); + }); + expect(editor().getPageSetup()!.marginsTwips.top).toBe(1440); +}); +test('full composition retains field wiring in the supplied order', async () => { + const { view, editor } = mount( + {}} preset={false}> + + + + + + + + + + ); + const dialog = view.getByRole('dialog'); + expect(dialog.firstElementChild?.getAttribute('data-docx-part')).toBe('footer'); + expect(view.queryByLabelText('Orientation')).toBeNull(); + expect( + [...dialog.querySelectorAll('[data-docx-part="field"]')].map((e) => + e.getAttribute('data-docx-field') + ) + ).toEqual(['marginLeft', 'marginTop']); + await act(async () => { + fireEvent.change(view.getByLabelText('Left'), { target: { value: '0.25' } }); + }); + await act(async () => { + fireEvent.click(view.getByText('Apply')); + }); + expect(editor().getPageSetup()!.marginsTwips.left).toBe(360); +}); +function CustomMargin() { + const dialog = usePageSetupDialog(); + return ; +} +test('custom value controls share the default draft and refusal handling', async () => { + const { view, editor } = mount( + {}}> + + + + + ); + await act(async () => { + fireEvent.click(view.getByText('Half inch')); + }); + await act(async () => { + fireEvent.click(view.getByText('Apply')); + }); + expect(editor().getPageSetup()!.marginsTwips.top).toBe(720); +}); +test('hidden controls are removed without removing other defaults', () => { + const { view } = mount( + {}}> + + ); + expect(view.queryByText('Cancel')).toBeNull(); + expect(view.getByText('Page settings')).toBeTruthy(); + expect(view.getByText('Apply')).toBeTruthy(); +}); +test('menu opening uses per-editor customization once', async () => { + const { view } = mount(undefined, { + pageSetup: (props) => ( + + Save settings + + ), + }); + await act(async () => { + fireEvent.click(view.getByRole('menuitem', { name: 'File' })); + }); + const row = [...view.container.querySelectorAll('[role="menuitem"]')].find((e) => + e.textContent?.toLowerCase().includes('page setup') + )!; + expect(!!row).toBe(true); + await act(async () => { + fireEvent.click(row); + }); + expect(view.getByText('Save settings')).toBeTruthy(); + expect(view.container.querySelectorAll('[data-docx-dialog="pageSetup"]').length).toBe(1); +}); +function CustomRule() { + const dialog = useParagraphDialog(); + return ( + <> + + {dialog.values.lineValue} + + ); +} +test('custom paragraph rule changes use the same unit rebasing', async () => { + const { view } = mount( + {}}> + + + + + ); + await act(async () => { + fireEvent.click(view.getByText('Exact')); + }); + expect(view.getByRole('status').textContent).toBe('12'); +}); +function TogglePage() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} /> + + ); +} +test('Cancel and Escape return focus to the opener without applying', async () => { + const { view, editor } = mount(); + const opener = view.getByText('Open page'); + await act(async () => { + opener.focus(); + fireEvent.click(opener); + }); + await act(async () => { + fireEvent.change(view.getByLabelText('Top'), { target: { value: '0.5' } }); + }); + await act(async () => { + fireEvent.keyDown(view.getByRole('dialog'), { key: 'Escape' }); + }); + expect(view.queryByRole('dialog')).toBeNull(); + expect(document.activeElement === opener).toBe(true); + expect(editor().getPageSetup()!.marginsTwips.top).toBe(1440); +}); + +test('fragment-wrapped extra content remains visible beside default parts', () => { + const { view } = mount( + {}}> + <> +

Settings affect the document.

+ Layout + +
+ ); + expect(view.getByText('Settings affect the document.')).toBeTruthy(); + expect(view.getByText('Layout')).toBeTruthy(); + expect(view.getByText('Apply')).toBeTruthy(); +}); + +test('reopening a controlled dialog clears the previous refusal', async () => { + const { view } = mount(); + await act(async () => { + fireEvent.click(view.getByText('Open page')); + }); + await act(async () => { + fireEvent.change(view.getByLabelText('Top'), { target: { value: '22' } }); + }); + await act(async () => { + fireEvent.click(view.getByText('Apply')); + }); + expect(view.getByRole('alert').textContent!.length > 0).toBe(true); + await act(async () => { + fireEvent.click(view.getByText('Cancel')); + }); + await act(async () => { + fireEvent.click(view.getByText('Open page')); + }); + expect(view.getByRole('alert').textContent).toBe(''); +}); + +function ToggleParagraph() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} /> + + ); +} +test('loading a document cancels an open paragraph draft', async () => { + const { view, editor } = mount(); + await act(async () => { + fireEvent.click(view.getByText('Open paragraph')); + }); + await act(async () => { + fireEvent.change(view.container.querySelector('[data-docx-field="alignment"] select')!, { + target: { value: 'right' }, + }); + }); + await act(async () => { + editor().load('blank'); + }); + await waitFor(() => expect(view.queryAllByRole('dialog').length).toBe(0)); + await act(async () => { + fireEvent.click(view.getByText('Open paragraph')); + }); + expect( + (view.container.querySelector('[data-docx-field="alignment"] select')! as HTMLSelectElement) + .value + ).toBe('left'); +}); diff --git a/packages/vue/src/components/DocxEditor.tsx b/packages/vue/src/components/DocxEditor.tsx index db88a56ff..e081a96fe 100644 --- a/packages/vue/src/components/DocxEditor.tsx +++ b/packages/vue/src/components/DocxEditor.tsx @@ -1,3 +1,4 @@ +import { DocxEditorTextFormFieldDialog } from '../editor/DocxEditorTextFormFieldDialog'; import { computed, defineComponent, @@ -147,6 +148,7 @@ const ScopedChrome = defineComponent({ /** @public */ export interface DocxEditorNamespace { (props: DocxEditorProps): VNode; + readonly TextFormFieldDialog: typeof DocxEditorTextFormFieldDialog; readonly Root: typeof DocxEditorRoot; readonly Viewport: typeof DocxEditorViewport; readonly Content: typeof DocxEditorContent; @@ -174,6 +176,7 @@ export interface DocxEditorNamespace { } const docxEditorFrameProps = { + dialogs: Object as PropType, document: { type: [String, Object, Uint8Array, ArrayBuffer] as PropType, default: undefined, @@ -420,6 +423,7 @@ const DocxEditorFrame = defineComponent({ ...(props.author !== undefined ? { author: props.author } : {}), ...(props.locale !== undefined ? { locale: props.locale } : {}), ...(props.dateInputOrder !== undefined ? { dateInputOrder: props.dateInputOrder } : {}), + dialogs: props.dialogs, translate: t, ...(props.mode !== undefined ? { mode: props.mode } : { mode: 'edit' }), ...(props.modules !== undefined ? { modules: props.modules } : {}), @@ -528,6 +532,7 @@ const DocxEditorImpl = defineComponent({ /** @public */ export const DocxEditor = Object.assign(DocxEditorImpl, { + TextFormFieldDialog: DocxEditorTextFormFieldDialog, Root: DocxEditorRoot, Viewport: DocxEditorViewport, Content: DocxEditorContent, diff --git a/packages/vue/src/editor/DocxEditorContent.ts b/packages/vue/src/editor/DocxEditorContent.ts index b648ccbbb..1f76f3186 100644 --- a/packages/vue/src/editor/DocxEditorContent.ts +++ b/packages/vue/src/editor/DocxEditorContent.ts @@ -1,3 +1,4 @@ +import { useDialogHost } from './dialog-host'; import { defineComponent, h, @@ -34,6 +35,7 @@ export const DocxEditorContent = defineComponent({ className: { type: String, default: undefined }, }, setup(props) { + const dialogs = useDialogHost(); const editorRef = useDocxEditor(); const imageInsert = useImageInsertOptional(); const elementRef = shallowRef(null); @@ -105,6 +107,7 @@ export const DocxEditorContent = defineComponent({ { ref: (el: unknown) => { portalRef.value = el instanceof HTMLDivElement ? el : null; + if (dialogs) dialogs.target.value = portalRef.value; }, class: 'docx-content-mount', }, diff --git a/packages/vue/src/editor/DocxEditorPageSetup.tsx b/packages/vue/src/editor/DocxEditorPageSetup.tsx index 6e2502270..050f1d2e5 100644 --- a/packages/vue/src/editor/DocxEditorPageSetup.tsx +++ b/packages/vue/src/editor/DocxEditorPageSetup.tsx @@ -1,4 +1,20 @@ -import { defineComponent, ref, watch, type CSSProperties, type PropType } from 'vue'; +import { + Fragment, + computed, + cloneVNode, + defineComponent, + ref, + watch, + type CSSProperties, + type PropType, +} from 'vue'; +import { + createDialogComposition, + NativeDialog, + useDialogGeneration, + type DialogCustomizationProps, + type UseDialogReturn, +} from './dialog-parts'; import { useTranslation } from '../i18n'; import { usePageSetup } from './usePageSetup'; @@ -28,43 +44,47 @@ function findPageSizeIndex(w: number, h: number): number { ); } -const overlayStyle: CSSProperties = { - position: 'fixed', - inset: 0, - backgroundColor: 'var(--doc-overlay)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: 10000, -}; - -const dialogStyle: CSSProperties = { - backgroundColor: 'var(--doc-surface)', - borderRadius: '8px', - boxShadow: '0 4px 20px var(--doc-shadow)', - minWidth: '400px', - maxWidth: '480px', - width: '100%', - margin: '20px', -}; +/** Page Setup draft values in twips. @public */ +export interface PageSetupDialogFields { + pageWidth: number; + pageHeight: number; + orientation: 'portrait' | 'landscape'; + marginTop: number; + marginBottom: number; + marginLeft: number; + marginRight: number; + scope: 'document' | 'section'; +} +/** Page Setup draft state and actions. @public */ +export interface UsePageSetupDialogReturn extends UseDialogReturn {} +const composition = createDialogComposition< + PageSetupDialogFields, + Exclude | 'pageSize' +>('PageSetupDialog'); +/** Access the current Page Setup draft and actions. @public */ +export function usePageSetupDialog(): UsePageSetupDialogReturn { + return composition.useContext(); +} /** @public */ -export interface DocxEditorPageSetupDialogProps { +export interface DocxEditorPageSetupDialogProps extends DialogCustomizationProps { open: boolean; onClose: () => void; - className?: string; } /** @public */ -export const DocxEditorPageSetupDialog = defineComponent({ +const PageSetupDialogImpl = defineComponent({ name: 'DocxEditorPageSetupDialog', props: { open: { type: Boolean, required: true }, onClose: { type: Function as PropType<() => void>, required: true }, className: { type: String, default: undefined }, + style: Object as PropType, + preset: { type: Boolean, default: true }, }, - setup(props) { + setup(props, { slots }) { const { t } = useTranslation(); + const currentGeneration = useDialogGeneration(() => props.open, props.onClose); const setup = usePageSetup(); const pageWidth = ref(DEFAULT_WIDTH); const pageHeight = ref(DEFAULT_HEIGHT); @@ -74,13 +94,14 @@ export const DocxEditorPageSetupDialog = defineComponent({ const marginLeft = ref(DEFAULT_MARGIN); const marginRight = ref(DEFAULT_MARGIN); const scope = ref<'document' | 'section'>('document'); - const panelRef = ref(null); + const refused = ref(false); const seeded = ref<'no' | 'loading' | 'yes'>('no'); watch( [() => props.open, () => setup.pageSetup.value], ([open]) => { if (!open) { + refused.value = false; seeded.value = 'no'; return; } @@ -100,15 +121,7 @@ export const DocxEditorPageSetupDialog = defineComponent({ scope.value = 'document'; seeded.value = ps === null ? 'loading' : 'yes'; }, - { flush: 'post' } - ); - - watch( - () => props.open, - (open) => { - if (open) panelRef.value?.focus(); - }, - { flush: 'post' } + { flush: 'post', immediate: true } ); const handlePageSizeChange = (index: number) => { @@ -128,6 +141,7 @@ export const DocxEditorPageSetupDialog = defineComponent({ }; const handleApply = () => { + if (!currentGeneration()) return; const accepted = setup.apply({ pageWidthTwips: pageWidth.value, pageHeightTwips: pageHeight.value, @@ -138,223 +152,179 @@ export const DocxEditorPageSetupDialog = defineComponent({ marginLeftTwips: marginLeft.value, scope: scope.value, }); + refused.value = !accepted; if (accepted) props.onClose(); }; + const fields = { + pageWidth, + pageHeight, + orientation, + marginTop, + marginBottom, + marginLeft, + marginRight, + scope, + }; + const renderParts = composition.provideContext({ + values: computed( + () => + ({ + ...Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, value.value])), + }) as unknown as PageSetupDialogFields + ), + errors: computed(() => (refused.value ? { form: t('dialogs.paragraph.refused') } : {})), + isEnabled: setup.isEnabled, + setValue: (name, value) => { + if (name === 'orientation') handleOrientationChange(value as 'portrait' | 'landscape'); + else (fields[name as keyof typeof fields] as { value: unknown }).value = value; + }, + apply: handleApply, + cancel: props.onClose, + }); return () => { if (!props.open) return null; const sizeIndex = findPageSizeIndex(pageWidth.value, pageHeight.value); - const rowStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: '12px' }; - const labelStyle: CSSProperties = { - width: '80px', - fontSize: '13px', - color: 'var(--doc-text-muted)', - }; - const inputStyle: CSSProperties = { - flex: 1, - padding: '6px 8px', - border: '1px solid var(--doc-border)', - borderRadius: '4px', - fontSize: '13px', - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', - }; - - const marginRow = ( - labelKey: 'top' | 'bottom' | 'left' | 'right', - value: number, - set: (twips: number) => void - ) => ( -
- - - set(Math.max(0, inchesToTwips(Number((event.target as HTMLInputElement).value) || 0))) - } - aria-label={t(`dialogs.pageSetup.${labelKey}`)} - /> - - in - -
+ const field = (name: string, label: string, input: import('vue').VNode) => ( + ); - - return ( -
{ - if (event.key === 'Escape') props.onClose(); - if (event.key === 'Enter') handleApply(); - }} - > -
event.stopPropagation()} - onMousedown={(event) => event.stopPropagation()} - role="dialog" - aria-modal="true" - aria-label={t('dialogs.pageSetup.title')} - > -
+ const margins = ['top', 'bottom', 'left', 'right'] as const; + const defaults = ( + +
+ {t('dialogs.pageSetup.title')} -
-
-
+
+
+ + {field( + 'pageSize', + t('dialogs.pageSetup.sizeLabel'), + - handlePageSizeChange(Number((event.target as HTMLSelectElement).value)) - } - aria-label={t('dialogs.pageSetup.sizeLabel')} - > - {PAGE_SIZES.map((size, index) => ( - - ))} - {sizeIndex < 0 && } - -
-
- - -
-
( + + ))} + {sizeIndex < 0 ? : null} + + )} + {field( + 'orientation', + t('dialogs.pageSetup.orientation'), + { - scope.value = (event.target as HTMLSelectElement).value as - | 'document' - | 'section'; - }} - aria-label={t('dialogs.pageSetup.applyTo')} - > - - - -
+ + + + )} + -
- -
-
+ +
+ ); + return ( + { + if ( + event.key === 'Enter' && + !event.isComposing && + !(event.target instanceof HTMLButtonElement) && + !(event.target instanceof HTMLSelectElement) && + setup.isEnabled.value + ) { + event.preventDefault(); + handleApply(); + } + }} + content={() => renderParts(defaults, slots.default?.() ?? [], props.preset)} + /> ); }; }, }); +/** Customizable Page Setup dialog. @public */ +export const DocxEditorPageSetupDialog = Object.assign(PageSetupDialogImpl, composition.parts); diff --git a/packages/vue/src/editor/DocxEditorParagraphDialog.tsx b/packages/vue/src/editor/DocxEditorParagraphDialog.tsx index 603d790e4..6d26f1764 100644 --- a/packages/vue/src/editor/DocxEditorParagraphDialog.tsx +++ b/packages/vue/src/editor/DocxEditorParagraphDialog.tsx @@ -4,15 +4,23 @@ // step. The host owns visibility (`open`/`onClose`); the engine owns everything else. import { + Fragment, + computed, defineComponent, getCurrentInstance, - nextTick, ref, watch, type CSSProperties, type PropType, + type Ref, } from 'vue'; -import { Teleport } from 'vue'; +import { + createDialogComposition, + NativeDialog, + useDialogGeneration, + type DialogCustomizationProps, + type UseDialogReturn, +} from './dialog-parts'; import { useTranslation } from '../i18n'; import { useParagraphFormat, type ParagraphTabStop } from './useParagraphFormat'; import { @@ -22,7 +30,6 @@ import { mixedFieldsOf, NO_MIXED_FIELDS, seedFields, - trapTabWithin, TAB_ALIGNMENT_LABELS, twipsToInches, withTabStop, @@ -34,141 +41,38 @@ import { type TabLeaderName, } from './paragraph-dialog-fields'; -const refusedStyle: CSSProperties = { - marginRight: 'auto', - fontSize: '12px', - color: 'var(--doc-danger)', -}; - -const overlayStyle: CSSProperties = { - position: 'fixed', - inset: 0, - backgroundColor: 'var(--doc-overlay)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: 10000, -}; - -const dialogStyle: CSSProperties = { - backgroundColor: 'var(--doc-surface)', - borderRadius: '8px', - boxShadow: '0 4px 20px var(--doc-shadow)', - minWidth: '620px', - maxWidth: '720px', - width: '100%', - margin: '20px', - maxHeight: '90vh', - // The panel is a column with a scrolling middle, NOT one scrolling box. Scrolling the - // whole panel put OK and Cancel below the fold on an ordinary laptop viewport: the form - // simply ended mid-control with no button and no scrollbar cue that more existed. - display: 'flex', - flexDirection: 'column', - minHeight: 0, -}; - -const headerStyle: CSSProperties = { - padding: '16px 20px 12px', - borderBottom: '1px solid var(--doc-border)', - flexShrink: 0, - fontSize: '16px', - fontWeight: 600, - color: 'var(--doc-text)', -}; - -const bodyStyle: CSSProperties = { - padding: '16px 20px', - // The one part that scrolls, so the header and the buttons stay put. - overflowY: 'auto', - minHeight: 0, -}; - -// Two columns, the way Word lays this dialog out: General and Indentation and the tab -// stops on the left, Spacing and Pagination on the right. It halves the height, so the -// whole form fits an ordinary viewport without scrolling. -const columnsStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: '28px', -}; -const columnStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', - gap: '14px', - minWidth: 0, -}; - -const sectionLabelStyle: CSSProperties = { - fontSize: '12px', - fontWeight: 600, - color: 'var(--doc-text-muted)', - textTransform: 'uppercase', - letterSpacing: '0.5px', -}; - -const rowStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: '12px' }; -const labelStyle: CSSProperties = { - width: '92px', - fontSize: '13px', - color: 'var(--doc-text-muted)', -}; -const inputStyle: CSSProperties = { - flex: 1, - padding: '6px 8px', - border: '1px solid var(--doc-border)', - borderRadius: '4px', - fontSize: '13px', - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', -}; -const unitStyle: CSSProperties = { - fontSize: '11px', - color: 'var(--doc-text-muted)', - width: '20px', -}; -const checkRowStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '13px', - color: 'var(--doc-text)', -}; -const footerStyle: CSSProperties = { - padding: '12px 20px 16px', - borderTop: '1px solid var(--doc-border)', - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - gap: '8px', - flexShrink: 0, -}; -const btnStyle: CSSProperties = { - padding: '6px 16px', - fontSize: '13px', - border: '1px solid var(--doc-border)', - borderRadius: '4px', - cursor: 'pointer', - backgroundColor: 'var(--doc-surface)', - color: 'var(--doc-text)', -}; +const composition = createDialogComposition< + ParagraphDialogFields, + Exclude +>('ParagraphDialog'); +/** Paragraph draft state, mixed values, and actions. @public */ +export interface UseParagraphDialogReturn extends UseDialogReturn { + readonly mixed: Readonly>; +} +/** Access the current Paragraph draft and actions. @public */ +export function useParagraphDialog(): UseParagraphDialogReturn { + return composition.useContext() as UseParagraphDialogReturn; +} /** Props for `DocxEditorParagraphDialog`. @public */ -export interface DocxEditorParagraphDialogProps { +export interface DocxEditorParagraphDialogProps extends DialogCustomizationProps { open: boolean; onClose: () => void; - className?: string; } /** The Paragraph dialog, applied as one undoable command. @public */ -export const DocxEditorParagraphDialog = defineComponent({ +const ParagraphDialogImpl = defineComponent({ name: 'DocxEditorParagraphDialog', props: { open: { type: Boolean, required: true }, onClose: { type: Function as PropType<() => void>, required: true }, className: { type: String, default: undefined }, + style: Object as PropType, + preset: { type: Boolean, default: true }, }, - setup(props) { + setup(props, { slots }) { const { t } = useTranslation(); + const currentGeneration = useDialogGeneration(() => props.open, props.onClose); const paragraph = useParagraphFormat(); const alignment = ref<'left' | 'center' | 'right' | 'justify'>('left'); @@ -200,11 +104,6 @@ export const DocxEditorParagraphDialog = defineComponent({ // hoistable, and Vue refuses a `ref` on a hoisted vnode ("Missing ref owner context"), // which left the dialog opening unfocused and Escape doing nothing. const instance = getCurrentInstance(); - // Queried from the document, not the component's own element: the dialog teleports to - // the body, so `instance.vnode.el` is only the teleport's anchor comment. One dialog is - // open per host at a time, and the `Paragraph` label disambiguates it. - const panelOf = (): HTMLElement | null => - document.querySelector('[role="dialog"][aria-label="' + t('dialogs.paragraph.title') + '"]'); const refused = ref(false); // One prefix per mounted dialog, so a `