diff --git a/.changeset/dialog-customization.md b/.changeset/dialog-customization.md new file mode 100644 index 000000000..e07daa65f --- /dev/null +++ b/.changeset/dialog-customization.md @@ -0,0 +1,5 @@ +--- +'@docx-editor.dev/react': minor +--- + +Customize React and Vue popups with ordinary components through `definePopup()` or with typed render callbacks. Use existing parts and draft hooks to retain automatic behavior, or disable automatic rendering for manual ownership. Includes composable Page Setup, Paragraph Options, and Field Options dialogs. Fixes #771. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3612704b..1fae2490b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,12 +210,6 @@ jobs: - name: Parity gates run: bun run check:parity - # Fails when React/Vue adapter drift diverges from the pinned - # baseline in scripts/feature-parity-baseline.json, in either - # direction. Update the baseline with --update-baseline. - - name: Feature parity gate - run: bun run check:feature-parity - # Same generator the release runs, here as a gate rather than for its # output: it fails when a newly bundled dependency ships no license text, # or when a tsup config stops emitting the metafile it reads. Catching diff --git a/docs/CUSTOMIZING.md b/docs/CUSTOMIZING.md index a3a55dccb..d3c854fec 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 `popups` configuration for automatically opened instances. +See [Customize popups](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 diff --git a/docs/api/docx-editor-core/editor.api.md b/docs/api/docx-editor-core/editor.api.md index a16839cb4..f9836d8aa 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 [{ @@ -828,6 +833,37 @@ export function computeResizedImageExtentEmu(startWidthEmu: number, startHeightE readonly cy: number; }; +// @public +export interface ContentControlWidgetChromeHandlers { + // (undocumented) + readonly onRequest?: (session: ContentControlWidgetSession) => void; +} + +// @public +export interface ContentControlWidgetSession { + // (undocumented) + readonly anchor: HTMLElement | null; + // (undocumented) + apply(value: string): boolean; + // (undocumented) + canApply(): boolean; + // (undocumented) + cancel(): void; + // (undocumented) + readonly controlId: string; + // (undocumented) + readonly items: readonly { + readonly displayText: string; + readonly value: string; + }[]; + // (undocumented) + readonly kind: 'dropdown' | 'comboBox' | 'date'; + // (undocumented) + readonly signal: AbortSignal; + // (undocumented) + readonly value: string; +} + // @public export function createBrowserAutomationHost(editor: DocxEditorInstance): AutomationHost; @@ -926,13 +962,16 @@ export interface DocxEditorInstance extends Editor { presenceColorFor(name: string): string; setAllReviewAuthorsVisible(visible: boolean): void; setAuthor(author: string | undefined): void; + setContentControlWidgetChrome(handlers: ContentControlWidgetChromeHandlers, options?: PopupChromeRegistrationOptions): Unsubscribe; setEquationChrome(handlers: EquationChromeHandlers): Unsubscribe; setHyperlinkChrome(handlers: HyperlinkChromeHandlers): Unsubscribe; + setInvalidTextFormFieldChrome(handlers: InvalidTextFormFieldChromeHandlers, options?: PopupChromeRegistrationOptions): Unsubscribe; setLocale(locale: string | undefined): void; setMode(mode: 'edit' | 'view' | 'suggesting' | undefined): void; setRemoteCaretLabelHost(host: RemoteCaretLabelHost | null): void; setReviewAuthorVisible(author: string, visible: boolean): void; setRevisionStyles(styles: RevisionStyles): void; + setTextFormFieldChrome(handlers: TextFormFieldChromeHandlers, options?: PopupChromeRegistrationOptions): Unsubscribe; setTranslate(translate: ((key: string, params?: Record) => string) | undefined): void; showAllReviewAuthors(): void; stateVersion(): number; @@ -1162,6 +1201,9 @@ export interface FontUrlSource { readonly weight: number; } +// @public +export const formatInches: (twips: number) => string; + // @public export function formattingBarChromeGroups(image: ImageContext | null): readonly ChromeGroup[]; @@ -1339,6 +1381,25 @@ 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 interface InvalidTextFormFieldChromeHandlers { + // (undocumented) + readonly onRequest?: (session: InvalidTextFormFieldSession) => void; +} + +// @public +export interface InvalidTextFormFieldSession { + acknowledge(): void; + cancel(): void; + // (undocumented) + readonly signal: AbortSignal; + // (undocumented) + readonly type: 'number' | 'date'; +} + // @public export function isFontResolver(value: unknown): value is MarkedFontResolver; @@ -1386,12 +1447,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; @@ -1700,8 +1767,13 @@ export interface PaginatedSurfaceOptions { // (undocumented) readonly onEquationPopover?: (activation: EquationActivation) => void; readonly onHyperlinkPopover?: (activation: HyperlinkActivation) => void; + // (undocumented) + readonly onRequestContentControlWidget?: (session: ContentControlWidgetSession) => boolean; readonly onRequestHyperlink?: () => void; // (undocumented) + readonly onRequestInvalidTextFormField?: (session: InvalidTextFormFieldSession) => boolean; + readonly onRequestTextFormField?: (session: TextFormFieldDialogSession) => boolean; + // (undocumented) readonly onToggleParagraphMarks?: () => void; readonly pointer?: 'engine' | 'native'; readonly producer?: string; @@ -1742,6 +1814,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) @@ -1756,6 +1895,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; @@ -1780,6 +1995,11 @@ export function partOfNodeId(session: Pick number; + // @public export const SNAP_TWIPS_CM: number; @@ -2145,6 +2374,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'; @@ -2293,6 +2528,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[]; @@ -2305,6 +2552,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) @@ -2377,6 +2627,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): { @@ -2412,6 +2686,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) @@ -2521,6 +2798,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; @@ -2544,6 +2824,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 3fb37ceac..3f7851eec 100644 --- a/docs/api/docx-editor-core/index.api.md +++ b/docs/api/docx-editor-core/index.api.md @@ -1144,13 +1144,16 @@ export interface DocxEditorInstance extends Editor { presenceColorFor(name: string): string; setAllReviewAuthorsVisible(visible: boolean): void; setAuthor(author: string | undefined): void; + setContentControlWidgetChrome(handlers: ContentControlWidgetChromeHandlers, options?: PopupChromeRegistrationOptions): Unsubscribe; setEquationChrome(handlers: EquationChromeHandlers): Unsubscribe; setHyperlinkChrome(handlers: HyperlinkChromeHandlers): Unsubscribe; + setInvalidTextFormFieldChrome(handlers: InvalidTextFormFieldChromeHandlers, options?: PopupChromeRegistrationOptions): Unsubscribe; setLocale(locale: string | undefined): void; setMode(mode: 'edit' | 'view' | 'suggesting' | undefined): void; setRemoteCaretLabelHost(host: RemoteCaretLabelHost | null): void; setReviewAuthorVisible(author: string, visible: boolean): void; setRevisionStyles(styles: RevisionStyles): void; + setTextFormFieldChrome(handlers: TextFormFieldChromeHandlers, options?: PopupChromeRegistrationOptions): 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 eb388e89f..e5b6e278c 100644 --- a/docs/api/docx-editor-react/index.api.md +++ b/docs/api/docx-editor-react/index.api.md @@ -16,10 +16,12 @@ import { ChromeMenuSubmenuEntry } from '@docx-editor.dev/core/editor'; import { ChromeSlotId } from '@docx-editor.dev/core/editor'; import { ColorValue } from '@docx-editor.dev/core/contracts/editor'; import { commandForSlot } from '@docx-editor.dev/core/editor'; +import { ComponentType } from 'react'; import { composeFontConfiguration } from '@docx-editor.dev/core/editor'; import { composeFontOrigins } from '@docx-editor.dev/core/editor'; import { ContentControlSummary } from '@docx-editor.dev/core'; import { ContentControlType } from '@docx-editor.dev/core'; +import { ContentControlWidgetSession } from '@docx-editor.dev/core/editor'; import { createFontSource } from '@docx-editor.dev/core/editor'; import { CSSProperties } from 'react'; import { defineFontResolver } from '@docx-editor.dev/core/editor'; @@ -57,6 +59,7 @@ import { HTMLAttributes } from 'react'; import { ImageDecodePort } from '@docx-editor.dev/core/editor'; import { ImageWrapTarget } from '@docx-editor.dev/core/editor'; import { IndentFormatting } from '@docx-editor.dev/core/contracts/editor'; +import { InvalidTextFormFieldSession } from '@docx-editor.dev/core/editor'; import { isFontResolver } from '@docx-editor.dev/core/editor'; import { loadFonts } from '@docx-editor.dev/core/editor'; import { LoadFontsRequest } from '@docx-editor.dev/core/editor'; @@ -68,6 +71,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 +99,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 +357,34 @@ export { createFontSource } export { defineFontResolver } +// @public +export function definePopup(component: ComponentType): (props: Props) => DocxEditorChildren; + +// @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; @@ -390,6 +428,21 @@ export interface DocxEditorContentControlNamespace { readonly Remove: typeof ContentControlRemove; } +// @public +export function DocxEditorContentControlWidget(props: DocxEditorContentControlWidgetProps): react.JSX.Element; + +// @public +export interface DocxEditorContentControlWidgetProps { + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + session: ContentControlWidgetSession; + // (undocumented) + style?: CSSProperties; +} + // @public export interface DocxEditorContentProps { // (undocumented) @@ -517,6 +570,29 @@ export interface DocxEditorHyperLinkNamespace { readonly Url: typeof HyperLinkUrl; } +// @public +export function DocxEditorImageAltTextPopup(input: DocxEditorImageAltTextPopupProps): react.JSX.Element; + +// @public +export interface DocxEditorImageAltTextPopupProps { + // (undocumented) + anchorRef?: RefObject; + // (undocumented) + className?: string; + // (undocumented) + id: string; + // (undocumented) + isEnabled: boolean; + // (undocumented) + onApply(): void; + // (undocumented) + onClose(): void; + // (undocumented) + onValueChange(value: string): void; + // (undocumented) + value: string; +} + // @public export function DocxEditorImagePropertiesDialog(input: DocxEditorImagePropertiesDialogProps): react.JSX.Element | null; @@ -532,6 +608,21 @@ export interface DocxEditorImagePropertiesDialogProps { triggerRef?: RefObject; } +// @public +export function DocxEditorInvalidTextFormFieldDialog(props: DocxEditorInvalidTextFormFieldDialogProps): react.JSX.Element; + +// @public +export interface DocxEditorInvalidTextFormFieldDialogProps { + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + session: InvalidTextFormFieldSession; + // (undocumented) + style?: CSSProperties; +} + // @public export const DocxEditorLoading: DocxEditorLoadingComponent; @@ -620,7 +711,9 @@ export interface DocxEditorNamespace extends ForwardRefExoticComponent void; + // (undocumented) + readonly onClose: () => void; +} + // @public (undocumented) export function DocxEditorNotesChrome(input: DocxEditorNotesChromeProps): ReactElement | null; @@ -690,6 +825,47 @@ export interface DocxEditorNotesChromeProps { className?: string; } +// @public +export function DocxEditorNotesContextMenu(input: DocxEditorNotesContextMenuProps): react.JSX.Element; + +// @public +export interface DocxEditorNotesContextMenuProps { + // (undocumented) + className?: string; + // (undocumented) + convertAllDisabledReason?: string; + // (undocumented) + convertAllEnabled: boolean; + // (undocumented) + convertDisabledReason?: string; + // (undocumented) + convertEnabled: boolean; + // (undocumented) + deleteDisabledReason?: string; + // (undocumented) + deleteEnabled: boolean; + // (undocumented) + noteId: number; + // (undocumented) + noteKind: 'footnote' | 'endnote'; + // (undocumented) + onClose(): void; + // (undocumented) + onConvert(): void; + // (undocumented) + onConvertAll(): void; + // (undocumented) + onDelete(): void; + // (undocumented) + onOpenProperties(): void; + // (undocumented) + scopeId: string; + // (undocumented) + x: number; + // (undocumented) + y: number; +} + // @public export function DocxEditorPageNumber(input: DocxEditorPageNumberProps): react.JSX.Element | null; @@ -700,27 +876,108 @@ export interface DocxEditorPageNumberProps { } // @public -export function DocxEditorPageSetupDialog(input: DocxEditorPageSetupDialogProps): ReactElement | null; +export const DocxEditorPageSetupDialog: typeof PageSetupDialogRoot & { + Apply: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Body: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Cancel: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Error: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Field: (props: DialogPartProps & { + name: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop"; + }) => react.ReactNode; + Footer: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Header: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "marginLeft" | "marginRight" | "marginBottom" | "marginTop" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Title: (props: DialogPartProps & { + name?: "pageSize" | "orientation" | "scope" | "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" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Body: (props: DialogPartProps & { + name?: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Cancel: (props: DialogPartProps & { + name?: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Error: (props: DialogPartProps & { + name?: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Field: (props: DialogPartProps & { + name: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue"; + }) => react.ReactNode; + Footer: (props: DialogPartProps & { + name?: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Header: (props: DialogPartProps & { + name?: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "indentLeft" | "indentRight" | "specialBy" | "lineValue" | undefined; + }) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | react.JSX.Element | null; + Title: (props: DialogPartProps & { + name?: "alignment" | "special" | "spaceBefore" | "spaceAfter" | "contextualSpacing" | "keepNext" | "keepLines" | "widowControl" | "pageBreakBefore" | "tabStops" | "lineRule" | "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; } +// @public +export type DocxEditorPopup = false | ((props: Props) => DocxEditorChildren | null); + +// @public +export interface DocxEditorPopups { + // (undocumented) + contentControl?: DocxEditorPopup; + // (undocumented) + contentControlWidget?: DocxEditorPopup; + // (undocumented) + contextMenu?: DocxEditorPopup; + // (undocumented) + equation?: DocxEditorPopup>; + // (undocumented) + hyperlink?: DocxEditorPopup; + // (undocumented) + imageAltText?: DocxEditorPopup; + // (undocumented) + imageProperties?: DocxEditorPopup; + // (undocumented) + invalidTextFormField?: DocxEditorPopup; + // (undocumented) + notePreview?: DocxEditorPopup; + // (undocumented) + noteProperties?: DocxEditorPopup; + // (undocumented) + notesContextMenu?: DocxEditorPopup; + // (undocumented) + pageSetup?: DocxEditorPopup; + // (undocumented) + paragraph?: DocxEditorPopup; + // (undocumented) + textFormField?: DocxEditorPopup; +} + // @public export interface DocxEditorProps { // (undocumented) @@ -746,6 +1003,7 @@ export interface DocxEditorProps { onReady?: (editor: Editor) => void; onSave?: () => void; onTitleChange?: (title: string) => void; + popups?: DocxEditorPopups; readonly renderTitleBarLeft?: () => DocxEditorChildren; // (undocumented) readonly renderTitleBarRight?: () => DocxEditorChildren; @@ -799,6 +1057,7 @@ export interface DocxEditorRootProps { onChange?: (change: DocumentChange) => void; onFontError?: (error: EditorFontError) => void; onReady?: (editor: Editor) => void; + popups?: DocxEditorPopups; tableInteractionLabel?: (key: 'table.insertRowBelow' | 'table.insertColumnRight') => string; translate?: (key: string, params?: Record) => string; zoom?: number; @@ -851,6 +1110,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; @@ -1528,6 +1821,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) @@ -1622,81 +1935,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 { @@ -1747,15 +1990,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; @@ -1927,6 +2162,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; @@ -2156,6 +2405,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; @@ -2330,6 +2595,13 @@ export function useNoteScopeState(): Extract { +} + // @public export interface UsePageSetupReturn { readonly apply: (update: PageSetupUpdate) => boolean; @@ -2337,6 +2609,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; @@ -2383,6 +2664,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 9b3231944..a7a71629f 100644 --- a/docs/api/docx-editor-vue/index.api.md +++ b/docs/api/docx-editor-vue/index.api.md @@ -4,6 +4,7 @@ ```ts +import { AllowedComponentProps } from 'vue'; import { CHROME_GROUPS } from '@docx-editor.dev/core/editor'; import { CHROME_MENUS } from '@docx-editor.dev/core/editor'; import { ChromeMenu } from '@docx-editor.dev/core/editor'; @@ -16,11 +17,14 @@ import { ChromeMenuSubmenuEntry } from '@docx-editor.dev/core/editor'; import { ChromeSlotId } from '@docx-editor.dev/core/editor'; import { ColorValue } from '@docx-editor.dev/core/contracts/editor'; import { commandForSlot } from '@docx-editor.dev/core/editor'; +import { Component } from 'vue'; +import { ComponentCustomProps } from 'vue'; import { composeFontConfiguration } from '@docx-editor.dev/core/editor'; import { composeFontOrigins } from '@docx-editor.dev/core/editor'; import { ComputedRef } from 'vue'; import { ContentControlSummary } from '@docx-editor.dev/core'; import { ContentControlType } from '@docx-editor.dev/core'; +import { ContentControlWidgetSession } from '@docx-editor.dev/core/editor'; import { createFontSource } from '@docx-editor.dev/core/editor'; import { CSSProperties } from 'vue'; import { defineFontResolver } from '@docx-editor.dev/core/editor'; @@ -54,11 +58,13 @@ import { FontResolverMark } from '@docx-editor.dev/core/editor'; import { FontSource } from '@docx-editor.dev/core/contracts/editor'; import { FontSourceSubstitution } from '@docx-editor.dev/core/contracts/editor'; import { FontUrlSource } from '@docx-editor.dev/core/editor'; +import { FunctionalComponent } from 'vue'; import { generateRulerTicks } from '@docx-editor.dev/core/editor'; import { ImageDecodePort } from '@docx-editor.dev/core/editor'; import { ImageWrapTarget } from '@docx-editor.dev/core/editor'; import { IndentFormatting } from '@docx-editor.dev/core/contracts/editor'; import { InjectionKey } from 'vue'; +import { InvalidTextFormFieldSession } from '@docx-editor.dev/core/editor'; import { isFontResolver } from '@docx-editor.dev/core/editor'; import { loadFonts } from '@docx-editor.dev/core/editor'; import { LoadFontsRequest } from '@docx-editor.dev/core/editor'; @@ -72,6 +78,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 +103,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'; @@ -101,6 +114,7 @@ import { TranslationKey } from '@docx-editor.dev/i18n'; import { Translations } from '@docx-editor.dev/i18n'; import { ViewScope } from '@docx-editor.dev/core/contracts/editor'; import { VNode } from 'vue'; +import { VNodeProps } from 'vue'; import * as vue from 'vue'; import * as vue_jsx_runtime from 'vue/jsx-runtime'; import { WORD_DEFAULT_FONT } from '@docx-editor.dev/core/editor'; @@ -1994,6 +2008,39 @@ export { createFontSource } export { defineFontResolver } +// @public +export function definePopup>(component: C): (props: C extends new (...args: never[]) => { + $props: infer P; +} ? Omit ? (VNodeProps & AllowedComponentProps & ComponentCustomProps)[K] extends P[K] ? K : never : never : never; +}[keyof P]> : C extends (props: infer P, ...args: never[]) => unknown ? P : never) => DocxEditorChildren; + +// @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; + className: StringConstructor; + session: { + required: true; + type: PropType; + }; + style: PropType; +}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly; + className: StringConstructor; + session: { + required: true; + type: PropType; + }; + style: PropType; +}>> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + +// @public +export interface DocxEditorContentControlWidgetProps { + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + session: ContentControlWidgetSession; + // (undocumented) + style?: CSSProperties; +} + // @public (undocumented) export interface DocxEditorContentProps { // (undocumented) @@ -2449,6 +2527,83 @@ export interface DocxEditorHyperLinkNamespace { readonly Url: typeof HyperLinkUrl; } +// @public +export const DocxEditorImageAltTextPopup: vue.DefineComponent>; + className: StringConstructor; + id: { + required: true; + type: StringConstructor; + }; + isEnabled: { + required: true; + type: BooleanConstructor; + }; + onApply: { + required: true; + type: PropType<() => void>; + }; + onClose: { + required: true; + type: PropType<() => void>; + }; + onValueChange: { + required: true; + type: PropType<(value: string) => void>; + }; + value: { + required: true; + type: StringConstructor; + }; +}>, () => vue_jsx_runtime.JSX.Element, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly>; + className: StringConstructor; + id: { + required: true; + type: StringConstructor; + }; + isEnabled: { + required: true; + type: BooleanConstructor; + }; + onApply: { + required: true; + type: PropType<() => void>; + }; + onClose: { + required: true; + type: PropType<() => void>; + }; + onValueChange: { + required: true; + type: PropType<(value: string) => void>; + }; + value: { + required: true; + type: StringConstructor; + }; +}>> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + +// @public +export interface DocxEditorImageAltTextPopupProps { + // (undocumented) + anchorRef?: RefObject; + // (undocumented) + className?: string; + // (undocumented) + id: string; + // (undocumented) + isEnabled: boolean; + // (undocumented) + onApply(): void; + // (undocumented) + onClose(): void; + // (undocumented) + onValueChange(value: string): void; + // (undocumented) + value: string; +} + // @public export const DocxEditorImagePropertiesDialog: vue.DefineComponent; } +// @public +export const DocxEditorInvalidTextFormFieldDialog: vue.DefineComponent; + className: StringConstructor; + session: { + required: true; + type: PropType; + }; + style: PropType; +}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly; + className: StringConstructor; + session: { + required: true; + type: PropType; + }; + style: PropType; +}>> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + +// @public +export interface DocxEditorInvalidTextFormFieldDialogProps { + // (undocumented) + children?: DocxEditorChildren; + // (undocumented) + className?: string; + // (undocumented) + session: InvalidTextFormFieldSession; + // (undocumented) + style?: CSSProperties; +} + // @public (undocumented) export const DocxEditorLoading: DocxEditorLoadingComponent; @@ -2608,7 +2794,6 @@ export interface DocxEditorMenuProps { onOpen?: () => void; // (undocumented) onOpenFile?: (file: File) => void; - // (undocumented) onPageSetup?: () => void; // (undocumented) onReportIssue?: () => void; @@ -2635,6 +2820,8 @@ export interface DocxEditorNamespace { // (undocumented) readonly ContentControl: typeof DocxEditorContentControl; // (undocumented) + readonly ContentControlWidget: typeof DocxEditorContentControlWidget; + // (undocumented) readonly ContextMenu: typeof ContextMenu; // (undocumented) readonly DocumentOutline: typeof DocxEditorDocumentOutline; @@ -2648,14 +2835,26 @@ export interface DocxEditorNamespace { // (undocumented) readonly HyperLink: typeof DocxEditorHyperLink; // (undocumented) + readonly ImageAltTextPopup: typeof DocxEditorImageAltTextPopup; + // (undocumented) + readonly ImagePropertiesDialog: typeof DocxEditorImagePropertiesDialog; + // (undocumented) + readonly InvalidTextFormFieldDialog: typeof DocxEditorInvalidTextFormFieldDialog; + // (undocumented) readonly Loading: typeof DocxEditorLoading; // (undocumented) readonly Menu: typeof DocxEditorMenu; // (undocumented) readonly Navigation: typeof Navigation; // (undocumented) + readonly NotePreview: typeof DocxEditorNotePreview; + // (undocumented) + readonly NotePropertiesDialog: typeof DocxEditorNotePropertiesDialog; + // (undocumented) readonly NotesChrome: typeof DocxEditorNotesChrome; // (undocumented) + readonly NotesContextMenu: typeof DocxEditorNotesContextMenu; + // (undocumented) readonly PageNumber: typeof DocxEditorPageNumber; // (undocumented) readonly PageSetupDialog: typeof DocxEditorPageSetupDialog; @@ -2663,6 +2862,8 @@ export interface DocxEditorNamespace { // (undocumented) readonly Root: typeof DocxEditorRoot; // (undocumented) + readonly TextFormFieldDialog: typeof DocxEditorTextFormFieldDialog; + // (undocumented) readonly Toolbar: typeof DocxEditorToolbar; // (undocumented) readonly VerticalRuler: typeof DocxEditorVerticalRuler; @@ -2709,6 +2910,88 @@ export interface DocxEditorNavigationProps extends UseNavigationPaneOptions { toggle?: boolean | NavigationPartProps; } +// @public +export const DocxEditorNotePreview: vue.DefineComponent, () => vue_jsx_runtime.JSX.Element, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + +// @public +export interface DocxEditorNotePreviewProps { + // (undocumented) + className?: string; + // (undocumented) + scopeId: string; + // (undocumented) + text: string; + // (undocumented) + x: number; + // (undocumented) + y: number; +} + +// @public +export const DocxEditorNotePropertiesDialog: vue.DefineComponent void>; + }; + onClose: { + required: true; + type: PropType<() => void>; + }; +}>, () => vue_jsx_runtime.JSX.Element, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly void>; + }; + onClose: { + required: true; + type: PropType<() => void>; + }; +}>> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + +// @public +export interface DocxEditorNotePropertiesDialogProps { + // (undocumented) + onApply(command: EditorCommand): void; + // (undocumented) + onClose(): void; +} + // @public (undocumented) export const DocxEditorNotesChrome: vue.DefineComponent; + }; + onClose: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onConvert: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onConvertAll: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onDelete: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onOpenProperties: { + readonly required: true; + readonly type: PropType<() => void>; + }; + scopeId: { + required: true; + type: StringConstructor; + }; + x: { + required: true; + type: NumberConstructor; + }; + y: { + required: true; + type: NumberConstructor; + }; +}>, () => vue_jsx_runtime.JSX.Element, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly; + }; + onClose: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onConvert: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onConvertAll: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onDelete: { + readonly required: true; + readonly type: PropType<() => void>; + }; + onOpenProperties: { + readonly required: true; + readonly type: PropType<() => void>; + }; + scopeId: { + required: true; + type: StringConstructor; + }; + x: { + required: true; + type: NumberConstructor; + }; + y: { + required: true; + type: NumberConstructor; + }; +}>> & Readonly<{}>, { + convertAllEnabled: boolean; + convertEnabled: boolean; + deleteEnabled: boolean; +}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>; + +// @public +export interface DocxEditorNotesContextMenuProps { + // (undocumented) + className?: string; + // (undocumented) + convertAllDisabledReason?: string; + // (undocumented) + convertAllEnabled: boolean; + // (undocumented) + convertDisabledReason?: string; + // (undocumented) + convertEnabled: boolean; + // (undocumented) + deleteDisabledReason?: string; + // (undocumented) + deleteEnabled: boolean; + // (undocumented) + noteId: number; + // (undocumented) + noteKind: 'footnote' | 'endnote'; + // (undocumented) + onClose(): void; + // (undocumented) + onConvert(): void; + // (undocumented) + onConvertAll(): void; + // (undocumented) + onDelete(): void; + // (undocumented) + onOpenProperties(): void; + // (undocumented) + scopeId: string; + // (undocumented) + x: number; + // (undocumented) + y: number; +} + // @public (undocumented) export const DocxEditorPageNumber: vue.DefineComponent; + className: { + default: undefined; + type: StringConstructor; + }; + onClose: { + required: true; + type: PropType<() => 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; + className: { + default: undefined; + type: StringConstructor; + }; + onClose: { + required: true; + type: PropType<() => 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; className: { default: undefined; type: StringConstructor; @@ -2780,27 +3261,22 @@ export const DocxEditorPageSetupDialog: vue.DefineComponent, () => 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 +3284,64 @@ export interface DocxEditorPageSetupDialogProps { } // @public -export const DocxEditorParagraphDialog: vue.DefineComponent; + className: { + default: undefined; + type: StringConstructor; + }; + onClose: { + required: true; + type: PropType<() => 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; + className: { + default: undefined; + type: StringConstructor; + }; + onClose: { + required: true; + type: PropType<() => 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; className: { default: undefined; type: StringConstructor; @@ -2821,33 +3354,63 @@ export const DocxEditorParagraphDialog: vue.DefineComponent, () => 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) open: boolean; } +// @public +export type DocxEditorPopup

= false | ((props: P) => DocxEditorChildren | null); + +// @public +export interface DocxEditorPopups { + // (undocumented) + contentControl?: DocxEditorPopup; + // (undocumented) + contentControlWidget?: DocxEditorPopup; + // (undocumented) + contextMenu?: DocxEditorPopup; + // (undocumented) + equation?: DocxEditorPopup>; + // (undocumented) + hyperlink?: DocxEditorPopup; + // (undocumented) + imageAltText?: DocxEditorPopup; + // (undocumented) + imageProperties?: DocxEditorPopup; + // (undocumented) + invalidTextFormField?: DocxEditorPopup; + // (undocumented) + notePreview?: DocxEditorPopup; + // (undocumented) + noteProperties?: DocxEditorPopup; + // (undocumented) + notesContextMenu?: DocxEditorPopup; + // (undocumented) + pageSetup?: DocxEditorPopup; + // (undocumented) + paragraph?: DocxEditorPopup; + // (undocumented) + textFormField?: DocxEditorPopup; +} + // @public export interface DocxEditorProps { // (undocumented) @@ -2876,6 +3439,8 @@ export interface DocxEditorProps { // (undocumented) navigation?: boolean | DocxEditorNavigationProps; // (undocumented) + popups?: DocxEditorPopups; + // (undocumented) rulers?: boolean; t?: (key: string, params?: Record) => string; // (undocumented) @@ -2938,6 +3503,7 @@ export const DocxEditorRoot: vue.DefineComponent; }; + popups: PropType; tableInteractionLabel: { default: undefined; type: PropType; @@ -2989,6 +3555,7 @@ export const DocxEditorRoot: vue.DefineComponent; }; + popups: PropType; tableInteractionLabel: { default: undefined; type: PropType; @@ -3015,7 +3582,7 @@ export const DocxEditorRoot: vue.DefineComponent string) | undefined; translate: ((key: string, params?: Record) => string) | undefined; @@ -3054,6 +3621,8 @@ export interface DocxEditorRootProps { // (undocumented) onReady?: (editor: Editor) => void; // (undocumented) + popups?: DocxEditorPopups; + // (undocumented) tableInteractionLabel?: (key: 'table.insertRowBelow' | 'table.insertColumnRight') => string; translate?: (key: string, params?: Record) => string; // (undocumented) @@ -3075,6 +3644,76 @@ export interface DocxEditorRulerProps { // @public @deprecated (undocumented) export const DocxEditorShell: DocxEditorNamespace; +// @public +export const DocxEditorTextFormFieldDialog: { + new (...args: any[]): vue.CreateComponentPublicInstanceWithMixins; + className: StringConstructor; + preset: { + default: boolean; + type: BooleanConstructor; + }; + session: { + default: null; + type: PropType; + }; + 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; + className: StringConstructor; + preset: { + default: boolean; + type: BooleanConstructor; + }; + session: { + default: null; + type: PropType; + }; + style: PropType; + }>> & Readonly<{}>, () => vue_jsx_runtime.JSX.Element | null, {}, {}, {}, { + preset: boolean; + session: TextFormFieldDialogSession | null; + }>; + __isFragment?: never; + __isTeleport?: never; + __isSuspense?: never; +} & vue.ComponentOptionsBase; + className: StringConstructor; + preset: { + default: boolean; + type: BooleanConstructor; + }; + session: { + default: null; + type: PropType; + }; + 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; @@ -4306,6 +4945,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) @@ -4540,7 +5199,7 @@ export const PaginatedDocxEditorShell: vue.DefineComponent> & Readonly<{}>, { className: string; - colorMode: "light" | "dark"; + colorMode: "dark" | "light"; documentFontFamily: string; documentName: string; measurer: TextMeasurer; @@ -4583,81 +5242,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 { @@ -4711,15 +5300,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; @@ -4932,6 +5513,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; @@ -5577,6 +6188,13 @@ export function useNoteScopeState(): ShallowRef { +} + // @public (undocumented) export interface UsePageSetupReturn { // (undocumented) @@ -5587,6 +6205,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; @@ -5643,6 +6270,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/content-controls.mdx b/docs/site/content/guides/content-controls.mdx index b613f674c..ac6eef3a7 100644 --- a/docs/site/content/guides/content-controls.mdx +++ b/docs/site/content/guides/content-controls.mdx @@ -271,3 +271,9 @@ These interactions apply to content controls. For legacy Word form fields, see - [Editing API](/docs/2.x/editor-api): the full object model and its batching rules - [Custom nodes](/docs/2.x/pro/custom-nodes): your own typed inline nodes over the same OOXML primitive - [React hooks](/docs/2.x/react/hooks) and [Vue composables](/docs/2.x/vue/composables) + +## Customize control popups + +Use `popups.contentControl` for the control popup and `popups.contentControlWidget` +for dropdown, combo box, and date entry. Omitted entries retain their default UI. +See [Customize popups](/docs/2.x/guides/customize-dialogs) for renderer ownership and sessions. diff --git a/docs/site/content/guides/customize-dialogs.mdx b/docs/site/content/guides/customize-dialogs.mdx new file mode 100644 index 000000000..b4a3ab874 --- /dev/null +++ b/docs/site/content/guides/customize-dialogs.mdx @@ -0,0 +1,256 @@ +--- +title: 'Customize popups' +description: 'Use your components to customize editor popups in React and Vue.' +category: 'Guides' +--- + +Pass `popups` to the editor or `Root`. Use `definePopup(MyPopup)` to supply +an ordinary React component or Vue single-file component. The editor opens it +and supplies its typed props. Forward those props to the default component to +retain its state, validation, and commands. + +## Replace a button + +These examples replace **Apply** in Page Setup. You do not need to manage its +opening state or create a session. + + + + +```tsx +import { DocxEditor, definePopup } from '@docx-editor.dev/react'; +import type { DocxEditorPageSetupDialogProps, DocxEditorPopups } from '@docx-editor.dev/react'; +import '@docx-editor.dev/core/styles/editor.css'; + +function PageSetup(props: DocxEditorPageSetupDialogProps) { + return ( + + + + + + ); +} + +const popups: DocxEditorPopups = { pageSetup: definePopup(PageSetup) }; + +export function Editor({ document }: { document: ArrayBuffer }) { + return ; +} +``` + + + + +Create `PageSetup.vue`: + +```vue + + + +``` + +Keep `preset: true` when forwarding Vue props; omitted Boolean props otherwise +become `false`. Register the component in your editor: + +```vue + + + +``` + + + + +Custom button components must forward refs, attributes, and listeners to their +native button. Preserve the supplied handler and disabled state. + +Render callbacks also work: `pageSetup: (props) => ` in +React, or `pageSetup: (props) => h(PageSetup, props)` in Vue. Call hooks inside +components, not render callbacks. + +## Choose a popup + +Each entry accepts a `definePopup()` result, render callback, or `false`. +Props match the surface; dialogs, menus, and value widgets retain distinct contracts. + +| Entry | Supplied props | +| ------------------------ | ------------------------------------------ | +| `pageSetup`, `paragraph` | Controlled dialog props: `open`, `onClose` | +| `textFormField` | `{ session: TextFormFieldDialogSession }` | +| `hyperlink` | `HyperLinkProps` | +| `contentControl` | `ContentControlProps` | +| `equation` | No state props | +| `contextMenu` | `DocxEditorContextMenuProps` | +| `imageProperties` | `DocxEditorImagePropertiesDialogProps` | +| `imageAltText` | `DocxEditorImageAltTextPopupProps` | +| `noteProperties` | `DocxEditorNotePropertiesDialogProps` | +| `notesContextMenu` | `DocxEditorNotesContextMenuProps` | +| `notePreview` | `DocxEditorNotePreviewProps` | +| `contentControlWidget` | `{ session: ContentControlWidgetSession }` | +| `invalidTextFormField` | `{ session: InvalidTextFormFieldSession }` | + +Omitted entries retain existing behavior, including native widget and invalid-field +dialogs. The packaged editor supplies defaults. `Root` mounts configured surfaces; +image and note overrides use their existing triggers. Explicit entries override +`menu.onPageSetup`, `hyperlinkPopup`, and `contextMenu` shortcuts. + +For manual ownership, use `false` and mount the surface yourself. You then own its +trigger and visibility. Ordinary component customization does not need this option. +Toolbar menus and pickers use their existing compound `.Content` parts, outside this map. + +## Arrange parts and custom fields + +Page Setup, Paragraph Options, and Field Options expose `Header`, `Title`, `Body`, +`Footer`, `Apply`, `Cancel`, `Error`, and typed `Field` parts. Parts accept +`className`, `style`, `hidden`, and `asChild`. Other popups retain their existing parts. + +Named children replace default parts. Use `hidden` to remove a part or +`preset={false}` to supply the complete arrangement. Include a title, error region, +and accessible Apply and Cancel controls. Omitted fields retain their draft values. + +Page Setup's `pageSize` part is its size selector. Paragraph's `tabStops` part +contains the collection control; `specialBy` appears when relevant. + +For custom value controls, call `usePageSetupDialog()`, `useParagraphDialog()`, or +`useTextFormFieldDialog()` inside a child of the matching dialog. Each supplies +`values`, `setValue(name, value)`, `errors`, `isEnabled`, `apply()`, and `cancel()`. +Vue exposes state as refs. Use these draft APIs instead of creating another form session. + +A `Field` represents the labeled row; `asChild` does not adapt input value events. +Page Setup dimensions use twips: 1,440 per inch. Paragraph also exposes mixed-selection state. +See [React hooks](/docs/2.x/react/hooks#dialog-draft-contexts) or +[Vue composables](/docs/2.x/vue/composables#dialog-draft-contexts) for types. + +## Connect a custom input + +Put the input inside a named `Field`. Call the draft hook in the input component, +which renders under the dialog's provider. This example edits the top margin in inches. + + + + +```tsx +import { usePageSetupDialog } from '@docx-editor.dev/react'; + +function TopMargin() { + const { values, setValue, isEnabled } = usePageSetupDialog(); + return ( + + ); +} +``` + +Add this named child inside your `PageSetupDialog`: + +```tsx + + + +``` + + + + +Create `TopMargin.vue`: + +```vue + + + +``` + +Import `TopMargin` in `PageSetup.vue` and declare `const Field = Dialog.Field`. +Add this named child inside `Dialog`: + +```vue + +``` + + + + +Keep the default Apply and Error parts. Invalid margins keep the dialog open and +show an error; Cancel discards the draft. No separate session or opening state is needed. + +## Style and accessibility + +Import your 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`. + +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 your replacement without `!important`: + +```css +.docx-editor [data-docx-dialog='pageSetup'] .app-apply { + border-radius: 999px; + padding-inline: 1.25rem; +} +``` + +Automatic dialogs inherit their editor's theme. For external portals or Vue +`Teleport`, provide the theme at the destination; framework context does not transfer CSS inheritance. +Preserve labels, keyboard behavior, and input-method composition on custom controls. + +For a complete session renderer, follow its abort signal and use its supplied +actions. Widget sessions validate through `apply(value)`. Invalid-field sessions +use `acknowledge()` to handle the invalid fill, or `cancel()` to dismiss without clearing it. +`locale` controls regional dates; `i18n` controls UI strings. + +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 themed editors with custom dialog and link controls. diff --git a/docs/site/content/guides/fields.mdx b/docs/site/content/guides/fields.mdx index 3c6a815b5..11787ecb7 100644 --- a/docs/site/content/guides/fields.mdx +++ b/docs/site/content/guides/fields.mdx @@ -98,7 +98,7 @@ macros, DDE instructions, OLE content, or external include instructions. Select a `FORMTEXT` field, then double-click it or choose **Edit field…** from the context menu. Set its default value, type (regular text, number, or date), maximum length, format, and **Fill-in enabled** setting. A maximum length of -zero means unlimited. React and Vue use the same dialog. +zero means unlimited. React and Vue share the same default behavior. In an unprotected document, partial edits keep the field definition; replacing its whole result removes it. In a document protected for forms, editing keeps @@ -112,6 +112,9 @@ Save also validates pending protected field input and applies its format. Invali input rejects the save with code `invalidArgs`. The input stays available for correction, and save does not open an alert. +Use `popups.textFormField` for Field Options and `popups.invalidTextFormField` +for the validation alert. See [Customize popups](/docs/2.x/guides/customize-dialogs). + ### Date input and UI language `locale` controls date input; `i18n` controls UI strings. For Polish dates with 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 d21d429f8..82f326149 100644 --- a/docs/site/content/react/composition.mdx +++ b/docs/site/content/react/composition.mdx @@ -450,7 +450,11 @@ const [open, setOpen] = useState(false); ``` The dialog and `usePageSetup()` use the same engine command. -Use that hook to build a custom form. + +Page Setup, Paragraph Options, and legacy text Field Options expose replaceable +parts and draft contexts. Import `definePopup` from `@docx-editor.dev/react`. +Set `popups.pageSetup` to `definePopup(MyPopup)` on the editor or `Root`. +For examples, see [Customize popups](/docs/2.x/guides/customize-dialogs). ## Content-control panel diff --git a/docs/site/content/react/hooks.mdx b/docs/site/content/react/hooks.mdx index e14221313..56c6b2b87 100644 --- a/docs/site/content/react/hooks.mdx +++ b/docs/site/content/react/hooks.mdx @@ -337,6 +337,28 @@ 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 + +Call the hook in a child component of the matching dialog. Each returns `values`, +`setValue(name, value)`, `errors`, `isEnabled`, `apply()`, and `cancel()`. + +| Hook | Draft type | Draft keys | +| -------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `usePageSetupDialog()` | `PageSetupDialogFields` | `pageWidth`, `pageHeight`, `orientation`, `marginTop`, `marginBottom`, `marginLeft`, `marginRight`, `scope` | +| `useParagraphDialog()` | `UseParagraphDialogReturn['values']` | `alignment`, `indentLeft`, `indentRight`, `special`, `specialBy`, `spaceBefore`, `spaceAfter`, `lineRule`, `lineValue`, `contextualSpacing`, `keepNext`, `keepLines`, `widowControl`, `pageBreakBefore`, `tabStops`, `clearedAllTabStops` | +| `useTextFormFieldDialog()` | `TextFormFieldDialogFields` | `defaultText`, `type`, `maxLength`, `format`, `enabled` | + +Page dimensions, margins, and paragraph indents use twips: 1,440 per inch. +Paragraph spacing uses points. `lineValue` is a multiple for `lineRule: 'multiple'`, +and points for `'exact'` or `'atLeast'`. Paragraph also exposes `mixed` selection state. + +Most `Field` names match draft keys. Page Setup uses `pageSize` for the size selector, +which updates `pageWidth` and `pageHeight`. Paragraph's `clearedAllTabStops` is draft +state, not a separate `Field`. + +See [Connect a custom input](/docs/2.x/guides/customize-dialogs#connect-a-custom-input) +for a complete control and its placement inside the dialog. + ## 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 314c544ac..c1649f2a7 100644 --- a/docs/site/content/react/props.mdx +++ b/docs/site/content/react/props.mdx @@ -246,6 +246,13 @@ The ref has seven methods: For exact signatures, see the [React API reference](/docs/2.x/api/react). +## `popups` + +Use `definePopup(MyPopup)` to customize an automatic popup with your component. +Forward its typed props to the default component to retain editor behavior. +Render callbacks also work. Use `false` only when you own the trigger and visibility. +For supported surfaces and defaults, see [Customize popups](/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..1612bb0ce 100644 --- a/docs/site/content/vue/composables.mdx +++ b/docs/site/content/vue/composables.mdx @@ -441,6 +441,30 @@ 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 + +Call the hook in a child component of the matching dialog. Each returns `values`, +`setValue(name, value)`, `errors`, `isEnabled`, `apply()`, and `cancel()`. + +| Hook | Draft type | Draft keys | +| -------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `usePageSetupDialog()` | `PageSetupDialogFields` | `pageWidth`, `pageHeight`, `orientation`, `marginTop`, `marginBottom`, `marginLeft`, `marginRight`, `scope` | +| `useParagraphDialog()` | `UseParagraphDialogReturn['values']['value']` | `alignment`, `indentLeft`, `indentRight`, `special`, `specialBy`, `spaceBefore`, `spaceAfter`, `lineRule`, `lineValue`, `contextualSpacing`, `keepNext`, `keepLines`, `widowControl`, `pageBreakBefore`, `tabStops`, `clearedAllTabStops` | +| `useTextFormFieldDialog()` | `TextFormFieldDialogFields` | `defaultText`, `type`, `maxLength`, `format`, `enabled` | + +Vue exposes state as refs. Use `.value` in JavaScript; templates unwrap these refs. + +Page dimensions, margins, and paragraph indents use twips: 1,440 per inch. +Paragraph spacing uses points. `lineValue` is a multiple for `lineRule: 'multiple'`, +and points for `'exact'` or `'atLeast'`. Paragraph also exposes `mixed` selection state. + +Most `Field` names match draft keys. Page Setup uses `pageSize` for the size selector, +which updates `pageWidth` and `pageHeight`. Paragraph's `clearedAllTabStops` is draft +state, not a separate `Field`. + +See [Connect a custom input](/docs/2.x/guides/customize-dialogs#connect-a-custom-input) +for a complete control and its placement inside the dialog. + ## 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 882debbe9..424f29780 100644 --- a/docs/site/content/vue/composition.mdx +++ b/docs/site/content/vue/composition.mdx @@ -393,6 +393,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 draft contexts. Import `definePopup` from `@docx-editor.dev/vue`. +Set `popups.pageSetup` to `definePopup(MyPopup)` on the editor or `Root`. +For examples, see [Customize popups](/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 90b2e70a4..5f744525c 100644 --- a/docs/site/content/vue/props.mdx +++ b/docs/site/content/vue/props.mdx @@ -266,6 +266,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. +## `popups` + +Use `definePopup(MyPopup)` to customize an automatic popup with your component. +Forward its typed props to the default component to retain editor behavior. +Render callbacks also work. Use `false` only when you own the trigger and visibility. +For supported surfaces and defaults, see [Customize popups](/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..7333778c0 --- /dev/null +++ b/e2e/dialog-customization.interaction.spec.ts @@ -0,0 +1,122 @@ +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); + }); +} + +for (const [adapter, port] of [ + ['React', 5273], + ['Vue', 5274], +] as const) { + test(`${adapter}: unified popup map replaces one hyperlink panel per editor`, async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/?dialogs=1`); + const editors = page.locator('.dialog-demo-editors > .docx-editor'); + for (const index of [0, 1]) { + const editor = editors.nth(index); + await editor.locator('.docx-pages').click({ position: { x: 120, y: 110 } }); + await page.keyboard.press('ControlOrMeta+k'); + const popup = editor.getByTestId('hyperlink-popup'); + await expect(popup).toBeVisible(); + await expect(page.getByTestId('hyperlink-popup')).toHaveCount(1); + await popup.getByTestId('hyperlink-popup-url-input').fill('https://example.com'); + await popup.getByTestId('hyperlink-popup-text').fill(`Editor ${index + 1}`); + await popup.getByRole('button', { name: 'Save link', exact: true }).click(); + await expect(popup).toHaveCount(0); + await expect(editor.locator('.docx-hyperlink')).toHaveText(`Editor ${index + 1}`); + } + }); +} + +test('Vue: template text slots preserve Paragraph action labels and commands', async ({ page }) => { + await page.goto('http://localhost:5274/?dialogs=1'); + const editor = page.locator('.dialog-demo-editors > .docx-editor').first(); + await editor.locator('.docx-pages').click({ position: { x: 120, y: 110 } }); + await page.keyboard.type('A paragraph'); + const format = editor.locator('[data-menu="format"] > [role="menuitem"]'); + const open = async () => { + await format.click(); + await editor.getByRole('menuitem', { name: /Line spacing options/ }).click(); + }; + await open(); + const dialog = page.locator('dialog[data-docx-dialog="paragraph"]'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Save settings', exact: true })).toBeVisible(); + await dialog.locator('[data-docx-field="spaceAfter"] input').fill('12'); + await dialog.getByRole('button', { name: 'Save settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(format).toBeFocused(); + await open(); + await expect(dialog.locator('[data-docx-field="spaceAfter"] input')).toHaveValue('12'); + await page.keyboard.press('Escape'); +}); 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..02d0cf266 --- /dev/null +++ b/examples/vite/src/DialogCustomizationDemo.tsx @@ -0,0 +1,75 @@ +import { definePopup } from '@docx-editor.dev/react'; +import type { ComponentProps } from 'react'; +import { + DocxEditor, + DocxEditorPageSetupDialog, + DocxEditorParagraphDialog, + DocxEditorTextFormFieldDialog, + DocxEditorHyperLink, +} from '@docx-editor.dev/react'; +import type { DocxEditorPopups } from '@docx-editor.dev/react'; +import '../../shared/dialog-customization.css'; + +function CustomHyperlink(props: ComponentProps) { + return ( + + + + + + + + + ); +} + +function CustomPageSetup(props: ComponentProps) { + return ( + + + + + + ); +} + +function CustomParagraph(props: ComponentProps) { + return ( + + + + + + ); +} + +function CustomTextFormField(props: ComponentProps) { + return ( + + + + + + ); +} + +const popups: DocxEditorPopups = { + hyperlink: definePopup(CustomHyperlink), + pageSetup: definePopup(CustomPageSetup), + paragraph: definePopup(CustomParagraph), + textFormField: definePopup(CustomTextFormField), +}; +/** Two independently themed editors using the same custom button. */ +export function DialogCustomizationDemo() { + return ( +

+

Customize popups

+

Open File → Page setup or Format → Paragraph. Insert a link to try custom link actions.

+
+ + +
+
+ ); +} 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..d92e3f8d4 --- /dev/null +++ b/examples/vue/src/DialogCustomizationDemo.vue @@ -0,0 +1,25 @@ + + diff --git a/examples/vue/src/MyHyperlinkPopup.vue b/examples/vue/src/MyHyperlinkPopup.vue new file mode 100644 index 000000000..ffbebdfd2 --- /dev/null +++ b/examples/vue/src/MyHyperlinkPopup.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/vue/src/MyPageSetupDialog.vue b/examples/vue/src/MyPageSetupDialog.vue new file mode 100644 index 000000000..d32573d57 --- /dev/null +++ b/examples/vue/src/MyPageSetupDialog.vue @@ -0,0 +1,14 @@ + + + diff --git a/examples/vue/src/MyParagraphDialog.vue b/examples/vue/src/MyParagraphDialog.vue new file mode 100644 index 000000000..55988aec4 --- /dev/null +++ b/examples/vue/src/MyParagraphDialog.vue @@ -0,0 +1,14 @@ + + + diff --git a/examples/vue/src/MyTextFormFieldDialog.vue b/examples/vue/src/MyTextFormFieldDialog.vue new file mode 100644 index 000000000..06ce103c0 --- /dev/null +++ b/examples/vue/src/MyTextFormFieldDialog.vue @@ -0,0 +1,14 @@ + + + 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/package.json b/package.json index 7377b4045..1dbf2599b 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "check:adapter-mirror": "node scripts/check-adapter-mirror.mjs", "check:nuxt-auto-imports": "node scripts/check-nuxt-auto-imports.mjs", "generate:nuxt-auto-imports": "node scripts/check-nuxt-auto-imports.mjs --write", - "check:parity": "bun run check:export-parity && bun run check:editor-contract && bun run check:public-docs-surface && bun run check:parity-contract && bun run check:composable-parity && bun run check:nuxt-auto-imports && bun run check:adapter-mirror && bun run check:adapter-css-thin", + "check:parity": "bun run check:export-parity && bun run check:editor-contract && bun run check:public-docs-surface && bun run check:parity-contract && bun run check:composable-parity && bun run check:nuxt-auto-imports && bun run check:adapter-mirror && bun run check:adapter-css-thin && bun run check:feature-parity", "check:license-headers": "bun run check:pro-license-headers && bun run check:editor-api-license-headers", "check:parity-contract": "node scripts/check-parity-contract.mjs", "check:public-docs-surface": "node scripts/check-public-docs-surface.mjs", diff --git a/packages/core/src/editor/__tests__/content-control-surface.test.ts b/packages/core/src/editor/__tests__/content-control-surface.test.ts index 844a0bf08..c0f4d9d51 100644 --- a/packages/core/src/editor/__tests__/content-control-surface.test.ts +++ b/packages/core/src/editor/__tests__/content-control-surface.test.ts @@ -1,3 +1,4 @@ +import type { ContentControlWidgetSession } from '../popup-sessions.ts'; // Content-control surface chrome: boundary furniture, show-all, form-fill navigation, // lock/bound refusals, and remove — without layout reflow. @@ -38,9 +39,15 @@ const p = (text: string) => `${text}`; const sdt = (pr: string, content: string) => `${pr}${content}`; -function mount(body: string): { surface: PaginatedSurface; container: HTMLElement } { +function mount( + body: string, + onRequestContentControlWidget?: (request: ContentControlWidgetSession) => boolean +): { surface: PaginatedSurface; container: HTMLElement } { const container = document.createElement('div'); - const result = mountPaginatedSurface(container, docx(body), { scale: 1 }); + const result = mountPaginatedSurface(container, docx(body), { + scale: 1, + ...(onRequestContentControlWidget ? { onRequestContentControlWidget } : {}), + }); if (!result.ok) throw new Error(`${result.reason}: ${result.detail ?? ''}`); return { surface: result.surface, container }; } @@ -553,3 +560,58 @@ describe('content-control surface chrome', () => { expect(after?.getAttribute('aria-checked')).toBe('true'); }); }); + +test('custom content-control widget sessions retain core writes and invalidate stale callbacks', () => { + const requests: ContentControlWidgetSession[] = []; + const body = `${sdt( + '', + 'One' + )}`; + const { surface, container } = mount(body, (request) => { + requests.push(request); + return true; + }); + const open = () => + container.querySelector('[data-docx-cc-widget]')!.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + pointerType: 'mouse', + }) + ); + try { + open(); + const first = requests[0]!; + expect(first.kind).toBe('dropdown'); + expect(first.value).toBe('1'); + expect(first.items.map((item) => item.value)).toEqual(['1', '2']); + expect(first.anchor).not.toBeNull(); + expect(container.querySelector('.docx-content-control-menu')).toBeNull(); + expect(first.apply('invalid')).toBe(false); + expect(first.signal.aborted).toBe(false); + expect(first.apply('2')).toBe(true); + expect(first.signal.aborted).toBe(true); + expect(container.querySelector('.docx-page-content')?.textContent).toContain('Two'); + expect(first.apply('1')).toBe(false); + open(); + const second = requests[1]!; + open(); + expect(second.signal.aborted).toBe(true); + const third = requests[2]!; + second.cancel(); + expect(third.signal.aborted).toBe(false); + surface.setEditingMode('view'); + expect(third.signal.aborted).toBe(true); + expect(third.canApply()).toBe(false); + surface.setEditingMode('edit'); + open(); + const last = requests[3]!; + surface.destroy(); + expect(last.signal.aborted).toBe(true); + expect(last.apply('1')).toBe(false); + } finally { + container.remove(); + } +}); 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..b7258a92e --- /dev/null +++ b/packages/core/src/editor/__tests__/text-form-field-chrome.test.ts @@ -0,0 +1,82 @@ +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]); +}); + +for (const fallbackFirst of [true, false]) { + test(`manual chrome outranks adapter fallback (fallback first: ${fallbackFirst})`, () => { + const chrome = createTextFormFieldChrome(); + const calls: string[] = []; + const fallback = () => + chrome.register( + { + onRequest: (request) => { + calls.push('fallback'); + request.cancel(); + }, + }, + { fallback: true } + ); + const manual = () => + chrome.register({ + onRequest: () => { + calls.push('manual'); + }, + }); + let disposeManual: () => void; + let disposeFallback: () => void; + if (fallbackFirst) { + disposeFallback = fallback(); + disposeManual = manual(); + } else { + disposeManual = manual(); + disposeFallback = fallback(); + } + const custom = session(); + expect(chrome.request(custom)).toBe(true); + expect(calls).toEqual(['manual']); + expect(custom.signal.aborted).toBe(false); + disposeManual(); + expect(custom.signal.aborted).toBe(true); + const suppressed = session(); + expect(chrome.request(suppressed)).toBe(true); + expect(suppressed.signal.aborted).toBe(true); + expect(calls).toEqual(['manual', 'fallback']); + disposeFallback(); + expect(chrome.request(session())).toBe(false); + }); +} 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 1a5f141a0..220c6e6a5 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,5 @@ +import type { InvalidTextFormFieldSession } from '../popup-sessions.ts'; +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 +17,9 @@ function setup( protectedForm = false, emptyFirst = false, separator = ' and ', - emptySecond = false + emptySecond = false, + onRequest?: (session: TextFormFieldDialogSession) => boolean, + onInvalidRequest?: (session: InvalidTextFormFieldSession) => boolean ) { const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const field = (name: string) => @@ -51,6 +55,8 @@ function setup( let commits = 0; let rejectDelete = false; const interaction = createTextFormFieldInteraction({ + onRequest, + onInvalidRequest, locale: () => locale, container, pagesLayer, @@ -76,6 +82,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; @@ -390,3 +409,137 @@ 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(); + } +}); + +for (const action of ['acknowledge', 'cancel', 'destroy'] as const) { + test(`custom invalid-field ${action} preserves acknowledgement ownership`, () => { + const requests: InvalidTextFormFieldSession[] = []; + const host = setup(true, false, ' and ', false, undefined, (request) => { + requests.push(request); + return true; + }); + try { + host.configure({ type: 'number', format: '0.00', maxLength: 0, enabled: true }, '1'); + const paragraphId = host.selection().head.paragraphId; + host.select(0); + host.type({ op: 'insertText', paragraphId, offset: 0, text: '--' }); + host.select(20); + expect(requests).toHaveLength(1); + expect(host.container.querySelector('dialog')).toBeNull(); + const before = paragraphTextOf(host.part(), paragraphId); + const request = requests[0]!; + if (action === 'destroy') host.interaction.destroy(); + else request[action](); + expect(request.signal.aborted).toBe(true); + if (action === 'acknowledge') + expect(paragraphTextOf(host.part(), paragraphId)).toBe(' and Sample'); + else expect(paragraphTextOf(host.part(), paragraphId)).toBe(before); + request.acknowledge(); + request.cancel(); + expect(paragraphTextOf(host.part(), paragraphId)).toBe( + action === 'acknowledge' ? ' and Sample' : before + ); + } finally { + host.cleanup(); + } + }); +} + +test('custom Field Options retains the opening regional date locale', () => { + const requests: TextFormFieldDialogSession[] = []; + const host = setup(false, false, ' and ', false, (request) => { + requests.push(request); + return true; + }); + try { + host.setLocale('en-GB'); + host.interaction.edit(); + host.setLocale('en-US'); + expect( + requests[0]!.apply('1/2/2030', { + type: 'date', + format: 'yyyy-MM-dd', + maxLength: 0, + enabled: true, + }) + ).toBe(true); + expect(paragraphTextOf(host.part(), host.selection().head.paragraphId)).toBe( + '2030-02-01 and Sample' + ); + } finally { + host.cleanup(); + } +}); diff --git a/packages/core/src/editor/content-control-widget-session.ts b/packages/core/src/editor/content-control-widget-session.ts new file mode 100644 index 000000000..5d1cf6482 --- /dev/null +++ b/packages/core/src/editor/content-control-widget-session.ts @@ -0,0 +1,127 @@ +import type { OoxmlElement, OoxmlNode } from '@docx-editor.dev/core/store'; +import type { ContentControlWidgetSession } from './popup-sessions.ts'; + +interface Host { + find(id: string): OoxmlElement | null; + allowed(id: string): boolean; + apply(id: string, value: string): boolean; + items(id: string): readonly { displayText: string; value: string }[]; + date(id: string): string | undefined; + layer: HTMLElement; + setOpen(id: string, open: boolean): void; + request?: ((session: ContentControlWidgetSession) => boolean) | undefined; +} +function valueText(node: OoxmlNode): string { + if (node.kind === 'textValue') return node.value; + if (node.localName === 'sdtPr') return ''; + return node.children.map(valueText).join(''); +} +function kindOf(control: OoxmlElement | null): string | undefined { + const properties = control?.children.find( + (node) => node.kind !== 'textValue' && node.localName === 'sdtPr' + ); + if (!properties || properties.kind === 'textValue') return; + for (const child of properties.children) { + if (child.kind === 'textValue') continue; + if (child.localName === 'dropDownList') return 'dropdown'; + if (child.localName === 'comboBox' || child.localName === 'date') return child.localName; + } +} +/** Typed host sessions over the existing content-control command lane. */ +export function createContentControlWidgetSessions(host: Host) { + let active: { controller: AbortController; id: string } | null = null; + let destroyed = false; + const cancel = () => { + const previous = active; + active = null; + if (!previous) return; + host.setOpen(previous.id, false); + previous.controller.abort(); + }; + return { + cancel, + destroy() { + destroyed = true; + cancel(); + }, + open(id: string, kind: string): boolean { + cancel(); + if (destroyed || !host.request || !['dropdown', 'comboBox', 'date'].includes(kind)) + return false; + const controller = new AbortController(); + active = { controller, id }; + const isActive = () => + !destroyed && active?.controller === controller && !controller.signal.aborted; + const canApply = () => isActive() && kindOf(host.find(id)) === kind && host.allowed(id); + const control = host.find(id); + const items = host.items(id).map((item) => ({ ...item })); + const displayed = control ? valueText(control) : ''; + const value = items.find((item) => item.displayText === displayed)?.value ?? displayed; + const session: ContentControlWidgetSession = { + controlId: id, + kind: kind as ContentControlWidgetSession['kind'], + items, + value: kind === 'date' ? (host.date(id) ?? '') : value, + anchor: + [...host.layer.querySelectorAll('[data-docx-content-control]')].find( + (node) => node.getAttribute('data-docx-content-control') === id + ) ?? null, + signal: controller.signal, + canApply, + apply(value) { + if (!canApply() || !host.apply(id, value)) return false; + if (isActive()) cancel(); + return true; + }, + cancel() { + if (isActive()) cancel(); + }, + }; + host.setOpen(id, true); + if (host.request(session)) return true; + session.cancel(); + return false; + }, + }; +} + +export function contentControlWidgetItems( + control: OoxmlElement | null +): readonly { displayText: string; value: string }[] { + if (!control) return []; + for (const child of control.children) { + if (child.kind === 'textValue') continue; + if ( + (child as { kind?: string }).kind !== 'contentControlProperties' && + child.localName !== 'sdtPr' + ) { + continue; + } + for (const prop of child.children) { + if (prop.kind === 'textValue') continue; + if (prop.localName !== 'dropDownList' && prop.localName !== 'comboBox') continue; + const items: { displayText: string; value: string }[] = []; + for (const item of prop.children) { + if (item.kind === 'textValue' || item.localName !== 'listItem') continue; + const value = item.attributes.find((a) => a.localName === 'value')?.value ?? ''; + const displayText = + item.attributes.find((a) => a.localName === 'displayText')?.value ?? value; + items.push({ displayText, value }); + } + return items; + } + } + return []; +} + +export function contentControlWidgetDate(control: OoxmlElement | null): string | undefined { + if (!control) return undefined; + for (const child of control.children) { + if (child.kind !== 'contentControlProperties') continue; + for (const property of child.children) { + if (property.kind !== 'contentControlDate') continue; + return property.attributes.find((attribute) => attribute.localName === 'fullDate')?.value; + } + } + return undefined; +} diff --git a/packages/core/src/editor/docx-editor-types.ts b/packages/core/src/editor/docx-editor-types.ts index 533fc8e16..efe2d5e3b 100644 --- a/packages/core/src/editor/docx-editor-types.ts +++ b/packages/core/src/editor/docx-editor-types.ts @@ -1,3 +1,9 @@ +import type { + PopupChromeRegistrationOptions, + ContentControlWidgetChromeHandlers, + InvalidTextFormFieldChromeHandlers, +} from './popup-sessions.ts'; +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` @@ -193,6 +199,21 @@ 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, + options?: PopupChromeRegistrationOptions + ): Unsubscribe; + /** Register typed content-control widget chrome. */ + setContentControlWidgetChrome( + handlers: ContentControlWidgetChromeHandlers, + options?: PopupChromeRegistrationOptions + ): Unsubscribe; + /** Register invalid protected-field acknowledgement chrome. */ + setInvalidTextFormFieldChrome( + handlers: InvalidTextFormFieldChromeHandlers, + options?: PopupChromeRegistrationOptions + ): 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 3d33af97f..afa13138f 100644 --- a/packages/core/src/editor/docx-editor.ts +++ b/packages/core/src/editor/docx-editor.ts @@ -1,10 +1,7 @@ +import { createEditorPopupChrome } from './text-form-field-chrome.ts'; import { createReviewCommands } from './docx-editor-review-commands.ts'; import { canEditorViewCommand, createEditorParagraphMarks } from './docx-editor-view-commands.ts'; -// The `Editor` facade over the paginated surface. -// -// `createDocxEditor` implements the FULL `Editor` contract over the paginated surface — -// the document session, semantic layout and painted pages that framework adapters mount. -// +// The Editor facade owns the document session, semantic layout, and painted pages. // - REAL: load/save, the exec subset below (marks, mark attributes via `setMarkAttr`, // alignment, indent, line break, undo/redo, semantic setSelection, selection-addressed // insert/delete text), selection formatting, `isActive` for marks and alignment, page @@ -342,6 +339,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 popupChrome = createEditorPopupChrome(); const hyperlinkChrome = createChromeHandlerStack({}); const equationChrome = createChromeHandlerStack({}); let destroyed = false; @@ -581,6 +579,7 @@ 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), + ...popupChrome.surfaceOptions, onRequestHyperlink: () => hyperlinkChrome.current().onRequest?.(), onEquationPopover: (activation) => equationChrome.current().onPopover?.(activation), onTrackedChange: () => { @@ -1744,6 +1743,7 @@ export function createDocxEditor(config: DocxEditorConfig): DocxEditorInstance { return surface; }, + ...popupChrome.setters, setHyperlinkChrome: hyperlinkChrome.push, setEquationChrome: equationChrome.push, diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 74851f26f..a44642fb6 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -324,3 +324,44 @@ 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'; + +export type { + PopupChromeRegistrationOptions, + ContentControlWidgetSession, + ContentControlWidgetChromeHandlers, + InvalidTextFormFieldSession, + InvalidTextFormFieldChromeHandlers, +} from './popup-sessions.ts'; diff --git a/packages/core/src/editor/paginated-surface-options.ts b/packages/core/src/editor/paginated-surface-options.ts index 1b73ea6c2..9464a4816 100644 --- a/packages/core/src/editor/paginated-surface-options.ts +++ b/packages/core/src/editor/paginated-surface-options.ts @@ -121,6 +121,16 @@ 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 onRequestContentControlWidget?: ( + session: import('./popup-sessions.ts').ContentControlWidgetSession + ) => boolean; + readonly onRequestInvalidTextFormField?: ( + session: import('./popup-sessions.ts').InvalidTextFormFieldSession + ) => boolean; /** * 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 b9770b8eb..c20504806 100644 --- a/packages/core/src/editor/paginated-surface.ts +++ b/packages/core/src/editor/paginated-surface.ts @@ -1,3 +1,8 @@ +import { + createContentControlWidgetSessions, + contentControlWidgetItems, + contentControlWidgetDate, +} from './content-control-widget-session.ts'; import { createParagraphMarkVisibility } from './surface-paragraph-mark-visibility.ts'; import { saveSurfaceDocument } from './docx-editor-save.ts'; import { applyTextFormOperation, applyTextFormSave } from './surface-text-form-apply.ts'; @@ -2128,35 +2133,7 @@ export function mountPaginatedSurface( return true; } - function listItemsOfControl( - controlId: string - ): readonly { displayText: string; value: string }[] { - const control = findControl(controlId); - if (!control) return []; - for (const child of control.children) { - if (child.kind === 'textValue') continue; - if ( - (child as { kind?: string }).kind !== 'contentControlProperties' && - child.localName !== 'sdtPr' - ) { - continue; - } - for (const prop of child.children) { - if (prop.kind === 'textValue') continue; - if (prop.localName !== 'dropDownList' && prop.localName !== 'comboBox') continue; - const items: { displayText: string; value: string }[] = []; - for (const item of prop.children) { - if (item.kind === 'textValue' || item.localName !== 'listItem') continue; - const value = item.attributes.find((a) => a.localName === 'value')?.value ?? ''; - const displayText = - item.attributes.find((a) => a.localName === 'displayText')?.value ?? value; - items.push({ displayText, value }); - } - return items; - } - } - return []; - } + const listItemsOfControl = (id: string) => contentControlWidgetItems(findControl(id)); function checkboxChecked(controlId: string): boolean { const control = findControl(controlId); @@ -2181,18 +2158,7 @@ export function mountPaginatedSurface( return false; } - function dateValueOfControl(controlId: string): string | undefined { - const control = findControl(controlId); - if (!control) return undefined; - for (const child of control.children) { - if (child.kind !== 'contentControlProperties') continue; - for (const property of child.children) { - if (property.kind !== 'contentControlDate') continue; - return property.attributes.find((attribute) => attribute.localName === 'fullDate')?.value; - } - } - return undefined; - } + const dateValueOfControl = (id: string) => contentControlWidgetDate(findControl(id)); function setContentControlWidgetOpen(controlId: string, open: boolean): void { for (const chrome of pagesLayer.querySelectorAll('[data-docx-content-control]')) { @@ -2202,6 +2168,17 @@ export function mountPaginatedSurface( } } + const widgetSessions = createContentControlWidgetSessions({ + find: findControl, + allowed: (id) => !contentControlsOps.disabledReason(id, 'edit'), + apply: (id, value) => contentControlsOps.setValue(id, value), + items: listItemsOfControl, + date: dateValueOfControl, + layer: pagesLayer, + setOpen: setContentControlWidgetOpen, + request: options.onRequestContentControlWidget, + }); + function closeContentControlMenu(menu: HTMLElement): void { const controlId = menu.dataset.docxCcId; menu.remove(); @@ -2209,6 +2186,7 @@ export function mountPaginatedSurface( } function removeExistingContentControlMenu(): HTMLElement | null { + widgetSessions.cancel(); const existing = pagesLayer.querySelector('.docx-content-control-menu'); if (existing) closeContentControlMenu(existing); return existing; @@ -2321,12 +2299,13 @@ export function mountPaginatedSurface( contentControlsOps.setValue(controlId, checkboxChecked(controlId) ? 'false' : 'true'); return; } + // Re-pressing the native widget toggles its current menu shut. + if (removeExistingContentControlMenu()?.dataset.docxCcId === controlId) return; + if (widgetSessions.open(controlId, kind)) return; if (kind === 'dropdown' || kind === 'comboBox') { const items = listItemsOfControl(controlId); if (items.length === 0 && kind === 'dropdown') return; // Engine-level menu: no hardcoded English — displayText comes from the file. - // Re-pressing the owning widget toggles shut instead of reopening. - if (removeExistingContentControlMenu()?.dataset.docxCcId === controlId) return; const menu = document.createElement('div'); menu.className = 'docx-content-control-menu'; menu.dataset.docxMarker = ''; @@ -2387,7 +2366,6 @@ export function mountPaginatedSurface( return; } if (kind === 'date') { - if (removeExistingContentControlMenu()?.dataset.docxCcId === controlId) return; const menu = document.createElement('div'); menu.className = 'docx-content-control-menu'; menu.dataset.docxMarker = ''; @@ -5788,6 +5766,7 @@ export function mountPaginatedSurface( container.ownerDocument.defaultView?.removeEventListener('resize', onViewportResize); viewportObserver?.disconnect(); observedScroller = null; + widgetSessions.destroy(); textFormInteraction?.destroy(); pointer?.destroy(); tableInteraction.destroy(); @@ -5939,21 +5918,19 @@ 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, + onInvalidRequest: options.onRequestInvalidTextFormField, locale: dateLocale.get, translate: (key, params) => translate?.(key, params) ?? key, pagesLayer, container, - part: () => partOfNodeId(session, selection.head.paragraphId) ?? session.part(), + part: (paragraphId?: string) => + partOfNodeId(session, paragraphId ?? selection.head.paragraphId) ?? session.part(), parts: () => session.storyParts(), protected: (paragraphId = selection.head.paragraphId) => formsProtectionEnabled(session.settingsRoot()) && 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/popup-sessions.ts b/packages/core/src/editor/popup-sessions.ts new file mode 100644 index 000000000..349dca8be --- /dev/null +++ b/packages/core/src/editor/popup-sessions.ts @@ -0,0 +1,34 @@ +/** Priority of an editor-owned popup renderer. @public */ +export interface PopupChromeRegistrationOptions { + /** Use only when no manually registered renderer exists. */ + readonly fallback?: boolean; +} +/** A core-owned content-control value edit. @public */ +export interface ContentControlWidgetSession { + readonly controlId: string; + readonly kind: 'dropdown' | 'comboBox' | 'date'; + readonly items: readonly { readonly displayText: string; readonly value: string }[]; + readonly value: string; + readonly anchor: HTMLElement | null; + readonly signal: AbortSignal; + canApply(): boolean; + apply(value: string): boolean; + cancel(): void; +} +/** Framework rendering for content-control value widgets. @public */ +export interface ContentControlWidgetChromeHandlers { + readonly onRequest?: (session: ContentControlWidgetSession) => void; +} +/** An invalid protected-field acknowledgement. @public */ +export interface InvalidTextFormFieldSession { + readonly type: 'number' | 'date'; + readonly signal: AbortSignal; + /** Clear the unchanged invalid value, if the target still permits it. */ + acknowledge(): void; + /** Dismiss without clearing the authored value. */ + cancel(): void; +} +/** Framework rendering for invalid protected-field acknowledgements. @public */ +export interface InvalidTextFormFieldChromeHandlers { + readonly onRequest?: (session: InvalidTextFormFieldSession) => void; +} diff --git a/packages/core/src/editor/surface-text-form-fields.ts b/packages/core/src/editor/surface-text-form-fields.ts index 8e945ce93..917c085f1 100644 --- a/packages/core/src/editor/surface-text-form-fields.ts +++ b/packages/core/src/editor/surface-text-form-fields.ts @@ -1,3 +1,5 @@ +import type { InvalidTextFormFieldSession } from './popup-sessions.ts'; +import type { TextFormFieldDialogSession } from './text-form-field-session.ts'; import { supportsTextFormField, formatTextFormValue, @@ -51,7 +53,9 @@ interface Host { locale?(): string; readonly pagesLayer: HTMLElement; readonly container: HTMLElement; - part(): OoxmlPart; + part(paragraphId?: string): OoxmlPart; + onInvalidRequest?: ((session: InvalidTextFormFieldSession) => boolean) | undefined; + onRequest?: ((session: TextFormFieldDialogSession) => boolean) | undefined; parts?(): readonly OoxmlPart[]; protected(paragraphId?: string): boolean; selection(): SemanticSelection; @@ -172,10 +176,22 @@ export function createTextFormFieldInteraction( (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; + let invalidSession = false; + const invalidate = (): void => { + const controller = sessionController; + sessionController = null; + invalidSession = false; 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); @@ -195,15 +211,28 @@ export function createTextFormFieldInteraction( incoming = undefined; } function open(paragraphId: string, field: TextFormFieldRange): void { + if (destroyed) return; close(); const inputLocale = host.locale?.() ?? 'en-US'; - 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', locale: inputLocale, @@ -214,18 +243,26 @@ export function createTextFormFieldInteraction( }) ) 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, - t - ); + 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 => { @@ -439,7 +476,7 @@ export function createTextFormFieldInteraction( delete status.dataset.fieldError; return value; }; - if (committing || dialog?.getAttribute('role') === 'alertdialog') return null; + if (committing || invalidSession) return null; if (!host.editable()) return next; const hit = selectionField(); if ( @@ -506,68 +543,78 @@ export function createTextFormFieldInteraction( status.dataset.fieldError = 'true'; status.textContent = t('textFormField.invalidValue'); if (formatted === null && (field.type === 'number' || field.type === 'date')) { - dialog = textFormFieldInvalidDialog( - host.container, - field.type, - () => { - dialog?.remove(); - dialog = null; - const selected = host.selection(); - const restoreFocus = (): void => { - host.pagesLayer.focus({ preventScroll: true }); - host.select(selected); - }; - const paragraph = findNode(host.part(), paragraphId); - const latest = - paragraph?.kind === 'paragraph' - ? textFormFieldsOf(paragraph).find( - (value) => value.fieldNodeId === field.fieldNodeId - ) - : null; - // Do not discard a concurrent replacement, or bypass a new protection state. + invalidate(); + const controller = new AbortController(); + sessionController = controller; + invalidSession = true; + const acknowledge = () => { + if (destroyed || controller.signal.aborted || sessionController !== controller) return; + invalidate(); + if (destroyed) return; + const selected = host.selection(); + const restoreFocus = (): void => { + host.pagesLayer.focus({ preventScroll: true }); + host.select(selected); + }; + const paragraph = findNode(host.part(), paragraphId); + const latest = + paragraph?.kind === 'paragraph' + ? textFormFieldsOf(paragraph).find( + (value) => value.fieldNodeId === field.fieldNodeId + ) + : null; + // Do not discard a concurrent replacement, or bypass a new protection state. + if ( + !latest || + !host.editable() || + !host.protected(paragraphId) || + rawValue(paragraphId, latest) !== current || + !latest.enabled || + !supportsTextFormField(latest) || + latest.type !== field.type || + latest.format !== field.format || + formatTextFormValue(current, latest, 'fill', inputLocale) !== null + ) { + restoreFocus(); + return; + } + committing = true; + try { if ( - !latest || - !host.editable() || - !host.protected(paragraphId) || - rawValue(paragraphId, latest) !== current || - !latest.enabled || - !supportsTextFormField(latest) || - latest.type !== field.type || - latest.format !== field.format || - formatTextFormValue(current, latest, 'fill', inputLocale) !== null + host.apply({ + op: 'deleteText', + paragraphId, + start: latest.start, + end: latest.end, + textFormFieldId: latest.fieldNodeId, + }) ) { - restoreFocus(); - return; - } - committing = true; - try { - if ( - host.apply({ - op: 'deleteText', - paragraphId, - start: latest.start, - end: latest.end, - textFormFieldId: latest.fieldNodeId, - }) - ) { - forgetField(latest.fieldNodeId); - delete status.dataset.fieldError; - host.pagesLayer.focus({ preventScroll: true }); - const point = { paragraphId, offset: latest.start }; - // Bypass exit validation only for restoring the same, now empty, field. - committing = false; - host.select({ anchor: point, head: point }); - active = { paragraphId, fieldNodeId: latest.fieldNodeId }; - } else { - committing = false; - restoreFocus(); - } - } finally { + forgetField(latest.fieldNodeId); + delete status.dataset.fieldError; + host.pagesLayer.focus({ preventScroll: true }); + const point = { paragraphId, offset: latest.start }; + // Bypass exit validation only for restoring the same, now empty, field. committing = false; + host.select({ anchor: point, head: point }); + active = { paragraphId, fieldNodeId: latest.fieldNodeId }; + } else { + committing = false; + restoreFocus(); } + } finally { + committing = false; + } + }; + const request: InvalidTextFormFieldSession = { + type: field.type, + signal: controller.signal, + acknowledge, + cancel() { + if (sessionController === controller && !controller.signal.aborted) close(); }, - t - ); + }; + if (!host.onInvalidRequest?.(request) && !controller.signal.aborted) + dialog = textFormFieldInvalidDialog(host.container, field.type, acknowledge, t); } return null; } @@ -731,6 +778,8 @@ export function createTextFormFieldInteraction( return true; }, destroy() { + destroyed = true; + invalidate(); if (inputSaves.get(host.container) === saveInput) inputSaves.delete(host.container); if (inputSnapshots.get(host.container) === snapshotInput) inputSnapshots.delete(host.container); 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..b19133d48 --- /dev/null +++ b/packages/core/src/editor/text-form-field-chrome.ts @@ -0,0 +1,56 @@ +import type { ContentControlWidgetSession, InvalidTextFormFieldSession } from './popup-sessions.ts'; +import type { TextFormFieldDialogSession } from './text-form-field-session.ts'; +import type { PopupChromeRegistrationOptions } from './popup-sessions.ts'; + +/** Manual renderers take priority over automatic adapter fallbacks in either mount order. */ +export function createSessionChrome() { + type Handlers = { readonly onRequest?: (session: Session) => void }; + const registrations: { handlers: Handlers; fallback: boolean; sessions: Set }[] = []; + return { + request(session: Session): boolean { + const registration = + [...registrations].reverse().find((entry) => !entry.fallback) ?? registrations.at(-1); + if (!registration?.handlers.onRequest) return false; + const { sessions, handlers } = registration; + sessions.add(session); + session.signal.addEventListener('abort', () => sessions.delete(session), { once: true }); + handlers.onRequest!(session); + return true; + }, + register(handlers: Handlers, options?: PopupChromeRegistrationOptions): () => void { + const entry = { + handlers, + fallback: options?.fallback === true, + sessions: new Set(), + }; + registrations.push(entry); + return () => { + const index = registrations.indexOf(entry); + if (index < 0) return; + registrations.splice(index, 1); + for (const session of entry.sessions) session.cancel(); + }; + }, + }; +} +/** Own Field Options sessions independently of surface replacement. */ +export const createTextFormFieldChrome = () => createSessionChrome(); + +/** Facade wiring shared by the three core-owned popup session families. */ +export function createEditorPopupChrome() { + const text = createTextFormFieldChrome(); + const widget = createSessionChrome(); + const invalid = createSessionChrome(); + return { + surfaceOptions: { + onRequestTextFormField: text.request, + onRequestContentControlWidget: widget.request, + onRequestInvalidTextFormField: invalid.request, + }, + setters: { + setTextFormFieldChrome: text.register, + setContentControlWidgetChrome: widget.register, + setInvalidTextFormFieldChrome: invalid.register, + }, + }; +} diff --git a/packages/core/src/editor/text-form-field-dialog.ts b/packages/core/src/editor/text-form-field-dialog.ts index fd388335f..25b527755 100644 --- a/packages/core/src/editor/text-form-field-dialog.ts +++ b/packages/core/src/editor/text-form-field-dialog.ts @@ -137,7 +137,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-invalid-dialog.ts b/packages/core/src/editor/text-form-field-invalid-dialog.ts index d36ced16a..716909c17 100644 --- a/packages/core/src/editor/text-form-field-invalid-dialog.ts +++ b/packages/core/src/editor/text-form-field-invalid-dialog.ts @@ -35,7 +35,7 @@ export function textFormFieldInvalidDialog( }; ok.addEventListener('click', accept); panel.addEventListener('keydown', (event) => { - if (event.key !== 'Enter') return; + if (event.key !== 'Enter' || event.isComposing) return; event.preventDefault(); event.stopPropagation(); accept(); 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 82e53ec9e..e5cfc6aaa 100644 --- a/packages/core/src/store/store/text-form-field-options.ts +++ b/packages/core/src/store/store/text-form-field-options.ts @@ -16,6 +16,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 2f85e2b71..429dfcda0 100644 --- a/packages/core/src/styles/editor.css +++ b/packages/core/src/styles/editor.css @@ -6681,3 +6681,284 @@ 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; +} + +:where(.docx-content-control-widget-popup) { + position: absolute; + z-index: 20; + display: grid; + gap: 8px; + padding: 12px; + color: var(--doc-text); + background: var(--doc-popover-bg, white); + border: 1px solid var(--doc-border); + border-radius: var(--doc-dialog-radius, 8px); + box-shadow: 0 4px 16px var(--doc-shadow); +} 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 5cdfd80e1..76fef4941 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -1,3 +1,12 @@ +import { DocxEditorNotesContextMenu } from '../editor/note-popup-parts'; +import { DocxEditorNotePreview } from '../editor/note-popup-parts'; +import { DocxEditorNotePropertiesDialog } from '../editor/DocxEditorNotes'; +import { DocxEditorImagePropertiesDialog } from '../editor/images/ImageProperties'; +import { DocxEditorImageAltTextPopup } from '../editor/images/ImageAltText'; +import { DocxEditorInvalidTextFormFieldDialog } from '../editor/DocxEditorInvalidTextFormFieldDialog'; +import { DocxEditorContentControlWidget } from '../editor/DocxEditorContentControlWidget'; +import { createPackagedPopups } from '../editor/popup-config'; +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'; @@ -25,10 +34,7 @@ import { DocxEditorHeaderFooterChrome } from '../editor/DocxEditorHeaderFooter'; import { DocxEditorHyperLink } from '../editor/DocxEditorHyperLink'; import { DocxEditorEquation } from '../editor/DocxEditorEquation'; import { DocxEditorNotesChrome } from '../editor/DocxEditorNotes'; -import { - ContextMenu as DocxEditorContextMenuCompound, - DocxEditorContextMenu, -} from '../editor/contextmenu'; +import { ContextMenu as DocxEditorContextMenuCompound } from '../editor/contextmenu'; import { DocxEditorContentControl } from '../editor/DocxEditorContentControl'; import { LocaleProvider, useTranslation } from '../i18n'; import type { TranslationKey } from '../i18n'; @@ -299,15 +305,6 @@ const DocxEditorFrame = forwardRef( {chrome ? : null} {chrome ? : null} -
); diff --git a/packages/react/src/editor/DocxEditorContentControlWidget.tsx b/packages/react/src/editor/DocxEditorContentControlWidget.tsx new file mode 100644 index 000000000..64e62f1e0 --- /dev/null +++ b/packages/react/src/editor/DocxEditorContentControlWidget.tsx @@ -0,0 +1,152 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import type { ContentControlWidgetSession } from '@docx-editor.dev/core/editor'; +import type { DocxEditorChildren } from '../docx-editor-children'; +import { useFormControlTranslate } from './form-control-translate'; +import { useEditorState } from './useEditorState'; +import { absolutePointInScroller } from './scroller-geometry'; + +/** Value-widget session and optional replacement controls. @public */ +export interface DocxEditorContentControlWidgetProps { + session: ContentControlWidgetSession; + className?: string; + style?: CSSProperties; + children?: DocxEditorChildren; +} +/** Compact value editor for a configured content-control popup. @public */ +export function DocxEditorContentControlWidget(props: DocxEditorContentControlWidgetProps) { + const [session, setSession] = useState(props.session); + const [generation, setGeneration] = useState(0); + if (session !== props.session) { + setSession(props.session); + setGeneration(generation + 1); + } + return ; +} +function WidgetForm({ session, className, style, children }: DocxEditorContentControlWidgetProps) { + const t = useFormControlTranslate(); + const [value, setValue] = useState( + session.kind === 'date' ? session.value.slice(0, 10) : session.value + ); + const [closed, setClosed] = useState(session.signal.aborted); + const [refused, setRefused] = useState(false); + const panelRef = useRef(null); + const [position, setPosition] = useState({}); + const enabled = useEditorState(() => session.canApply()); + useEffect(() => { + const abort = () => setClosed(true); + session.signal.addEventListener('abort', abort, { once: true }); + if (session.signal.aborted) abort(); + return () => session.signal.removeEventListener('abort', abort); + }, [session]); + useLayoutEffect(() => { + if (closed) return; + const panel = panelRef.current; + const owner = panel?.ownerDocument; + const opener = owner?.activeElement as HTMLElement | null; + const scroller = panel?.closest('.docx-editor__scroll-container'); + const anchor = session.anchor; + if (anchor && scroller) { + const rect = anchor.getBoundingClientRect(); + setPosition(absolutePointInScroller(scroller, rect.left, rect.bottom)); + } + panel?.querySelector('input,select,button')?.focus({ preventScroll: true }); + return () => { + const active = owner?.activeElement; + if (opener?.isConnected && (active === owner?.body || (active && panel?.contains(active)))) { + opener.focus({ preventScroll: true }); + } + }; + }, [session, closed]); + useEffect(() => { + const panel = panelRef.current; + if (!panel || closed) return; + const dismiss = (event: PointerEvent) => { + if (!panel.contains(event.target as Node)) session.cancel(); + }; + panel.ownerDocument.addEventListener('pointerdown', dismiss, true); + return () => panel.ownerDocument.removeEventListener('pointerdown', dismiss, true); + }, [session, closed]); + if (closed) return null; + const apply = () => setRefused(!session.apply(value)); + const label = t(`contentControl.types.${session.kind}`); + return ( +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + session.cancel(); + } + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + event.preventDefault(); + apply(); + } + }} + > + {children ?? ( + <> + {session.kind === 'dropdown' ? ( + + ) : ( + setValue(event.target.value)} + /> + )} + {session.kind === 'comboBox' + ? session.items.map((item, index) => ( + + )) + : null} + {refused ?
{t('disabledReason.invalidValue')}
: null} +
+ + +
+ + )} +
+ ); +} diff --git a/packages/react/src/editor/DocxEditorInvalidTextFormFieldDialog.tsx b/packages/react/src/editor/DocxEditorInvalidTextFormFieldDialog.tsx new file mode 100644 index 000000000..c7381784d --- /dev/null +++ b/packages/react/src/editor/DocxEditorInvalidTextFormFieldDialog.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import type { InvalidTextFormFieldSession } from '@docx-editor.dev/core/editor'; +import type { DocxEditorChildren } from '../docx-editor-children'; +import { useFormControlTranslate } from './form-control-translate'; +import { DialogFrame } from './dialog-parts'; + +/** Invalid-value acknowledgement with editor-owned clearing and focus restoration. @public */ +export interface DocxEditorInvalidTextFormFieldDialogProps { + session: InvalidTextFormFieldSession; + className?: string; + style?: CSSProperties; + children?: DocxEditorChildren; +} +/** Default acknowledgement shell for a custom popup renderer. @public */ +export function DocxEditorInvalidTextFormFieldDialog( + props: DocxEditorInvalidTextFormFieldDialogProps +) { + const [session, setSession] = useState(props.session); + const [generation, setGeneration] = useState(0); + if (session !== props.session) { + setSession(props.session); + setGeneration(generation + 1); + } + return ; +} +function InvalidForm({ + session, + className, + style, + children, +}: DocxEditorInvalidTextFormFieldDialogProps) { + const t = useFormControlTranslate(); + const [closed, setClosed] = useState(session.signal.aborted); + const panelRef = useRef(null); + useEffect(() => { + const abort = () => setClosed(true); + session.signal.addEventListener('abort', abort, { once: true }); + if (session.signal.aborted) abort(); + return () => session.signal.removeEventListener('abort', abort); + }, [session]); + if (closed) return null; + return ( + { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault(); + session.acknowledge(); + } + }} + > + {children ?? ( + <> +
+

{t('textFormField.invalidTitle')}

+
+
+ {t( + session.type === 'number' + ? 'textFormField.invalidNumber' + : 'textFormField.invalidDate' + )} +
+
+ +
+ + )} +
+ ); +} diff --git a/packages/react/src/editor/DocxEditorNotes.tsx b/packages/react/src/editor/DocxEditorNotes.tsx index 2b46d4bd0..032564ed9 100644 --- a/packages/react/src/editor/DocxEditorNotes.tsx +++ b/packages/react/src/editor/DocxEditorNotes.tsx @@ -1,3 +1,15 @@ +import { renderPopup } from './popup-renderer'; +import { usePopupConfig } from './popup-config'; +import { + DocxEditorNotePreview, + DocxEditorNotesContextMenu, + type DocxEditorNotesContextMenuProps, +} from './note-popup-parts'; +export { DocxEditorNotePreview, DocxEditorNotesContextMenu } from './note-popup-parts'; +export type { + DocxEditorNotePreviewProps, + DocxEditorNotesContextMenuProps, +} from './note-popup-parts'; // Thin React note chrome: hover preview, context menu, properties dialog. // // No model/layout logic — reads engine state and dispatches Editor.exec / setActiveScope. @@ -5,7 +17,7 @@ // and navigates only. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { CSSProperties, MouseEvent, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import type { EditorCommand } from '@docx-editor.dev/core/contracts/editor'; import { useTranslation } from '../i18n'; import { Z_INDEX } from '../styles/zIndex'; @@ -123,6 +135,8 @@ function NoteStoryOptions(props: { export function DocxEditorNotesChrome({ className, }: DocxEditorNotesChromeProps): ReactElement | null { + const popups = usePopupConfig(); + const chromeRef = useRef(null); const { t } = useTranslation(); const editor = useDocxEditor(); const noteScope = useNoteScopeState(); @@ -158,7 +172,9 @@ export function DocxEditorNotesChrome({ useEffect(() => { if (!editor) return undefined; - const root = document.querySelector('.docx-pages'); + const root = + chromeRef.current?.closest('.docx-editor__scroll-container') ?? + chromeRef.current?.closest('.docx-editor'); if (!root) return undefined; const onClick = (event: Event) => { @@ -279,29 +295,6 @@ export function DocxEditorNotesChrome({ convertAllCmd ?? { type: 'convertAllNotes', fromKind: 'footnote' } ); - const previewStyle: CSSProperties = useMemo( - () => ({ - position: 'fixed', - left: preview?.x ?? 0, - top: preview?.y ?? 0, - zIndex: Z_INDEX.popover, - maxWidth: 280, - maxHeight: '40vh', - overflowY: 'auto', - padding: '8px 10px', - background: 'var(--doc-popover-bg, #fff)', - color: 'var(--doc-popover-fg, #111)', - border: '1px solid var(--doc-border, #ddd)', - boxShadow: 'var(--doc-shadow, 0 4px 16px rgba(0,0,0,.12))', - fontSize: 12, - lineHeight: 1.4, - // A preview is informational, never an interaction surface. Large attacker-authored - // notes must not cover the viewport or intercept pointer input. - pointerEvents: 'none', - }), - [preview] - ); - const parsedActive = noteScope ? parseNoteScopeId(noteScope.id) : null; const regionLabel = parsedActive ? t('notes.editingRegion', { @@ -311,11 +304,43 @@ export function DocxEditorNotesChrome({ }) : null; + const menuProps: DocxEditorNotesContextMenuProps | null = + menu && menuParsed + ? { + ...menu, + ...menuParsed, + onDelete: () => { + if (deleteCmd) runNoteCommand(deleteCmd); + }, + onConvert: () => { + if (convertCmd) runNoteCommand(convertCmd); + }, + onConvertAll: () => { + if (convertAllCmd) runNoteCommand(convertAllCmd); + }, + onOpenProperties: () => setPropsOpen(true), + onClose: () => setMenu(null), + deleteEnabled: deleteGate.enabled, + convertEnabled: convertGate.enabled, + convertAllEnabled: convertAllGate.enabled, + deleteDisabledReason: deleteGate.reason ?? undefined, + convertDisabledReason: convertGate.reason ?? undefined, + convertAllDisabledReason: convertAllGate.reason ?? undefined, + } + : null; + const propertiesProps: DocxEditorNotePropertiesDialogProps = { + onClose: () => setPropsOpen(false), + onApply: (command) => { + runNoteCommand(command); + setPropsOpen(false); + }, + }; if (!editor) return null; return (
) : null} - {preview ? ( -
{ - event.preventDefault(); - event.stopPropagation(); - }} - > - {preview.text} -
+ {preview && popups?.notePreview !== false ? ( + popups?.notePreview ? ( + renderPopup(popups.notePreview, preview) + ) : ( + + ) ) : null} - - {menu && menuParsed ? ( -
- - - - -
+ {menuProps && popups?.notesContextMenu !== false ? ( + popups?.notesContextMenu ? ( + renderPopup(popups.notesContextMenu, menuProps) + ) : ( + + ) ) : null} - {propsOpen ? ( - setPropsOpen(false)} - onApply={(command) => { - runNoteCommand(command); - setPropsOpen(false); - }} - /> + {propsOpen && popups?.noteProperties !== false ? ( + popups?.noteProperties ? ( + renderPopup(popups.noteProperties, propertiesProps) + ) : ( + + ) ) : null}
); } -function NotePropertiesDialog(props: { +/** Properties actions for a note settings dialog. @public */ +export interface DocxEditorNotePropertiesDialogProps { readonly onClose: () => void; readonly onApply: (command: EditorCommand) => void; -}): ReactElement { +} +/** Default note properties dialog. @public */ +export function DocxEditorNotePropertiesDialog( + props: DocxEditorNotePropertiesDialogProps +): ReactElement { const { t } = useTranslation(); const editor = useDocxEditor(); const engineState = useNotePropertiesState(); 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 c16c22b28..25b6b88b3 100644 --- a/packages/react/src/editor/DocxEditorRoot.tsx +++ b/packages/react/src/editor/DocxEditorRoot.tsx @@ -1,3 +1,6 @@ +import { FormControlTranslateProvider } from './form-control-translate'; +import { DialogProvider } from './dialog-host'; +import { PopupConfigProvider, type DocxEditorPopups } from './popup-config'; import type { DocxEditorChildren } from '../docx-editor-children'; // Provider-first host for the docx editor facade. // @@ -59,6 +62,8 @@ import { * @public */ export interface DocxEditorRootProps { + /** Customize automatically mounted popups. Set an entry to false for manual ownership. */ + popups?: DocxEditorPopups; /** 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. */ @@ -421,22 +426,28 @@ export function DocxEditorRoot(props: DocxEditorRootProps) { }, [revisionStyleRegistry, editor]); return ( - - - - - {/* ONE link-popover state per editor, published here so a TOOLBAR button and the + + + + + + {/* 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..85f57102b --- /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 { useFormControlTranslate } from './form-control-translate'; +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 = useFormControlTranslate(); + 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..fde3a12ef --- /dev/null +++ b/packages/react/src/editor/dialog-host.tsx @@ -0,0 +1,119 @@ +import { renderPopup } from './popup-renderer'; +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { DocxEditorPopups } from './popup-config'; +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 } from './DocxEditorPageSetup'; +import { DocxEditorParagraphDialog } from './DocxEditorParagraphDialog'; +import { DocxEditorTextFormFieldDialog } from './DocxEditorTextFormFieldDialog'; + +interface DialogHost { + readonly ownsPageSetup: boolean; + 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({ + popups, + children, +}: { + popups?: DocxEditorPopups; + 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 popupsRef = useRef(popups); + popupsRef.current = popups; + 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) { + if (popupsRef.current?.textFormField === false) { + request.cancel(); + return; + } + setActive(null); + setSession(request); + request.signal.addEventListener( + 'abort', + () => setSession((previous) => (previous === request ? null : previous)), + { once: true } + ); + }, + }, + { fallback: true } + ); + }, [editor]); + useEffect(() => { + setActive(null); + setSession(null); + }, [editor, generation]); + useEffect(() => { + if (popups?.textFormField === false) sessionRef.current?.cancel(); + if (active && popups?.[active] === false) setActive(null); + }, [popups, active]); + const host = useMemo( + () => ({ + setContainer, + ownsPageSetup: popups?.pageSetup !== undefined, + open(kind, returnFocusTo) { + sessionRef.current?.cancel(); + if (popupsRef.current?.[kind] === false) return; + opener.current = + returnFocusTo ?? (container?.ownerDocument.activeElement as HTMLElement | null); + setActive(kind); + }, + }), + [container, popups?.pageSetup] + ); + const props = { open: true, onClose: close }; + const content = + active === 'pageSetup' && popups?.pageSetup !== false ? ( + popups?.pageSetup ? ( + renderPopup(popups.pageSetup, props) + ) : ( + + ) + ) : active === 'paragraph' && popups?.paragraph !== false ? ( + popups?.paragraph ? ( + renderPopup(popups.paragraph, props) + ) : ( + + ) + ) : session && popups?.textFormField !== false ? ( + popups?.textFormField ? ( + renderPopup(popups.textFormField, { session }, 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..98ae5f26b --- /dev/null +++ b/packages/react/src/editor/dialog-parts.tsx @@ -0,0 +1,318 @@ +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, + role = 'dialog', + className, + style, + label, + onClose, + onKeyDown, + panelRef, + children, + dismissOutside = true, + sessionSignal, + restoreFocus = true, +}: { + kind: 'pageSetup' | 'paragraph' | 'textFormField' | 'invalidTextFormField'; + role?: 'dialog' | 'alertdialog'; + 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/engine-popups.tsx b/packages/react/src/editor/engine-popups.tsx new file mode 100644 index 000000000..c6060ff2e --- /dev/null +++ b/packages/react/src/editor/engine-popups.tsx @@ -0,0 +1,84 @@ +import { renderPopup } from './popup-renderer'; +import { useEffect, useRef, useState } from 'react'; +import type { + ContentControlWidgetSession, + InvalidTextFormFieldSession, +} from '@docx-editor.dev/core/editor'; +import { useDocxEditor } from './context'; +import { usePopupConfig } from './popup-config'; + +/** Registers configured engine popups below manual handlers, independent of effect order. */ +export function EnginePopups() { + const editor = useDocxEditor(); + const popups = usePopupConfig(); + const current = useRef(popups); + current.current = popups; + const [widget, setWidget] = useState(null); + const [invalid, setInvalid] = useState(null); + const widgetConfigured = popups?.contentControlWidget !== undefined; + const invalidConfigured = popups?.invalidTextFormField !== undefined; + useEffect(() => { + if (!editor || !widgetConfigured) return; + let mounted = true; + const dispose = editor.setContentControlWidgetChrome( + { + onRequest(session) { + if (current.current?.contentControlWidget === false) return session.cancel(); + setWidget(session); + session.signal.addEventListener( + 'abort', + () => { + if (mounted) setWidget((previous) => (previous === session ? null : previous)); + }, + { once: true } + ); + }, + }, + { fallback: true } + ); + return () => { + mounted = false; + dispose(); + setWidget(null); + }; + }, [editor, widgetConfigured]); + useEffect(() => { + if (!editor || !invalidConfigured) return; + let mounted = true; + const dispose = editor.setInvalidTextFormFieldChrome( + { + onRequest(session) { + if (current.current?.invalidTextFormField === false) return session.cancel(); + setInvalid(session); + session.signal.addEventListener( + 'abort', + () => { + if (mounted) setInvalid((previous) => (previous === session ? null : previous)); + }, + { once: true } + ); + }, + }, + { fallback: true } + ); + return () => { + mounted = false; + dispose(); + setInvalid(null); + }; + }, [editor, invalidConfigured]); + useEffect(() => { + if (popups?.contentControlWidget === false) widget?.cancel(); + if (popups?.invalidTextFormField === false) invalid?.cancel(); + }, [popups, widget, invalid]); + return ( + <> + {widget && + popups?.contentControlWidget && + renderPopup(popups.contentControlWidget, { session: widget }, widget)} + {invalid && + popups?.invalidTextFormField && + renderPopup(popups.invalidTextFormField, { session: invalid }, invalid)} + + ); +} diff --git a/packages/react/src/editor/form-control-translate.tsx b/packages/react/src/editor/form-control-translate.tsx new file mode 100644 index 000000000..8dd3327a9 --- /dev/null +++ b/packages/react/src/editor/form-control-translate.tsx @@ -0,0 +1,18 @@ +import { createContext, useCallback, useContext } from 'react'; +import type { TFunction } from '@docx-editor.dev/i18n'; +import { useTranslation } from '../i18n'; + +const Context = createContext(undefined); +/** Internal bridge between Root's live engine resolver and framework-owned form controls. */ +export const FormControlTranslateProvider = Context.Provider; +export function useFormControlTranslate(): TFunction { + const override = useContext(Context); + const { t } = useTranslation(); + return useCallback( + (key, params) => { + const translated = override?.(key, params); + return translated === undefined || translated === key ? t(key, params) : translated; + }, + [override, t] + ); +} diff --git a/packages/react/src/editor/images/ImageAltText.tsx b/packages/react/src/editor/images/ImageAltText.tsx index 89e21c30a..33593cb3d 100644 --- a/packages/react/src/editor/images/ImageAltText.tsx +++ b/packages/react/src/editor/images/ImageAltText.tsx @@ -1,3 +1,6 @@ +import { renderPopup } from '../popup-renderer'; +import { usePopupConfig } from '../popup-config'; +import type { RefObject } from '../../docx-editor-ref-object'; import type { DocxEditorChildren } from '../../docx-editor-children'; // Alt-text authoring: description and title, never `@name` fallback. @@ -23,7 +26,7 @@ export interface ImageAltTextProps { * @public */ export function ImageAltText({ className, hidden, asChild, children }: ImageAltTextProps) { - const { t } = useTranslation(); + const popups = usePopupConfig(); const label = useToolbarLabel(); const { execute, value, isEnabled, disabledReason } = useEditorValueCommand('image.altText'); const [open, setOpen] = useState(false); @@ -40,7 +43,12 @@ export function ImageAltText({ className, hidden, asChild, children }: ImageAltT if (!open) return undefined; const onMouseDown = (event: MouseEvent): void => { const root = rootRef.current; - if (root && event.target instanceof Node && root.contains(event.target)) return; + if ( + event.target instanceof Node && + (root?.contains(event.target) || + root?.ownerDocument.getElementById(panelId)?.contains(event.target)) + ) + return; setOpen(false); }; const onKeyDown = (event: KeyboardEvent): void => { @@ -55,7 +63,7 @@ export function ImageAltText({ className, hidden, asChild, children }: ImageAltT document.removeEventListener('mousedown', onMouseDown, true); document.removeEventListener('keydown', onKeyDown); }; - }, [open]); + }, [open, panelId]); const apply = useCallback(() => { execute(draft); @@ -82,6 +90,16 @@ export function ImageAltText({ className, hidden, asChild, children }: ImageAltT onClick: () => setOpen((was) => !was), }; + const popupProps: DocxEditorImageAltTextPopupProps = { + id: panelId, + value: draft, + onValueChange: setDraft, + onApply: apply, + onClose: () => setOpen(false), + isEnabled, + anchorRef: triggerRef, + }; + return (
{asChild ? ( @@ -89,37 +107,12 @@ export function ImageAltText({ className, hidden, asChild, children }: ImageAltT ) : ( )} - {open ? ( -