diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index 09a5decaa0..75eca040a5 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where clicking a file action button or the retry button submitted the surrounding form, causing the page to submit or a containing dialog to close unexpectedly. + ### Changed - Since version 2.5.0, removing a file with the default remove button removes the entry from the file list immediately, instead of leaving it in the list greyed out. This matches how removal already worked when custom buttons are configured. This change was missing from the 2.5.0 release notes. diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/.openspec.yaml b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/.openspec.yaml similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/.openspec.yaml rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/.openspec.yaml diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/design.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/design.md similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/design.md rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/design.md diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/proposal.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/proposal.md similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/proposal.md rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/proposal.md diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/tasks.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/tasks.md similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/tasks.md rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/tasks.md diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml new file mode 100644 index 0000000000..b4b3ece784 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md new file mode 100644 index 0000000000..01138661f7 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md @@ -0,0 +1,63 @@ +## Context + +`ActionButton` renders the per-file action buttons in the files list (`.action-button`, e.g. the "add" / "remove" / custom list-action buttons). `RetryButton` renders the retry affordance on a failed upload. Both are plain ` - + + {title &&

{title}

} + {message && ( +
+

{message}

+ )} +
+ +
- +
); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss index 23c1d33e67..2adbf4a798 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss @@ -1,3 +1,27 @@ +// Single stacking scale for every dialog. Dialogs are portalled to the document body, so they must +// clear whatever Atlas modal layer the widget itself is sitting inside. +$rte-dialog-overlay-z: 10000; +$rte-dialog-z: 10001; + +// Positioned wrapper for an inline dialog, portalled to the body. +.widget-rich-text-dialog-layer { + z-index: $rte-dialog-z; +} + +// Dimmed, scroll-locked backdrop for a focused dialog. Position and inset come from Floating UI's +// FloatingOverlay; centering, dimming and stacking come from here. +.widget-rich-text-dialog-overlay { + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + z-index: $rte-dialog-overlay-z; + + > .toolbar-dialog { + z-index: $rte-dialog-z; + } +} + .toolbar-dialog, .link-dialog { background: white; @@ -7,11 +31,38 @@ min-width: 320px; max-width: 400px; + // The title and the actions stay pinned while the region between them scrolls, so a tall + // dialog — a Media Library listing many images, say — can never push Insert/Cancel out of + // reach. `max-height` is supplied by DialogShell: available space when inline, 70vh when + // focused. + display: flex; + flex-direction: column; + h3 { padding: 8px 16px; - font-size: 16px; + font-size: var(--font-size-medium, 16px); font-weight: 600; border-bottom: 1px solid var(--border-color-default, #ced0d3); + flex-shrink: 0; + } + + // Dialogs that wrap their content in a (or, for the image dialog, a plain div) put this + // class on that wrapper, so the column layout survives the extra element. + .dialog-layout { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + } + + // Scrollable middle region. `min-height: 0` is required: a flex item defaults to + // `min-height: auto`, which refuses to shrink below its content height and would defeat the + // dialog's max-height entirely. + .dialog-scroll, + .help-dialog-content { + flex: 1; + min-height: 0; + overflow-y: auto; } .dialog-mode-selector { @@ -33,7 +84,7 @@ label { cursor: pointer; - font-size: 13px; + font-size: var(--font-size-small, 13px); font-weight: 500; margin-bottom: 0; user-select: none; @@ -69,7 +120,7 @@ min-width: 0; .preview-name { - font-size: 13px; + font-size: var(--font-size-small, 13px); font-weight: 500; color: #333; white-space: nowrap; @@ -79,7 +130,7 @@ } .preview-size { - font-size: 11px; + font-size: var(--font-size-smaller, 11px); color: #999; } } @@ -105,9 +156,11 @@ } } + // No min-height: the embedded image-source widget is app-developer content of arbitrary height, + // and forcing it to grow is what used to push the dialog past the viewport. It scrolls with + // `.dialog-scroll` instead. .image-dialog-entity { margin: 0 var(--spacing-medium, 16px); - min-height: 100px; } .dialog-field { @@ -116,7 +169,7 @@ label { display: block; margin-bottom: 4px; - font-size: 12px; + font-size: var(--font-size-small, 12px); font-weight: 600; color: #333; @@ -132,7 +185,7 @@ padding: 6px 8px; border: 1px solid var(--border-color-default, #ced0d3); border-radius: 3px; - font-size: 13px; + font-size: var(--font-size-small, 13px); box-sizing: border-box; &:focus { @@ -142,7 +195,7 @@ } input[type="file"] { - font-size: 13px; + font-size: var(--font-size-small, 13px); } } @@ -185,34 +238,14 @@ gap: 8px; padding: 8px 16px; border-top: 1px solid var(--border-color-default, #ced0d3); + flex-shrink: 0; button { - padding: 6px 16px; - border: 1px solid var(--border-color-default, #ced0d3); - border-radius: 3px; - font-size: 13px; - cursor: pointer; - background: white; - - &[type="submit"] { - background: var(--brand-primary, #264ae5); - color: white; - border-color: var(--brand-primary, #264ae5); - - &:hover:not(:disabled) { - background: var(--brand-primary-hover, #106ebe); - } + font-size: var(--font-size-small, 13px); - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - - &[type="button"] { - &:hover { - background: #f3f3f3; - } + &:disabled { + opacity: 0.5; + cursor: not-allowed; } } } @@ -226,7 +259,7 @@ .confirm-message { padding: 16px; - font-size: 14px; + font-size: var(--font-size-default, 14px); color: #666; line-height: 1.5; } @@ -299,9 +332,9 @@ max-width: 480px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + // Height comes from the shell's max-height on `.toolbar-dialog`; the scroll behaviour comes from + // the shared `.help-dialog-content` rule above. Capping it again here would nest two scrollers. .help-dialog-content { - max-height: 60vh; - overflow-y: auto; padding: 8px 16px; } @@ -350,22 +383,11 @@ } } -.confirm-dialog-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - - .confirm-dialog { - max-width: 400px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); - } +// Overlay, centering and stacking now come from `.widget-rich-text-dialog-overlay`; only the +// dialog's own box styling is left here. +.confirm-dialog { + max-width: 400px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } // Video dialog and Image dialog enhancements diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx new file mode 100644 index 0000000000..09728c27bf --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx @@ -0,0 +1,167 @@ +import { + FloatingFocusManager, + FloatingOverlay, + FloatingPortal, + useDismiss, + useFloating, + useInteractions, + useMergeRefs, + useRole +} from "@floating-ui/react"; +import classNames from "classnames"; +import { ReactElement, ReactNode, Ref, useEffect, useRef } from "react"; +import { DialogStyleEnum } from "../../../../typings/RichTextProps"; +import { useDropdown } from "../hooks/useDropdown"; +import "./Dialog.scss"; + +export interface DialogShellProps { + /** + * `inline` anchors the dialog to `referenceElement`; `focused` centers it over a dimmed, + * scroll-locked overlay with a focus trap. + */ + mode: DialogStyleEnum; + onClose: () => void; + /** Anchor for `inline` mode. Unused in `focused` mode. */ + referenceElement?: HTMLElement | null; + /** Dialog-specific class, e.g. `image-dialog`, applied next to `toolbar-dialog`. */ + className?: string; + /** Id of the element labelling the dialog. */ + ariaLabelledBy?: string; + /** + * Ref onto the `.toolbar-dialog` element. `ImageDialog` needs it: app-developer JS actions + * dispatch the `imageSelected` custom event at that element, so it must stay the same node. + */ + dialogRef?: Ref; + children: ReactNode; +} + +/** + * Bound for `focused` mode, and the inline fallback until the measured available height arrives (or + * for good, if there is no anchor to measure against). Matches v4's `--max-dialog-height` default. + */ +const DEFAULT_MAX_HEIGHT = "70vh"; + +/** + * Shared shell for every Rich Text dialog. + * + * Both modes portal to the document body. Rendering in place is what allowed a tall dialog to be + * clipped: the widget node sets `overflow: hidden`, and any ancestor with a transform — a Mendix + * popup page, for one — turns it into the containing block for `position: fixed`, so the dialog + * could no longer escape it. Both modes also bound their own height and expect the caller to mark + * the region that should scroll (`.dialog-scroll`), which keeps `.dialog-actions` reachable. + */ +export function DialogShell({ + mode, + onClose, + referenceElement, + className, + ariaLabelledBy, + dialogRef, + children +}: DialogShellProps): ReactElement { + const isInline = mode === "inline"; + + // Inline positioning. `trackAvailableHeight` reports the room left at the resolved placement so + // the dialog shrinks to fit instead of overflowing the viewport. + const { + refs: inlineRefs, + floatingStyles, + availableHeight + } = useDropdown({ + isOpen: isInline, + onClose, + referenceElement, + trackAvailableHeight: true + }); + + // Focused mode interactions. Hooks run in both modes — React requires an unconditional call — + // but stay inert while `open` is false. + const { refs: focusedRefs, context } = useFloating({ + open: !isInline, + onOpenChange: open => { + if (!open) { + onClose(); + } + } + }); + // Escape is handled below in the capture phase, so `useDismiss` must not also claim it. + const dismiss = useDismiss(context, { outsidePressEvent: "mousedown", escapeKey: false }); + const role = useRole(context, { role: "dialog" }); + const { getFloatingProps } = useInteractions([dismiss, role]); + + // Focus target when the dialog itself opens with nothing already focused inside it. A dialog + // whose first field carries `autoFocus` keeps that focus: FloatingFocusManager leaves focus + // alone when it is already inside the floating element. + const focusTargetRef = useRef(null); + const focusedDialogRef = useMergeRefs([focusedRefs.setFloating, focusTargetRef, dialogRef ?? null]); + + // Escape closes the dialog, and stops there. Without the capture phase and `stopPropagation`, + // an Escape meant for the dialog also reaches the editor, whose Fullscreen extension exits + // fullscreen on Escape — closing the dialog and leaving fullscreen in one keystroke. + useEffect(() => { + if (isInline) { + return; + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") { + return; + } + event.preventDefault(); + event.stopPropagation(); + onClose(); + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isInline, onClose]); + + const dialogClassName = classNames("toolbar-dialog", className); + + if (isInline) { + return ( + + {/* `widget-rich-text` travels with the portalled node so widget-scoped styling and + custom properties still resolve outside the widget's own subtree. */} +
+
+ {children} +
+
+
+ ); + } + + return ( + + + +
+ {children} +
+
+
+
+ ); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx index 656ddc10d0..06c3bc8a68 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx @@ -1,4 +1,5 @@ -import { ReactElement, useEffect, useRef } from "react"; +import { ReactElement } from "react"; +import { DialogShell } from "./DialogShell"; import { useT } from "../../../utils/i18n"; import { SHORTCUT_CATEGORIES } from "../helpers/shortcuts"; import "./Dialog.scss"; @@ -11,73 +12,36 @@ const TITLE_ID = "rich-text-help-dialog-title"; /** * Centered modal listing the editor's keyboard shortcuts (TinyMCE-style help). - * Mirrors the ConfirmDialog pattern: overlay, click-outside to close, Escape to - * close. Escape is scoped to this dialog and its propagation is stopped so the - * fullscreen/editor Escape handlers don't also fire while the dialog is open. + * Always focused, whatever the widget's "Dialog style" is set to: it is a reference panel with no + * anchor to attach to. Overlay, click-outside, Escape (whose propagation is stopped so the + * fullscreen/editor Escape handlers don't also fire) and focus handling all come from DialogShell. */ export function HelpDialog({ onClose }: HelpDialogProps): ReactElement { - const dialogRef = useRef(null); const t = useT(); - useEffect(() => { - const handleClickOutside = (event: MouseEvent): void => { - if (dialogRef.current && !dialogRef.current.contains(event.target as Node)) { - onClose(); - } - }; - - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKeyDown, true); - - // Move focus into the dialog on open. - dialogRef.current?.focus(); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKeyDown, true); - }; - }, [onClose]); - return ( -
-
-

{t("help.title")}

-
- {SHORTCUT_CATEGORIES.map(category => ( -
-

{t(category.titleKey)}

-
    - {category.shortcuts.map(shortcut => ( -
  • - {t(shortcut.labelKey)} - {shortcut.keys} -
  • - ))} -
-
- ))} -
-
- -
+ +

{t("help.title")}

+
+ {SHORTCUT_CATEGORIES.map(category => ( +
+

{t(category.titleKey)}

+
    + {category.shortcuts.map(shortcut => ( +
  • + {t(shortcut.labelKey)} + {shortcut.keys} +
  • + ))} +
+
+ ))} +
+
+
-
+ ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx index 2ad9721346..7d37907999 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx @@ -1,17 +1,14 @@ import classNames from "classnames"; -import { ReactElement, useState, useRef, useEffect, FormEvent } from "react"; +import { ReactElement, useState, useEffect, KeyboardEvent } from "react"; import { useDropzone } from "react-dropzone"; -import { useT, TranslateFn } from "../../../utils/i18n"; +import { DialogShell } from "./DialogShell"; +import { useT } from "../../../utils/i18n"; +import { MAX_FILE_SIZE, formatFileSize, readFileAsDataUrl, validateImageFile } from "../../../utils/imageFiles"; import { useCurrentEditor } from "../../EditorContext"; -import { ImageDialogProps, EntityImage, ImageSourceMode, MAX_FILE_SIZE } from "../helpers/toolbarTypes"; -import { useDropdown } from "../hooks/useDropdown"; +import { ImageDialogProps, EntityImage, ImageSourceMode } from "../helpers/toolbarTypes"; import "./Dialog.scss"; -const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; +const TITLE_ID = "rich-text-image-dialog-title"; const toPixelValue = (value: string): string | undefined => { const parsed = Number(value); @@ -21,18 +18,8 @@ const toPixelValue = (value: string): string | undefined => { return `${parsed}px`; }; -const validateFile = (file: File, t: TranslateFn): string | null => { - if (file.size > MAX_FILE_SIZE) { - return t("image.errorTooLarge", formatFileSize(file.size)); - } - if (!file.type.startsWith("image/")) { - return t("image.errorNotImage"); - } - return null; -}; - export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): ReactElement { - const { editor, imageConfig } = useCurrentEditor(); + const { editor, imageConfig, dialogStyle } = useCurrentEditor(); const { imageSourceContent, enableDefaultUpload, hasImageSource } = imageConfig; const t = useT(); const [activeTab, setActiveTab] = useState("url"); @@ -45,13 +32,11 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re const [uploadedFile, setUploadedFile] = useState(null); const [selectedEntityImage, setSelectedEntityImage] = useState(null); const [dragError, setDragError] = useState(""); - const dialogRef = useRef(null); - - const { refs, floatingStyles } = useDropdown({ - isOpen: true, - onClose, - referenceElement - }); + // The `imageSelected` event target: app-developer JS actions dispatch that event at the + // `.toolbar-dialog` node, so `DialogShell` forwards this ref onto it rather than owning it. + // Held in state, not a ref: the dialog is portalled, and a portal's children mount one commit + // after the dialog itself, so a mount-time effect would still see `null`. + const [dialogNode, setDialogNode] = useState(null); const handleTabChange = (newTab: ImageSourceMode): void => { setActiveTab(newTab); @@ -87,23 +72,22 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re } const file = acceptedFiles[0]; - const error = validateFile(file, t); + const error = validateImageFile(file); if (error) { - setDragError(error); + setDragError(error.arg ? t(error.key, error.arg) : t(error.key)); return; } - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result as string; - setSrc(base64); - setUploadedFile(file); - }; - reader.onerror = () => { - setDragError(t("image.errorReadFailed")); - }; - reader.readAsDataURL(file); + readFileAsDataUrl(file).then( + base64 => { + setSrc(base64); + setUploadedFile(file); + }, + () => { + setDragError(t("image.errorReadFailed")); + } + ); }; const handleClearFile = (): void => { @@ -123,8 +107,7 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re multiple: false }); - const handleSubmit = (e: FormEvent): void => { - e.preventDefault(); + const handleInsert = (): void => { if (!editor || !src.trim()) return; const imageAttrs: any = { @@ -155,6 +138,17 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re onClose(); }; + // Enter inserts only from the dialog's own single-line inputs. The dialog deliberately has no + // , so nothing inside `imageSourceContent` or the dropzone can trigger an insert. + // preventDefault also stops implicit submission of any form the widget itself is placed in. + const handleInputKeyDown = (e: KeyboardEvent): void => { + if (e.key !== "Enter") { + return; + } + e.preventDefault(); + handleInsert(); + }; + const handleImageSelected = (event: CustomEvent): void => { const imageData = event.detail; if (imageData.url && isPromise(imageData.url)) { @@ -168,31 +162,38 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re // Set the selected entity image setSelectedEntityImage(imageData); - // Switch to entity tab if not already - if (activeTab !== "entity") { - setActiveTab("entity"); - } + setActiveTab("entity"); }; useEffect(() => { // event listener for image selection triggered from custom widgets JS Action - const imgRef = dialogRef.current; - - if (imgRef !== null) { - imgRef.addEventListener("imageSelected", handleImageSelected); + if (dialogNode === null) { + return; } + + dialogNode.addEventListener("imageSelected", handleImageSelected); return () => { - imgRef?.removeEventListener("imageSelected", handleImageSelected); + dialogNode.removeEventListener("imageSelected", handleImageSelected); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dialogRef.current]); + // Registered once per dialog node. The handler only uses state setters, so it reads no + // stale state. + }, [dialogNode]); return ( -
-
- -

{t("image.title")}

- + + {/* Intentionally not a : `imageSourceContent` is app-developer content, and a + descendant
@@ -345,6 +348,7 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re type="text" value={title} onChange={e => setTitle(e.target.value)} + onKeyDown={handleInputKeyDown} placeholder={t("image.titlePlaceholder")} />
@@ -358,6 +362,7 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re type="number" value={width} onChange={e => setWidth(e.target.value)} + onKeyDown={handleInputKeyDown} />
@@ -367,6 +372,7 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re type="number" value={height} onChange={e => setHeight(e.target.value)} + onKeyDown={handleInputKeyDown} disabled={maintainRatio} />
@@ -382,19 +388,19 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re {t("image.maintainRatio")} - - {/* Action Buttons */} -
- - -
- + + + {/* Action Buttons */} +
+ + +
- + ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx index e08486fee3..36e38c1449 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx @@ -1,13 +1,15 @@ -import { ReactElement, useState, FormEvent, useRef, useEffect } from "react"; +import { ReactElement, useState, FormEvent, useRef } from "react"; +import { DialogShell } from "./DialogShell"; import { isSafeLinkUrl } from "../../../utils/helpers"; import { useT } from "../../../utils/i18n"; import { useCurrentEditor } from "../../EditorContext"; import { LinkDialogProps } from "../helpers/toolbarTypes"; -import { useDropdown } from "../hooks/useDropdown"; import "./Dialog.scss"; +const TITLE_ID = "rich-text-link-dialog-title"; + export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): ReactElement { - const { editor } = useCurrentEditor(); + const { editor, dialogStyle } = useCurrentEditor(); const t = useT(); // Get initial values from editor state @@ -23,19 +25,10 @@ export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): Reac (existingLink.target === "_blank" ? "_blank" : "_self") as "_self" | "_blank" ); + // Only used to return focus after a rejected URL. Focus on open comes from the input's + // `autoFocus`: the dialog is portalled, so a mount-time effect here would run before the input + // exists. const urlInputRef = useRef(null); - const dialogRef = useRef(null); - - const { refs, floatingStyles } = useDropdown({ - isOpen: true, - onClose, - referenceElement - }); - - useEffect(() => { - // Focus URL input when dialog opens - urlInputRef.current?.focus(); - }, []); const handleSubmit = (e: FormEvent): void => { e.preventDefault(); @@ -89,11 +82,11 @@ export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): Reac }; return ( -
-
-
-

{existingLink.href ? t("link.editTitle") : t("link.insertTitle")}

+ + +

{existingLink.href ? t("link.editTitle") : t("link.insertTitle")}

+
- -
- - -
- -
-
+ + +
+ + +
+ + ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx index 2987b19062..1ef24d8263 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx @@ -1,16 +1,18 @@ -import { ReactElement, useState, useRef, FormEvent, useEffect } from "react"; +import { ReactElement, useState, FormEvent, useEffect } from "react"; +import { DialogShell } from "./DialogShell"; import { parseEmbedCode } from "../../../utils/embedCodeParser"; import { useT } from "../../../utils/i18n"; import { matchPattern } from "../../../utils/videoUrlPattern"; import { useCurrentEditor } from "../../EditorContext"; import { VideoDialogProps } from "../helpers/toolbarTypes"; -import { useDropdown } from "../hooks/useDropdown"; import "./Dialog.scss"; type TabMode = "url" | "embed"; +const TITLE_ID = "rich-text-video-dialog-title"; + export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): ReactElement { - const { editor } = useCurrentEditor(); + const { editor, dialogStyle } = useCurrentEditor(); const t = useT(); const [activeTab, setActiveTab] = useState("url"); const [urlInput, setUrlInput] = useState(""); @@ -19,13 +21,6 @@ export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): Re const [height, setHeight] = useState("480"); const [detectedPlatform, setDetectedPlatform] = useState(null); const [validationError, setValidationError] = useState(null); - const dialogRef = useRef(null); - - const { refs, floatingStyles } = useDropdown({ - isOpen: true, - onClose, - referenceElement - }); // Handle URL input change const handleUrlChange = (value: string): void => { @@ -176,11 +171,17 @@ export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): Re (activeTab === "embed" && (!embedCodeInput.trim() || !!validationError)); return ( -
-
-
-

{t("video.title")}

- + + +

{t("video.title")}

+ +
{/* Tab Navigation */}
)} - - {/* Actions */} -
- - -
- -
-
+ + + {/* Actions */} +
+ + +
+ + ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx new file mode 100644 index 0000000000..95b548b8d5 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx @@ -0,0 +1,125 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ReactElement, ReactNode } from "react"; +import { DialogStyleEnum } from "../../../../../typings/RichTextProps"; +import { EditorContext } from "../../../EditorContext"; +import { ColorPicker } from "../ColorPicker"; +import { ConfirmDialog } from "../ConfirmDialog"; +import { HelpDialog } from "../HelpDialog"; +import { ImageDialog } from "../ImageDialog"; +import { LinkDialog } from "../LinkDialog"; +import { TableGridSelector } from "../TableGridSelector"; +import { VideoDialog } from "../VideoDialog"; + +const OVERLAY_SELECTOR = ".widget-rich-text-dialog-overlay"; + +function chainSpy(): { chain: any; calls: string[] } { + const calls: string[] = []; + const chain: any = new Proxy( + {}, + { + get: (_target, prop: string) => () => { + calls.push(prop); + return prop === "run" ? true : chain; + } + } + ); + return { chain, calls }; +} + +function withEditor(children: ReactNode, dialogStyle: DialogStyleEnum, editor: any = null): ReactElement { + return ( + undefined, + dialogStyle, + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + {children as ReactElement} + + ); +} + +const noop = (): void => undefined; + +const INSERT_DIALOGS: Array<[string, ReactElement]> = [ + ["ImageDialog", ], + ["VideoDialog", ], + ["LinkDialog", ] +]; + +describe.each(INSERT_DIALOGS)("%s presentation", (_name, dialog) => { + it("renders no overlay when the dialog style is inline", () => { + render(withEditor(dialog, "inline")); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.querySelector(".toolbar-dialog")).not.toBeNull(); + }); + + it("renders a modal dialog over an overlay when the dialog style is focused", () => { + render(withEditor(dialog, "focused")); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); +}); + +describe("dialogs that are always focused", () => { + it("HelpDialog renders an overlay even with the inline dialog style in context", () => { + render(withEditor(, "inline")); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(screen.getByRole("dialog", { name: "Keyboard shortcuts" })).toBeInTheDocument(); + }); + + it("ConfirmDialog renders an overlay even with the inline dialog style in context", () => { + render( + withEditor(, "inline") + ); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); +}); + +// The toolbar popovers keep their pre-existing anchored behaviour: the new property is about +// dialogs only. +describe("popovers are unaffected by the dialog style", () => { + it("ColorPicker renders anchored with no overlay while the style is focused", () => { + render(withEditor(, "focused")); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.querySelector(".color-picker-dropdown")).not.toBeNull(); + }); + + it("TableGridSelector renders anchored with no overlay while the style is focused", () => { + const { chain } = chainSpy(); + const editor = { chain: () => chain } as any; + + render(withEditor(, "focused")); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.querySelector(".table-grid-selector")).not.toBeNull(); + }); +}); + +// `chain().focus()` is what restores the selection the editor held when the dialog opened, so it +// has to run before the insert command in both presentations. +describe.each(["inline", "focused"] as const)("selection preservation (%s)", dialogStyle => { + it("focuses the editor before inserting an image", () => { + const { chain, calls } = chainSpy(); + const editor = { chain: () => chain } as any; + + render(withEditor(, dialogStyle, editor)); + + fireEvent.change(screen.getByLabelText("Image URL"), { + target: { value: "https://example.com/image.jpg" } + }); + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + + expect(calls).toEqual(["focus", "setImage", "run"]); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx new file mode 100644 index 0000000000..d18f8e0fd7 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx @@ -0,0 +1,115 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { DialogShell } from "../DialogShell"; + +const OVERLAY_SELECTOR = ".widget-rich-text-dialog-overlay"; + +function renderShell(mode: "inline" | "focused", onClose = jest.fn()): { onClose: jest.Mock } { + const reference = document.createElement("button"); + document.body.appendChild(reference); + + render( + +

Shell title

+
+ +
+
+ ); + + return { onClose }; +} + +describe("DialogShell inline mode", () => { + it("renders no overlay and does not lock body scroll", () => { + renderShell("inline"); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.body.style.overflow).not.toBe("hidden"); + }); + + it("closes on outside mousedown", () => { + const { onClose } = renderShell("inline"); + + fireEvent.mouseDown(document.body); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close on mousedown inside the dialog", () => { + const { onClose } = renderShell("inline"); + + fireEvent.mouseDown(screen.getByRole("button", { name: "Close" })); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe("DialogShell focused mode", () => { + it("renders the overlay, locks body scroll and exposes modal dialog semantics", () => { + renderShell("focused"); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(document.body).toHaveStyle({ overflow: "hidden" }); + + const dialog = screen.getByRole("dialog", { name: "Shell title" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("moves focus into the dialog", async () => { + renderShell("focused"); + + const dialog = screen.getByRole("dialog", { name: "Shell title" }); + await waitFor(() => expect(dialog).toHaveFocus()); + }); + + it("closes on Escape", () => { + const { onClose } = renderShell("focused"); + + fireEvent.keyDown(screen.getByRole("dialog", { name: "Shell title" }), { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes on overlay mousedown", () => { + const { onClose } = renderShell("focused"); + + fireEvent.mouseDown(document.querySelector(OVERLAY_SELECTOR) as HTMLElement); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // Regression guard: without capture-phase handling plus stopPropagation, one Escape both closes + // the dialog and exits the editor's fullscreen mode. + it("stops Escape before it reaches other listeners", () => { + const spy = jest.fn(); + document.body.addEventListener("keydown", spy, true); + + try { + const { onClose } = renderShell("focused"); + fireEvent.keyDown(screen.getByRole("dialog", { name: "Shell title" }), { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalled(); + } finally { + document.body.removeEventListener("keydown", spy, true); + } + }); +}); + +describe("DialogShell portalling", () => { + // The widget node clips overflow, and a transformed ancestor makes it the containing block for + // `position: fixed`, so a dialog rendered in place can be clipped in both modes. + it.each(["inline", "focused"] as const)("renders %s outside the widget's own subtree", mode => { + const onClose = jest.fn(); + const { container } = render( + +

Shell title

+
+ ); + + const dialog = document.querySelector(".toolbar-dialog"); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx index 798e26e0f0..7771dec2de 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { ReactElement } from "react"; +import { DialogStyleEnum } from "../../../../../typings/RichTextProps"; import { EditorContext, ImageDialogConfig } from "../../../EditorContext"; import { ImageDialog } from "../ImageDialog"; @@ -11,6 +12,7 @@ function renderWithConfig(imageConfig: ImageDialogConfig): ReturnType undefined, + dialogStyle: "inline", imageConfig }} > @@ -70,6 +72,7 @@ describe("ImageDialog dimensions", () => { editor, codeViewState: { isCodeView: false, htmlCode: "", showConfirm: false }, codeViewDispatch: () => undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: false, hasImageSource: false } }} > @@ -143,3 +146,252 @@ describe("ImageDialog dimensions", () => { expect(screen.getByLabelText("Height (px)")).toHaveValue(200); }); }); + +describe("ImageDialog insertion isolation", () => { + // A widget placed in the image-source content slot may render a + } + }} + > + {() as ReactElement} + + ); + + fireEvent.click(screen.getByRole("button", { name: "Media Library" })); + + return { setImage, onClose }; + } + + // The dialog is portalled to the body, so it is not inside `render`'s container. + const selectEntityImage = (): void => { + const dialog = document.querySelector(".image-dialog") as HTMLElement; + act(() => { + dialog.dispatchEvent( + new CustomEvent("imageSelected", { + detail: { id: "id-1234-5678", url: "https://example.com/entity.jpg" } + }) + ); + }); + }; + + it("renders no form element", () => { + renderWithEmbeddedContent(); + + // Portalled, so this looks at the whole document rather than the render container. + expect(document.querySelector(".image-dialog form")).toBeNull(); + }); + + it("does not insert or close when an untyped embedded button is clicked", () => { + const { setImage, onClose } = renderWithEmbeddedContent(); + + fireEvent.click(screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL })); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not insert or close when an untyped embedded button is clicked after an image is selected", () => { + const { setImage, onClose } = renderWithEmbeddedContent(); + const embedded = screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL }); + + fireEvent.click(embedded); + selectEntityImage(); + fireEvent.click(embedded); + fireEvent.click(embedded); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not insert when Enter is pressed inside embedded content", () => { + const { setImage, onClose } = renderWithEmbeddedContent(); + selectEntityImage(); + + fireEvent.keyDown(screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL }), { key: "Enter" }); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("inserts the selected entity image when the Insert button is activated", () => { + const { setImage, onClose } = renderWithEmbeddedContent(); + selectEntityImage(); + + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + + expect(setImage).toHaveBeenCalledTimes(1); + const attrs = setImage.mock.calls[0][0]; + expect(attrs.src).toBe("https://example.com/entity.jpg"); + expect(attrs.dataEntity).toBe(true); + expect(attrs.dataEntityId).toBe("id-1234-5678"); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe("ImageDialog Enter to insert", () => { + function renderWithEditor(): { setImage: jest.Mock; onClose: jest.Mock } { + const setImage = jest.fn(); + const onClose = jest.fn(); + const chain = { + focus: () => chain, + setImage: (attrs: Record) => { + setImage(attrs); + return chain; + }, + run: () => true + }; + const editor = { chain: () => chain } as any; + + render( + undefined, + dialogStyle: "inline", + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + {() as ReactElement} + + ); + + return { setImage, onClose }; + } + + const fillUrl = (): void => { + fireEvent.change(screen.getByLabelText("Image URL"), { + target: { value: "https://example.com/image.jpg" } + }); + }; + + it.each(["Image URL", "Alt text (optional)", "Title (optional)", "Width (px)"])( + "inserts on Enter in the %s input", + label => { + const { setImage, onClose } = renderWithEditor(); + fillUrl(); + + fireEvent.keyDown(screen.getByLabelText(label), { key: "Enter" }); + + expect(setImage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + } + ); + + it("inserts on Enter in the Height input", () => { + const { setImage, onClose } = renderWithEditor(); + fillUrl(); + fireEvent.click(screen.getByLabelText("Maintain aspect ratio")); // enables Height + + fireEvent.keyDown(screen.getByLabelText("Height (px)"), { key: "Enter" }); + + expect(setImage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not insert on Enter while the image source is empty", () => { + const { setImage, onClose } = renderWithEditor(); + + fireEvent.keyDown(screen.getByLabelText("Image URL"), { key: "Enter" }); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("ignores keys other than Enter", () => { + const { setImage } = renderWithEditor(); + fillUrl(); + + fireEvent.keyDown(screen.getByLabelText("Image URL"), { key: "a" }); + + expect(setImage).not.toHaveBeenCalled(); + }); +}); + +// A Media Library listing many images used to grow the dialog past the viewport and push Insert / +// Cancel out of reach. The tall content now lives in a bounded scroll region instead. +describe("ImageDialog scroll region", () => { + function renderWithTallImageSource(dialogStyle: DialogStyleEnum): void { + render( + undefined, + dialogStyle, + imageConfig: { + enableDefaultUpload: false, + hasImageSource: true, + imageSourceContent: ( +
    + {Array.from({ length: 60 }, (_, index) => ( +
  • Image {index}
  • + ))} +
+ ) + } + }} + > + {( undefined} referenceElement={null} />) as ReactElement} +
+ ); + + fireEvent.click(screen.getByRole("button", { name: "Media Library" })); + } + + it("keeps the tall image source inside the scroll region and the actions outside it", () => { + renderWithTallImageSource("inline"); + + const scroll = document.querySelector(".dialog-scroll") as HTMLElement; + const actions = document.querySelector(".dialog-actions") as HTMLElement; + + expect(scroll).not.toBeNull(); + expect(scroll.querySelector(".image-dialog-entity")).not.toBeNull(); + expect(scroll.contains(actions)).toBe(false); + expect(document.querySelector(".dialog-scroll h3")).toBeNull(); + }); + + it("bounds the dialog height in focused mode", () => { + renderWithTallImageSource("focused"); + + expect(screen.getByRole("dialog")).toHaveStyle({ maxHeight: "70vh" }); + }); + + it("bounds the dialog height in inline mode", () => { + renderWithTallImageSource("inline"); + + // With no anchor to measure against, the shell falls back to its default bound rather than + // leaving the dialog unbounded. + expect(document.querySelector(".toolbar-dialog")).toHaveStyle({ maxHeight: "70vh" }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx index 4b55742127..01ae7d37d7 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx @@ -13,6 +13,7 @@ function renderWithEditor(children: ReactNode, isCodeView = false): ReturnType undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: true, hasImageSource: false } }} > @@ -32,6 +33,7 @@ function renderInCodeViewEditable(children: ReactNode): ReturnType undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: true, hasImageSource: false } }} > diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts new file mode 100644 index 0000000000..d919ac7550 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts @@ -0,0 +1,11 @@ +import { buildAdvancedToolbar } from "../../ToolbarConfig"; + +describe("buildAdvancedToolbar", () => { + it("maps the header item to the text format dropdown", () => { + const groups = buildAdvancedToolbar([{ ctItemType: "header" }]); + + expect(groups).toHaveLength(1); + expect(groups[0].buttons).toHaveLength(1); + expect(groups[0].buttons[0].name).toBe("textFormat"); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts index b4fb802d2f..1701aa3c77 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts @@ -104,11 +104,6 @@ export type ImageSourceMode = "url" | "upload" | "entity"; // Constants // ============================================================================ -/** - * Maximum file size for image uploads (5MB) - */ -export const MAX_FILE_SIZE = 5 * 1024 * 1024; - /** * Maximum table dimensions for TableGridSelector */ diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts index c6b17c3b6b..6202d5ba95 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts @@ -1,5 +1,12 @@ -import { useFloating, offset, flip, shift, autoUpdate, Placement } from "@floating-ui/react"; -import { useEffect, useRef, RefObject } from "react"; +import { useFloating, offset, flip, shift, size, autoUpdate, Placement } from "@floating-ui/react"; +import { useEffect, useRef, useState, RefObject } from "react"; + +/** + * Smallest height an auto-sized floating element is allowed to shrink to. Without a floor, a + * trigger near the viewport edge yields an `availableHeight` of a few pixels and the element + * collapses into an unusable sliver. + */ +export const MIN_AVAILABLE_HEIGHT = 200; export interface UseDropdownOptions { isOpen: boolean; @@ -7,6 +14,12 @@ export interface UseDropdownOptions { placement?: Placement; offsetValue?: number; referenceElement?: HTMLElement | null; + /** + * Measure the space left at the resolved placement and report it as `availableHeight`, so the + * caller can cap its own scroll region. Off by default: the toolbar popovers already bound + * themselves in CSS and must keep their current behaviour. + */ + trackAvailableHeight?: boolean; } export interface UseDropdownReturn { @@ -21,6 +34,11 @@ export interface UseDropdownReturn { top: number; left: number; }; + /** + * Height available at the resolved placement, floored at `MIN_AVAILABLE_HEIGHT`. Only produced + * when `trackAvailableHeight` is set; `undefined` otherwise. + */ + availableHeight?: number; } /** @@ -32,14 +50,32 @@ export function useDropdown({ onClose, placement = "bottom-start", offsetValue = 4, - referenceElement + referenceElement, + trackAvailableHeight = false }: UseDropdownOptions): UseDropdownReturn { const ignoreClickRef = useRef(null); + const [availableHeight, setAvailableHeight] = useState(undefined); const { x, y, strategy, refs } = useFloating({ placement, strategy: "fixed", - middleware: [offset(offsetValue), flip(), shift({ padding: 8 })], + // `size` runs last on purpose: it must measure the placement `flip` and `shift` settled on, + // not the requested one. + middleware: [ + offset(offsetValue), + flip(), + shift({ padding: 8 }), + ...(trackAvailableHeight + ? [ + size({ + padding: 8, + apply({ availableHeight: available }) { + setAvailableHeight(Math.max(Math.floor(available), MIN_AVAILABLE_HEIGHT)); + } + }) + ] + : []) + ], whileElementsMounted: autoUpdate, open: isOpen }); @@ -91,6 +127,7 @@ export function useDropdown({ position: strategy, top: y ?? 0, left: x ?? 0 - } + }, + availableHeight }; } diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts new file mode 100644 index 0000000000..d8c87583a9 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts @@ -0,0 +1,42 @@ +import { mergeAttributes } from "@tiptap/core"; +// Imported from the umbrella package, which is a direct dependency; +// `@tiptap/extension-bullet-list` is only transitively available. +import { BulletList } from "@tiptap/extension-list"; +import { computeMaxMarkerSize, maxMarkerSizeToAttrs } from "../utils/markerFormat"; + +export interface BulletListStyledOptions { + styleDataFormat: "inline" | "class"; +} + +/** + * Bullet list that publishes the largest marker size among its direct items. + * + * An enlarged marker grows leftward out of the list's `padding-left`, so the stylesheet + * widens the gutter from this value. Emitted only when an item actually has an enlarged + * marker, so unformatted lists render unchanged. + * + * Kept fresh in the live view by the decoration plugin in `ListItemMarkerFormat`, for the + * same `toDOM` staleness reason documented there. + */ +export const BulletListStyled = BulletList.extend({ + name: "bulletList", + + addOptions() { + return { + ...this.parent?.(), + styleDataFormat: "inline" + }; + }, + + renderHTML(props) { + const { node, HTMLAttributes } = props; + const merged = mergeAttributes( + HTMLAttributes, + maxMarkerSizeToAttrs(computeMaxMarkerSize(node), this.options.styleDataFormat) + ); + + // `BulletList` always defines `renderHTML`; the fallback only guards against an + // upstream change removing it. + return this.parent?.({ ...props, HTMLAttributes: merged }) ?? ["ul", merged, 0]; + } +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts new file mode 100644 index 0000000000..ef28b3f8a3 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts @@ -0,0 +1,230 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import { ImageFileError, pickImageFiles, readFileAsDataUrl, validateImageFile } from "../utils/imageFiles"; + +/** + * Inserts image files dropped onto or pasted into the editor as base64 images, + * matching the image dialog's Upload tab (same validation, same `data:` URI). + * + * Handlers are registered through `handleDOMEvents` rather than `handleDrop`/ + * `handlePaste` on purpose. ProseMirror dispatches the latter through + * `handlers[event.type]`, which is gated on `view.editable`, and its + * `dragover`/`dragenter` preventDefault sits behind the same gate — so on a + * read-only editor the drop would reach the browser, which navigates the whole + * page to the dropped file and discards unsaved form data. `handleDOMEvents` + * runs ahead of that gate, so this extension can neutralise the event in every + * state, including when uploading is disabled. + * + * The decision and insertion logic are exported as plain functions so they can + * be tested without jsdom's stub `DataTransfer`; the plugin below is only + * registration and DOM plumbing. + */ + +/** Fired on the editor DOM node when a dropped or pasted file is rejected. */ +export const IMAGE_DROP_ERROR_EVENT = "richtextImageDropError"; + +export interface ImagePasteDropOptions { + /** Whether the widget's "Enable default upload" property is on. */ + isEnabled: () => boolean; + /** Whether the editor currently accepts edits. */ + isEditable: () => boolean; + /** Ancestor of the editor DOM that carries the drag-over class. */ + wrapperSelector: string; + dragOverClass: string; +} + +export type ImageEventDecision = + /** No image file present: leave the event to ProseMirror. */ + | "ignore" + /** Image file present but insertion not allowed: neutralise, insert nothing. */ + | "swallow" + | "insert"; + +export interface ImageInsertContext { + insertImage: (src: string, pos: number) => void; + docSize: () => number; + reportError: (error: ImageFileError) => void; +} + +export function decideImageEvent( + files: File[], + gate: Pick +): ImageEventDecision { + if (files.length === 0) { + return "ignore"; + } + return gate.isEnabled() && gate.isEditable() ? "insert" : "swallow"; +} + +/** + * Document position under the drop, falling back to the selection. `posAtCoords` + * needs layout APIs (`elementFromPoint`); the event has already been prevented by + * the time it is called, so a throw there would silently lose the image. + */ +export function dropPosition(view: EditorView, coords: { clientX: number; clientY: number }): number { + try { + return view.posAtCoords({ left: coords.clientX, top: coords.clientY })?.pos ?? view.state.selection.from; + } catch { + return view.state.selection.from; + } +} + +/** True when a drag advertises files. `dataTransfer.files` is empty during a drag. */ +export function dragCarriesFiles(dataTransfer: DataTransfer | null): boolean { + return Array.from(dataTransfer?.types ?? []).includes("Files"); +} + +/** + * Reads and inserts each file in order. Invalid or unreadable files are + * reported and skipped; the remaining files are still inserted. + * + * Reading is asynchronous while the DOM handler must answer synchronously, so + * the target position is captured by the caller before the read starts. The + * document can change in between, so every insert clamps to the current + * document size. + */ +export async function insertImageFiles(files: File[], pos: number, ctx: ImageInsertContext): Promise { + let at = pos; + + for (const file of files) { + const error = validateImageFile(file); + if (error) { + ctx.reportError(error); + continue; + } + + let src: string; + try { + src = await readFileAsDataUrl(file); + } catch { + ctx.reportError({ key: "image.errorReadFailed" }); + continue; + } + + const target = Math.min(at, ctx.docSize()); + ctx.insertImage(src, target); + // An image node has size 1, so the next file lands after this one and + // multiple dropped files keep their drop order. + at = target + 1; + } +} + +export const ImagePasteDrop = Extension.create({ + name: "imagePasteDrop", + + addOptions() { + return { + isEnabled: () => true, + isEditable: () => true, + wrapperSelector: ".tiptap-wrapper", + dragOverClass: "rich-text-drag-over" + }; + }, + + addProseMirrorPlugins() { + const options = this.options; + const editor = this.editor; + // dragenter/dragleave fire for every descendant the pointer crosses, so + // a plain toggle flickers over text. Count enters instead. + let dragDepth = 0; + + const wrapperOf = (dom: HTMLElement): HTMLElement => + (dom.closest(options.wrapperSelector) as HTMLElement | null) ?? dom; + + const setDragOver = (dom: HTMLElement, active: boolean): void => { + wrapperOf(dom).classList.toggle(options.dragOverClass, active); + }; + + const context = (): ImageInsertContext => ({ + insertImage: (src, pos) => { + editor.commands.insertContentAt(pos, { type: "image", attrs: { src } }); + }, + docSize: () => editor.state.doc.content.size, + reportError: error => { + editor.view.dom.dispatchEvent(new CustomEvent(IMAGE_DROP_ERROR_EVENT, { detail: error })); + } + }); + + return [ + new Plugin({ + key: new PluginKey("imagePasteDrop"), + props: { + handleDOMEvents: { + dragenter: (view, event) => { + if (!dragCarriesFiles(event.dataTransfer)) { + return false; + } + // Prevented even when insertion is not allowed: without it the + // browser owns the drop on a read-only view. + event.preventDefault(); + dragDepth += 1; + if (options.isEnabled() && options.isEditable()) { + setDragOver(view.dom as HTMLElement, true); + } + return true; + }, + dragover: (_view, event) => { + if (!dragCarriesFiles(event.dataTransfer)) { + return false; + } + event.preventDefault(); + return true; + }, + dragleave: (view, event) => { + if (!dragCarriesFiles(event.dataTransfer)) { + return false; + } + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + setDragOver(view.dom as HTMLElement, false); + } + return false; + }, + drop: (view, event) => { + const files = pickImageFiles(event.dataTransfer?.files); + const decision = decideImageEvent(files, options); + if (decision === "ignore") { + return false; + } + + event.preventDefault(); + dragDepth = 0; + setDragOver(view.dom as HTMLElement, false); + + if (decision === "swallow") { + return true; + } + + insertImageFiles(files, dropPosition(view, event), context()); + return true; + }, + paste: (view, event) => { + // Rich content pasted from Word or Google Docs can carry an image + // file alongside its HTML. That HTML is the paste the user means, + // and it still has to reach `WordPaste`, so only a file-only + // clipboard (a screenshot, a copied image file) is intercepted. + if (Array.from(event.clipboardData?.types ?? []).includes("text/html")) { + return false; + } + + const files = pickImageFiles(event.clipboardData?.files); + const decision = decideImageEvent(files, options); + if (decision === "ignore") { + return false; + } + + event.preventDefault(); + if (decision === "swallow") { + return true; + } + + insertImageFiles(files, view.state.selection.from, context()); + return true; + } + } + } + }) + ]; + } +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts index fa858cb139..a02e1a90bd 100644 --- a/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts @@ -1,6 +1,7 @@ import { Image } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { ImageResize as ImageResizeComponent } from "../components/ImageResize"; +import { toHtmlDimension } from "../utils/imageSize"; export type ImageResizeOptions = { inline?: boolean; @@ -33,11 +34,12 @@ export const ImageResize = Image.extend({ const style = element.style.width; return style || null; }, + // Dimensions stay in width/height attributes rather than inline style: + // a style attribute needs `style-src 'unsafe-inline'`, which the + // `styleDataFormat: "class"` mode exists to avoid. renderHTML: attributes => { - if (!attributes.width) { - return {}; - } - return { width: attributes.width }; + const width = toHtmlDimension(attributes.width); + return width === undefined ? {} : { width }; } }, height: { @@ -51,10 +53,8 @@ export const ImageResize = Image.extend({ return style || null; }, renderHTML: attributes => { - if (!attributes.height) { - return {}; - } - return { height: attributes.height }; + const height = toHtmlDimension(attributes.height); + return height === undefined ? {} : { height }; } }, dataEntity: { diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts new file mode 100644 index 0000000000..e9e827243a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts @@ -0,0 +1,123 @@ +import { mergeAttributes } from "@tiptap/core"; +import { ListItem } from "@tiptap/extension-list-item"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + computeMarkerFormat, + computeMarkerLength, + computeMaxMarkerSize, + markerFormatToAttrs, + maxMarkerSizeToAttrs +} from "../utils/markerFormat"; + +export interface ListItemMarkerFormatOptions { + styleDataFormat: "inline" | "class"; +} + +const markerFormatPluginKey = new PluginKey("listItemMarkerFormat"); + +/** Lists whose markers participate; `taskList` renders checkboxes and has no `::marker`. */ +const GUTTER_LIST_TYPES = new Set(["orderedList", "bulletList"]); + +/** + * Makes a list item's bullet or number follow the format of its first inline run. + * + * Marker format is *derived*, never stored. Nothing is written to the document, so opening + * existing content does not modify it — which matters because `Editor.tsx` reconciles by + * comparing `editor.getHTML()` against the bound value, and `onUpdate` pushes every + * transaction back out. A document mutation here would dirty every existing list document + * on load and fire change actions. + * + * Deriving it requires two delivery paths, because neither alone is sufficient: + * + * renderHTML — feeds getHTML(), copy/paste and the initial view render, but ProseMirror + * only re-invokes toDOM when a node's *markup* changes. `sameMarkup` + * compares type/attrs/marks, not content, so restyling the first run reuses + * the existing
  • element and the attribute would go stale. + * decoration — keeps the live
  • fresh, but decorations live in the view and never + * reach getHTML(). + * + * Both call `computeMarkerFormat`, so they cannot disagree. + * + * No node attribute is declared, so `parseHTML` ignores incoming `--rt-marker-*` and + * `data-marker-*`: pasted or reloaded content drops stale marker data and recomputes it. + */ +export const ListItemMarkerFormat = ListItem.extend({ + name: "listItem", + + addOptions() { + return { + ...this.parent?.(), + styleDataFormat: "inline" + }; + }, + + renderHTML(props) { + const { node, HTMLAttributes } = props; + // Merge into HTMLAttributes and delegate, rather than rebuilding the spec, so any + // upstream ListItem rendering logic keeps working. `mergeAttributes` concatenates + // `style` and `class` rather than overwriting them. + const merged = mergeAttributes( + HTMLAttributes, + markerFormatToAttrs(computeMarkerFormat(node), this.options.styleDataFormat) + ); + + // `ListItem` always defines `renderHTML`; the fallback only guards against an + // upstream change removing it. + return this.parent?.({ ...props, HTMLAttributes: merged }) ?? ["li", merged, 0]; + }, + + addProseMirrorPlugins() { + const styleDataFormat = this.options.styleDataFormat; + + return [ + ...(this.parent?.() ?? []), + new Plugin({ + key: markerFormatPluginKey, + state: { + init: (_, state) => buildDecorations(state.doc, styleDataFormat), + apply(transaction, previous, _oldState, newState) { + // Selection-only transactions cannot change any marker. + if (!transaction.docChanged) { + return previous; + } + return buildDecorations(newState.doc, styleDataFormat); + } + }, + props: { + decorations: state => markerFormatPluginKey.getState(state) + } + }) + ]; + } +}); + +/** + * Node decorations carrying marker format for every list item and gutter size for every + * list, at any nesting depth. + * + * Merging with what `toDOM` already emitted is safe: prosemirror-view appends decoration + * `style` (`dom.style.cssText += cur.style`) and adds decoration classes via + * `classList.add`, so neither clobbers the node's own attributes. + */ +function buildDecorations(doc: ProseMirrorNode, styleDataFormat: "inline" | "class"): DecorationSet { + const decorations: Decoration[] = []; + + doc.descendants((node, pos) => { + // `taskItem`/`taskList` are distinct node types and never match, so task lists are + // excluded without an explicit guard. + const attrs = + node.type.name === "listItem" + ? markerFormatToAttrs(computeMarkerFormat(node), styleDataFormat) + : GUTTER_LIST_TYPES.has(node.type.name) + ? maxMarkerSizeToAttrs(computeMaxMarkerSize(node), styleDataFormat, computeMarkerLength(node)) + : null; + + if (attrs && Object.keys(attrs).length > 0) { + decorations.push(Decoration.node(pos, pos + node.nodeSize, attrs)); + } + }); + + return DecorationSet.create(doc, decorations); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts index 625b791769..c6d89c539e 100644 --- a/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts @@ -1,4 +1,6 @@ +import { mergeAttributes } from "@tiptap/core"; import { OrderedList } from "@tiptap/extension-ordered-list"; +import { computeMarkerLength, computeMaxMarkerSize, maxMarkerSizeToAttrs } from "../utils/markerFormat"; export interface OrderedListStyledOptions { styleDataFormat: "inline" | "class"; @@ -62,6 +64,22 @@ export const OrderedListStyled = OrderedList.extend({ }; }, + // Publishes the largest marker size among direct items, plus how many characters the + // longest counter takes, so the stylesheet can widen the marker gutter to fit. Delegates + // to the parent, which handles the `start` and `type` attributes specially. Kept fresh in + // the live view by the decoration plugin in ListItemMarkerFormat. + renderHTML(props) { + const { node, HTMLAttributes } = props; + const merged = mergeAttributes( + HTMLAttributes, + maxMarkerSizeToAttrs(computeMaxMarkerSize(node), this.options.styleDataFormat, computeMarkerLength(node)) + ); + + // `OrderedList` always defines `renderHTML`; the fallback only guards against an + // upstream change removing it. + return this.parent?.({ ...props, HTMLAttributes: merged }) ?? ["ol", merged, 0]; + }, + addCommands() { return { ...this.parent?.(), diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts new file mode 100644 index 0000000000..9cda6f2003 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts @@ -0,0 +1,314 @@ +import { Editor } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ImageFileError } from "../../utils/imageFiles"; +import { IMAGE_DROP_ERROR_EVENT, ImagePasteDrop, insertImageFiles } from "../ImagePasteDrop"; + +const DRAG_OVER_CLASS = "rich-text-drag-over"; + +type DOMHandlers = Record boolean>; + +interface Harness { + editor: Editor; + wrapper: HTMLElement; + handlers: DOMHandlers; + errors: ImageFileError[]; +} + +function imageFile(name: string, content: string, type = "image/png", size?: number): File { + const file = new File([content], name, { type }); + if (size !== undefined) { + Object.defineProperty(file, "size", { value: size }); + } + return file; +} + +function dropEvent(files: File[], coords: { clientX: number; clientY: number } = { clientX: 0, clientY: 0 }): any { + return { + ...coords, + preventDefault: jest.fn(), + dataTransfer: { files, types: files.length > 0 ? ["Files"] : [] } + }; +} + +function pasteEvent(files: File[], types = files.length > 0 ? ["Files"] : []): any { + return { + preventDefault: jest.fn(), + clipboardData: { files, types } + }; +} + +function dragEvent(types: string[] = ["Files"]): any { + return { preventDefault: jest.fn(), dataTransfer: { types } }; +} + +function makeHarness({ enabled = true, editable = true } = {}): Harness { + const wrapper = document.createElement("div"); + wrapper.className = "tiptap-wrapper"; + const element = document.createElement("div"); + wrapper.appendChild(element); + document.body.appendChild(wrapper); + + const editor = new Editor({ + element, + content: "

    Hello world

    ", + extensions: [ + StarterKit, + Image.configure({ inline: true, allowBase64: true }), + ImagePasteDrop.configure({ + isEnabled: () => enabled, + isEditable: () => editable, + wrapperSelector: ".tiptap-wrapper", + dragOverClass: DRAG_OVER_CLASS + }) + ] + }); + + // Tiptap core registers its own drop/paste DOM handlers, so match this plugin by key. + const plugin = editor.state.plugins.find(p => (p as unknown as { key: string }).key.startsWith("imagePasteDrop")); + const errors: ImageFileError[] = []; + editor.view.dom.addEventListener(IMAGE_DROP_ERROR_EVENT, event => { + errors.push((event as CustomEvent).detail); + }); + + return { editor, wrapper, handlers: plugin!.spec.props!.handleDOMEvents as DOMHandlers, errors }; +} + +/** Lets the FileReader callbacks and the sequential inserts run. */ +async function flush(): Promise { + for (let i = 0; i < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +function imagePositions(editor: Editor): Array<{ pos: number; src: string }> { + const found: Array<{ pos: number; src: string }> = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "image") { + found.push({ pos, src: node.attrs.src }); + } + return true; + }); + return found; +} + +describe("ImagePasteDrop drop handling", () => { + it("inserts a dropped image at the drop position", async () => { + const { editor, handlers } = makeHarness(); + jest.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 6, inside: 0 }); + const event = dropEvent([imageFile("a.png", "a")], { clientX: 40, clientY: 12 }); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + const images = imagePositions(editor); + expect(images).toHaveLength(1); + expect(images[0].pos).toBe(6); + expect(images[0].src.startsWith("data:image/png;base64,")).toBe(true); + }); + + it("inserts several dropped images in drop order", async () => { + const { editor, handlers } = makeHarness(); + jest.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 6, inside: 0 }); + const files = [imageFile("a.png", "a"), imageFile("b.png", "bb"), imageFile("c.png", "ccc")]; + + handlers.drop(editor.view, dropEvent(files)); + await flush(); + + const srcs = imagePositions(editor).map(image => image.src); + expect(srcs).toHaveLength(3); + expect(srcs).toEqual([...srcs].sort((a, b) => a.length - b.length)); + expect(new Set(srcs).size).toBe(3); + }); + + it("rejects a file above the size limit and reports its size", async () => { + const { editor, handlers, errors } = makeHarness(); + const event = dropEvent([imageFile("huge.png", "a", "image/png", 13_000_000)]); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + expect(errors).toEqual([{ key: "image.errorTooLarge", arg: "12.4 MB" }]); + }); + + it("inserts the valid files of a mixed drop and reports the rejected one", async () => { + const { editor, handlers, errors } = makeHarness(); + jest.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 6, inside: 0 }); + + handlers.drop(editor.view, dropEvent([imageFile("ok.png", "a"), imageFile("huge.png", "a", "image/png", 9e6)])); + await flush(); + + expect(imagePositions(editor)).toHaveLength(1); + expect(errors).toEqual([{ key: "image.errorTooLarge", arg: "8.6 MB" }]); + }); + + it("leaves a drop without image files to ProseMirror", () => { + const { editor, handlers } = makeHarness(); + const event = dropEvent([imageFile("doc.pdf", "a", "application/pdf")]); + + expect(handlers.drop(editor.view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("swallows the drop when default upload is disabled", async () => { + const { editor, handlers, errors } = makeHarness({ enabled: false }); + const event = dropEvent([imageFile("a.png", "a")]); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + expect(errors).toEqual([]); + }); + + it("swallows the drop when the editor is read-only", async () => { + const { editor, handlers, errors } = makeHarness({ editable: false }); + const event = dropEvent([imageFile("a.png", "a")]); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + expect(errors).toEqual([]); + }); +}); + +describe("ImagePasteDrop paste handling", () => { + it("inserts a pasted image at the selection", async () => { + const { editor, handlers } = makeHarness(); + editor.commands.setTextSelection(4); + const event = pasteEvent([imageFile("a.png", "a")]); + + expect(handlers.paste(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)[0].pos).toBe(4); + }); + + it("leaves a paste that also carries HTML to the existing paste handling", () => { + const { editor, handlers } = makeHarness(); + const event = pasteEvent([imageFile("a.png", "a")], ["text/html", "Files"]); + + expect(handlers.paste(editor.view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("swallows the paste when default upload is disabled", async () => { + const { editor, handlers } = makeHarness({ enabled: false }); + const event = pasteEvent([imageFile("a.png", "a")]); + + expect(handlers.paste(editor.view, event)).toBe(true); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + }); +}); + +describe("ImagePasteDrop drag affordance", () => { + it("prevents dragover of a file drag so the drop reaches the editor", () => { + const { editor, handlers } = makeHarness(); + const event = dragEvent(); + + expect(handlers.dragover(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("prevents dragover even when insertion is not allowed", () => { + const { editor, handlers } = makeHarness({ enabled: false }); + const event = dragEvent(); + + handlers.dragover(editor.view, event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("ignores drags that carry no file", () => { + const { editor, handlers } = makeHarness(); + const event = dragEvent(["text/html"]); + + expect(handlers.dragover(editor.view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("highlights the wrapper while a file drag is over the editor", () => { + const { editor, handlers, wrapper } = makeHarness(); + + handlers.dragenter(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(true); + + handlers.dragleave(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(false); + }); + + it("keeps the highlight while nested elements are entered", () => { + const { editor, handlers, wrapper } = makeHarness(); + + handlers.dragenter(editor.view, dragEvent()); + handlers.dragenter(editor.view, dragEvent()); + handlers.dragleave(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(true); + }); + + it("clears the highlight on drop even after several dragenters", () => { + const { editor, handlers, wrapper } = makeHarness(); + + handlers.dragenter(editor.view, dragEvent()); + handlers.dragenter(editor.view, dragEvent()); + handlers.drop(editor.view, dropEvent([imageFile("a.png", "a")])); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(false); + }); + + it("does not highlight when default upload is disabled", () => { + const { editor, handlers, wrapper } = makeHarness({ enabled: false }); + + handlers.dragenter(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(false); + }); +}); + +describe("insertImageFiles", () => { + it("clamps the insert position to the current document size", async () => { + const insertImage = jest.fn(); + + await insertImageFiles([imageFile("a.png", "a")], 500, { + insertImage, + docSize: () => 12, + reportError: jest.fn() + }); + + expect(insertImage).toHaveBeenCalledWith(expect.stringContaining("data:image/png;base64,"), 12); + }); + + it("reports a read failure and continues with the next file", async () => { + const readAsDataURL = jest.spyOn(FileReader.prototype, "readAsDataURL").mockImplementationOnce(function ( + this: FileReader + ) { + this.onerror?.(new ProgressEvent("error") as ProgressEvent); + }); + const insertImage = jest.fn(); + const reportError = jest.fn(); + + await insertImageFiles([imageFile("bad.png", "a"), imageFile("good.png", "b")], 3, { + insertImage, + docSize: () => 100, + reportError + }); + + expect(reportError).toHaveBeenCalledWith({ key: "image.errorReadFailed" }); + expect(insertImage).toHaveBeenCalledTimes(1); + expect(insertImage).toHaveBeenCalledWith(expect.any(String), 3); + + readAsDataURL.mockRestore(); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts new file mode 100644 index 0000000000..a3a42cc6b2 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts @@ -0,0 +1,85 @@ +import { Editor } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ImageResize } from "../ImageResize"; + +function editorWith(html: string): Editor { + return new Editor({ + element: document.createElement("div"), + content: html, + extensions: [StarterKit, ImageResize] + }); +} + +function imageAttrs(editor: Editor): Record { + let attrs: Record = {}; + editor.state.doc.descendants(node => { + if (node.type.name === "image") { + attrs = node.attrs; + } + return true; + }); + return attrs; +} + +describe("ImageResize parsing", () => { + it("keeps the unitless dimensions written by Rich Text 4", () => { + const editor = editorWith('

    '); + + expect(imageAttrs(editor)).toMatchObject({ width: "300", height: "200" }); + }); + + it("keeps the pixel strings written by Rich Text 5", () => { + const editor = editorWith('

    '); + + expect(imageAttrs(editor)).toMatchObject({ width: "300px", height: "200px" }); + }); + + it("reads dimensions from inline style when no attribute is present", () => { + const editor = editorWith('

    '); + + expect(imageAttrs(editor)).toMatchObject({ width: "300px", height: "200px" }); + }); +}); + +describe("ImageResize serialization", () => { + it("leaves Rich Text 4 content unchanged when nothing is edited", () => { + const editor = editorWith('

    '); + + expect(editor.getHTML()).toContain('width="300" height="200"'); + }); + + it("drops the px suffix so the attribute value is a valid HTML dimension", () => { + const editor = editorWith('

    '); + + expect(editor.getHTML()).toContain('width="300" height="200"'); + }); + + it("serializes inline-style dimensions as attributes, not as style", () => { + const editor = editorWith('

    '); + const html = editor.getHTML(); + + expect(html).toContain('width="300" height="200"'); + expect(html).not.toContain("style="); + }); + + it("keeps a percentage width", () => { + const editor = editorWith('

    '); + + expect(editor.getHTML()).toContain('width="50%"'); + }); + + it("omits a dimension a width attribute cannot express", () => { + const editor = editorWith('

    '); + + // `width="20em"` would be legacy-parsed as 20 pixels, silently shrinking the image. + expect(editor.getHTML()).not.toContain("width="); + }); + + it("writes no dimensions for an image without a size", () => { + const editor = editorWith('

    '); + const html = editor.getHTML(); + + expect(html).not.toContain("width="); + expect(html).not.toContain("height="); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts new file mode 100644 index 0000000000..b89f0cd235 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts @@ -0,0 +1,227 @@ +import { Editor } from "@tiptap/core"; +import { TaskItem } from "@tiptap/extension-task-item"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TextStyle } from "@tiptap/extension-text-style"; +import { StarterKit } from "@tiptap/starter-kit"; +import { BulletListStyled } from "../BulletListStyled"; +import { FontSize } from "../FontSize"; +import { ListItemMarkerFormat } from "../ListItemMarkerFormat"; +import { OrderedListStyled } from "../OrderedListStyled"; + +type StyleDataFormat = "inline" | "class"; + +/** Mirrors the list-related extension set that `Editor.tsx` builds. */ +function makeEditor(styleDataFormat: StyleDataFormat = "inline"): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [ + StarterKit.configure({ orderedList: false, bulletList: false, listItem: false }), + OrderedListStyled.configure({ styleDataFormat }), + BulletListStyled.configure({ styleDataFormat }), + ListItemMarkerFormat.configure({ styleDataFormat }), + TextStyle, + TaskList, + TaskItem.configure({ nested: true }), + FontSize.configure({ types: ["textStyle"], styleDataFormat }) + ] + }); +} + +/** + * `setContent` appends an empty trailing paragraph when the document ends in a list. That is + * unrelated to marker format, so it is dropped before comparing. + */ +function setAndGet(editor: Editor, html: string): string { + editor.commands.setContent(html); + return editor.getHTML().replace(/

    <\/p>$/, ""); +} + +/** The first `

  • ` in the live editor view, i.e. after decorations are applied. */ +function liveListItem(editor: Editor): HTMLElement { + const li = editor.view.dom.querySelector("li"); + if (!li) { + throw new Error("No
  • in the editor view"); + } + return li as HTMLElement; +} + +/** + * Applies a font size through the toolbar command path. + * + * Class-mode markup cannot be used as a test input: `textStyle`'s only parse rule requires a + * `style` attribute, so a `` is discarded on + * parse. Driving the command reaches the same document state either way. + */ +function setFontSizeOnAll(editor: Editor, size: string): void { + editor.commands.selectAll(); + editor.commands.setFontSize(size); +} + +describe("ListItemMarkerFormat", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + describe("inline mode", () => { + beforeEach(() => { + editor = makeEditor("inline"); + }); + + it("publishes the first run's format as custom properties on the li", () => { + const html = setAndGet(editor, `
    1. Hello

    `); + + expect(html).toContain(`
  • { + const html = setAndGet(editor, `
    • big

    `); + + expect(html).toContain(`
      { + const source = `
      1. one

      2. two

      `; + + expect(setAndGet(editor, source)).toBe(source); + }); + + it("emits nothing for a task list", () => { + const html = setAndGet( + editor, + `
      • ` + + `

        task

      ` + ); + + expect(html).toContain("font-size: 32px"); + expect(html).not.toContain("--rt-marker"); + }); + + it("keeps the ordered list's start and type attributes", () => { + const html = setAndGet( + editor, + `
      1. c

      ` + ); + + expect(html).toContain(`start="3"`); + expect(html).toContain(`type="a"`); + expect(html).toContain("--rt-marker-max-size: 32px"); + }); + + it("marks only the item whose first run is formatted", () => { + editor.commands.setContent(`
      1. one

      2. two

      `); + // Select just the first character of the first item and enlarge it. + // ol opens at 0, li at 1, p at 2, so the text "one" starts at 3. + editor.commands.setTextSelection({ from: 3, to: 4 }); + editor.commands.setFontSize("32px"); + const html = editor.getHTML(); + + expect(html).toContain(`
    • { + beforeEach(() => { + editor = makeEditor("class"); + }); + + it("publishes per-property classes and data attributes", () => { + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + const html = editor.getHTML(); + + expect(html).toContain(`data-marker-font-size="32"`); + expect(html).toContain("has-marker-font-size"); + expect(html).toContain(`data-marker-max-size="32"`); + expect(html).toContain("has-marker-gutter"); + }); + + it("leaves an unformatted list byte-identical", () => { + const source = `
      • one

      `; + + expect(setAndGet(editor, source)).toBe(source); + }); + }); + + // The two delivery paths — `renderHTML` for `getHTML()`, decorations for the live view — + // must not drift, so both are asserted against the same document. Class mode is used + // because its attributes survive jsdom, whose `style.cssText` drops custom properties. + describe("view and getHTML agreement", () => { + beforeEach(() => { + editor = makeEditor("class"); + }); + + it("marks the live li the same way getHTML does", () => { + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + const li = liveListItem(editor); + + expect(li.classList.contains("has-marker-font-size")).toBe(true); + expect(li.getAttribute("data-marker-font-size")).toBe("32"); + expect(editor.getHTML()).toContain(`data-marker-font-size="32"`); + }); + + it("refreshes the live li when only the first run's format changes", () => { + // `toDOM` is not re-invoked here: `sameMarkup` compares type, attrs and marks but + // not content, so without the decoration plugin the
    • would keep 32. + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + expect(liveListItem(editor).getAttribute("data-marker-font-size")).toBe("32"); + + setFontSizeOnAll(editor, "48px"); + + expect(liveListItem(editor).getAttribute("data-marker-font-size")).toBe("48"); + expect(editor.getHTML()).toContain(`data-marker-font-size="48"`); + }); + + it("clears the live li when the format is removed", () => { + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + + editor.commands.selectAll(); + editor.commands.unsetFontSize(); + + expect(liveListItem(editor).hasAttribute("data-marker-font-size")).toBe(false); + expect(editor.getHTML()).not.toContain("data-marker-font-size"); + }); + }); + + describe("stale and legacy content", () => { + beforeEach(() => { + editor = makeEditor("inline"); + }); + + it("discards marker data that disagrees with the content", () => { + // What a paste from a document whose first run was since unformatted looks like. + const html = setAndGet( + editor, + `
        ` + + `
      1. plain

      2. ` + + `
      ` + ); + + expect(html).not.toContain("99px"); + expect(html).not.toContain("--rt-marker"); + }); + + it("recomputes stale marker data from the content that is actually there", () => { + const html = setAndGet( + editor, + `
      1. ` + + `

        Hello

      ` + ); + + expect(html).toContain("--rt-marker-font-size: 32px"); + expect(html).not.toContain("99px"); + }); + + it("renders a marker for legacy content that carries no marker data", () => { + const html = setAndGet(editor, `
      • legacy

      `); + + expect(html).toContain("--rt-marker-font-size: 42px"); + // The stored inline font size is untouched; the marker data is purely additive. + expect(html).toContain("font-size: 42px"); + }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss index 1c8a369b12..060dcca460 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss @@ -6,6 +6,10 @@ $rte-border-color-default: #ced0d3; $rte-gray-ligher: #f8f8f8; $rte-brand-primary: #264ae5; +$rte-drag-over-bg: #e6eaff; +$rte-brand-warning: #eca51c; +$rte-warning-bg: #fbedd2; +$rte-warning-text: #8f620b; .widget-rich-text { // CSS Variables for consistent theming @@ -49,6 +53,26 @@ $rte-brand-primary: #264ae5; display: flex; flex-direction: column; width: 100%; + + // Set while an image file is dragged over the editor (see extensions/ImagePasteDrop). + &.rich-text-drag-over .tiptap-editor { + outline: 1.5px dashed var(--brand-primary, $rte-brand-primary); + outline-offset: -2px; + background-color: var(--color-primary-lighter, $rte-drag-over-bg); + } + } + + // Transient message for an image drop/paste that was rejected. + .rich-text-drop-error { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + font-size: 12px; + line-height: 17px; + color: var(--file-dropzone-color, $rte-warning-text); + background-color: var(--color-warning-lighter, $rte-warning-bg); + border-top: 1px solid var(--brand-warning, $rte-brand-warning); } .tiptap-editor { @@ -153,9 +177,36 @@ $rte-brand-primary: #264ae5; font-weight: 700; } + // Marker follows the format of the list item's first inline run. The values are + // published as custom properties on the
    • by ListItemMarkerFormat; a custom + // property does not affect the item's own content, only what ::marker reads. + // Absent property = `inherit`, i.e. unchanged from the base. + // Task items carry a checkbox instead of a marker, and custom properties inherit + // down the DOM, so a task list nested under a formatted item is excluded here. + li:not([data-type="taskItem"])::marker { + font-size: var(--rt-marker-font-size, inherit); + font-weight: var(--rt-marker-font-weight, inherit); + font-style: var(--rt-marker-font-style, inherit); + color: var(--rt-marker-color, inherit); + font-family: var(--rt-marker-font-family, inherit); + } + // Ordered list auto-cycling (6 levels = 2 full cycles) ol { - padding-left: 1.5em; + // An enlarged marker grows leftward out of this padding, so the gutter has + // to grow with it. `--rt-marker-max-size` is the largest marker size among + // the list's direct items; when absent, calc() is 0px and this is exactly + // the previous `1.5em`. + // + // `--rt-marker-chars` is how many characters the longest counter takes, so a + // hundred-item list reserves room for `100.` rather than for `1.`. The + // factors come from measuring digit advance widths in Chrome: ~0.55em per + // digit, plus the `.` and the gap to the text. Verified to clear the widest + // marker at every length from 1 to 4 characters at the 98px maximum size. + padding-left: max( + 1.5em, + calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5)) + ); margin: 0.5em 0; list-style-type: decimal; @@ -182,7 +233,10 @@ $rte-brand-primary: #264ae5; // Unordered list auto-cycling (6 levels = 2 full cycles) ul { - padding-left: 1.5em; + padding-left: max( + 1.5em, + calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5)) + ); margin: 0.5em 0; list-style-type: disc; diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss index b131a69db6..ed3788ff1a 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss @@ -23,7 +23,7 @@ // inline-mode margin step (2em per level). ol.indent-#{$i}, ul.indent-#{$i} { - padding-left: 1.5em; + padding-left: max(1.5em, calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5))); margin-left: #{$i * 2}em; } } @@ -53,6 +53,41 @@ font-size: attr(data-font-size px); } + // List marker format, class mode. These feed the same `--rt-marker-*` custom properties + // that inline mode writes directly, so `li::marker` in RichText.scss stays the single + // consumption point for both modes. Setting the font properties on `::marker` here + // instead would lose to that rule, which is both more specific and later in source + // order (`@use` hoists this file above it). + .has-marker-font-size { + --rt-marker-font-size: attr(data-marker-font-size px); + } + + .has-marker-color { + --rt-marker-color: attr(data-marker-color type()); + } + + .has-marker-font-family { + --rt-marker-font-family: attr(data-marker-font-family raw-string); + } + + .has-marker-bold { + --rt-marker-font-weight: bold; + } + + .has-marker-italic { + --rt-marker-font-style: italic; + } + + // Widens the marker gutter on the list itself; consumed by the `ol`/`ul` padding rules. + .has-marker-gutter { + --rt-marker-max-size: attr(data-marker-max-size px); + } + + // Only present when the longest counter takes more than one character. + .has-marker-chars { + --rt-marker-chars: attr(data-marker-chars type()); + } + .text-align-left { text-align: left; } diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss b/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss index 1b910d84a1..8708ec6f37 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss @@ -8,7 +8,7 @@ td, th { border-width: 1px; - border-color: var(--gray-ligher, rgba(61, 37, 20, 0.12)); + border-color: var(--gray-lighter, rgba(61, 37, 20, 0.12)); border-style: dotted; box-sizing: border-box; min-width: 1em; diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts new file mode 100644 index 0000000000..905cc89ab4 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts @@ -0,0 +1,80 @@ +import { MAX_FILE_SIZE, formatFileSize, pickImageFiles, readFileAsDataUrl, validateImageFile } from "../imageFiles"; + +function fileOf(name: string, type: string, size: number): File { + const file = new File(["x"], name, { type }); + // `File` derives its size from the content, so override it to size the file + // without allocating megabytes in the test. + Object.defineProperty(file, "size", { value: size }); + return file; +} + +describe("validateImageFile", () => { + it("rejects files above the maximum size, reporting the actual size", () => { + const error = validateImageFile(fileOf("huge.png", "image/png", 13_000_000)); + + expect(error).toEqual({ key: "image.errorTooLarge", arg: "12.4 MB" }); + }); + + it("rejects files that are not images", () => { + const error = validateImageFile(fileOf("doc.pdf", "application/pdf", 1024)); + + expect(error).toEqual({ key: "image.errorNotImage" }); + }); + + it("accepts an image within the size limit", () => { + expect(validateImageFile(fileOf("photo.jpg", "image/jpeg", MAX_FILE_SIZE - 1))).toBeNull(); + }); + + it("accepts an image exactly at the size limit", () => { + expect(validateImageFile(fileOf("photo.jpg", "image/jpeg", MAX_FILE_SIZE))).toBeNull(); + }); +}); + +describe("formatFileSize", () => { + it.each([ + [512, "512 B"], + [2048, "2.0 KB"], + [5 * 1024 * 1024, "5.0 MB"] + ])("formats %p as %p", (bytes, expected) => { + expect(formatFileSize(bytes)).toBe(expected); + }); +}); + +describe("pickImageFiles", () => { + it("keeps only image files, in their original order", () => { + const first = fileOf("a.png", "image/png", 10); + const text = fileOf("notes.txt", "text/plain", 10); + const second = fileOf("b.gif", "image/gif", 10); + + expect(pickImageFiles([first, text, second])).toEqual([first, second]); + }); + + it("returns an empty list when no file is an image", () => { + expect(pickImageFiles([fileOf("doc.pdf", "application/pdf", 10)])).toEqual([]); + }); + + it("returns an empty list for missing file lists", () => { + expect(pickImageFiles(null)).toEqual([]); + expect(pickImageFiles(undefined)).toEqual([]); + }); +}); + +describe("readFileAsDataUrl", () => { + it("resolves with a data URI", async () => { + const dataUrl = await readFileAsDataUrl(new File(["hello"], "a.png", { type: "image/png" })); + + expect(dataUrl.startsWith("data:image/png;base64,")).toBe(true); + }); + + it("rejects when the file cannot be read", async () => { + const readAsDataURL = jest.spyOn(FileReader.prototype, "readAsDataURL").mockImplementation(function ( + this: FileReader + ) { + this.onerror?.(new ProgressEvent("error") as ProgressEvent); + }); + + await expect(readFileAsDataUrl(new File([""], "a.png", { type: "image/png" }))).rejects.toThrow(); + + readAsDataURL.mockRestore(); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts new file mode 100644 index 0000000000..e98ac930a8 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts @@ -0,0 +1,40 @@ +import { toCssLength, toHtmlDimension } from "../imageSize"; + +describe("toCssLength", () => { + it.each([ + ["300", "300px"], + ["300.5", "300.5px"], + [300, "300px"], + ["300px", "300px"], + [" 300 ", "300px"], + ["50%", "50%"], + ["20em", "20em"], + ["auto", "auto"] + ])("converts %p to %p", (input, expected) => { + expect(toCssLength(input)).toBe(expected); + }); + + it.each([null, undefined, "", " "])("returns undefined for %p", input => { + expect(toCssLength(input)).toBeUndefined(); + }); +}); + +describe("toHtmlDimension", () => { + it.each([ + ["300", "300"], + ["300px", "300"], + ["300PX", "300"], + ["300.5px", "300.5"], + [300, "300"], + ["50%", "50%"] + ])("converts %p to %p", (input, expected) => { + expect(toHtmlDimension(input)).toBe(expected); + }); + + it.each([null, undefined, "", "20em", "auto", "inherit", "calc(100% - 10px)"])( + "returns undefined for %p", + input => { + expect(toHtmlDimension(input)).toBeUndefined(); + } + ); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts new file mode 100644 index 0000000000..2b7a8678d8 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts @@ -0,0 +1,315 @@ +import { Editor } from "@tiptap/core"; +import { TextStyle } from "@tiptap/extension-text-style"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { StarterKit } from "@tiptap/starter-kit"; +import { FontFamilyClass } from "../../extensions/FontFamilyClass"; +import { FontSize } from "../../extensions/FontSize"; +import { ImageResize } from "../../extensions/ImageResize"; +import { TextColorClass } from "../../extensions/TextColorClass"; +import { + computeMarkerFormat, + computeMarkerLength, + computeMaxMarkerSize, + markerFormatToClassAttrs, + markerFormatToInlineStyle, + maxMarkerSizeToAttrs +} from "../markerFormat"; + +function makeEditor(): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [ + StarterKit, + TextStyle, + ImageResize.configure({ inline: true }), + FontSize.configure({ types: ["textStyle"], styleDataFormat: "inline" }), + FontFamilyClass.configure({ types: ["textStyle"], styleDataFormat: "inline" }), + TextColorClass.configure({ types: ["textStyle"], styleDataFormat: "inline" }) + ] + }); +} + +/** First node of the given type in the document. */ +function firstNodeOfType(editor: Editor, typeName: string): ProseMirrorNode { + let found: ProseMirrorNode | null = null; + editor.state.doc.descendants(node => { + if (found) { + return false; + } + if (node.type.name === typeName) { + found = node; + return false; + } + return true; + }); + if (!found) { + throw new Error(`No ${typeName} node found`); + } + return found; +} + +function markerFormatOf(editor: Editor, html: string): ReturnType { + editor.commands.setContent(html); + return computeMarkerFormat(firstNodeOfType(editor, "listItem")); +} + +describe("computeMarkerFormat", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + beforeEach(() => { + editor = makeEditor(); + }); + + it("reads all five properties from the first inline run", () => { + const format = markerFormatOf( + editor, + `
      • Hi

      ` + ); + + expect(format).toEqual({ + fontSize: "32px", + color: "rgb(255, 0, 0)", + fontFamily: "Arial", + bold: true, + italic: true + }); + }); + + it("reads a font size applied to the whole item", () => { + const format = markerFormatOf(editor, `
      1. Hello

      `); + + expect(format).toEqual({ fontSize: "32px" }); + }); + + it("reads a font size applied to only the first character", () => { + const format = markerFormatOf(editor, `
      1. Hello

      `); + + expect(format).toEqual({ fontSize: "32px" }); + }); + + it("ignores formatting that starts after the first character", () => { + const format = markerFormatOf(editor, `
      1. Hello

      `); + + expect(format).toBeNull(); + }); + + it("returns null for an unformatted item", () => { + expect(markerFormatOf(editor, `
      1. plain

      `)).toBeNull(); + }); + + it("returns null for an empty item", () => { + expect(markerFormatOf(editor, `
      `)).toBeNull(); + }); + + it("returns null when the item starts with an inline image", () => { + const format = markerFormatOf( + editor, + `
      1. after

      ` + ); + + expect(format).toBeNull(); + }); + + it("returns null when the item starts with a hard break", () => { + const format = markerFormatOf( + editor, + `

      1. after

      ` + ); + + expect(format).toBeNull(); + }); + + it("reads bold alone without a textStyle mark", () => { + expect(markerFormatOf(editor, `
      • bold

      `)).toEqual({ bold: true }); + }); + + it("evaluates each item independently", () => { + editor.commands.setContent( + `
      1. big

      2. small

      ` + ); + const list = firstNodeOfType(editor, "orderedList"); + + expect(computeMarkerFormat(list.child(0))).toEqual({ fontSize: "32px" }); + expect(computeMarkerFormat(list.child(1))).toBeNull(); + }); + + it("reads a nested item's own first run, not its parent's", () => { + editor.commands.setContent( + `
      • outer

        • inner

      ` + ); + const outer = firstNodeOfType(editor, "listItem"); + + expect(computeMarkerFormat(outer)).toBeNull(); + expect(computeMarkerFormat(firstNodeOfType(editor, "bulletList").child(0).child(1).child(0))).toEqual({ + fontSize: "42px" + }); + }); + + it("drops an unsafe font size rather than emitting it", () => { + const format = markerFormatOf(editor, `
      1. ok

      `); + expect(format?.fontSize).toBe("32px"); + + // A value that is not a valid CSS size must not reach the marker. + editor.commands.setContent(`
      1. ok

      `); + const item = firstNodeOfType(editor, "listItem"); + const textStyleType = editor.schema.marks.textStyle; + const doctored = item.type.create( + item.attrs, + item.firstChild!.type.create(item.firstChild!.attrs, [ + editor.schema.text("ok", [textStyleType.create({ fontSize: "url(javascript:alert(1))" })]) + ]) + ); + + expect(computeMarkerFormat(doctored)).toBeNull(); + }); +}); + +describe("computeMaxMarkerSize", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + beforeEach(() => { + editor = makeEditor(); + }); + + it("returns the largest first-run size among direct items", () => { + editor.commands.setContent( + `
        ` + + `
      1. a

      2. ` + + `
      3. b

      4. ` + + `
      5. c

      6. ` + + `
      ` + ); + + expect(computeMaxMarkerSize(firstNodeOfType(editor, "orderedList"))).toBe("84px"); + }); + + it("returns null when no item has an enlarged marker", () => { + editor.commands.setContent(`
      1. a

      2. b

      `); + + expect(computeMaxMarkerSize(firstNodeOfType(editor, "orderedList"))).toBeNull(); + }); + + it("ignores a nested list's items", () => { + editor.commands.setContent( + `
      • outer

        • inner

      ` + ); + + expect(computeMaxMarkerSize(firstNodeOfType(editor, "bulletList"))).toBeNull(); + }); +}); + +describe("computeMarkerLength", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + beforeEach(() => { + editor = makeEditor(); + }); + + function lengthOf(html: string, typeName = "orderedList"): number { + editor.commands.setContent(html); + return computeMarkerLength(firstNodeOfType(editor, typeName)); + } + + function items(count: number): string { + return `
    • x

    • `.repeat(count); + } + + it("counts the digits of the last item's number", () => { + expect(lengthOf(`
        ${items(9)}
      `)).toBe(1); + expect(lengthOf(`
        ${items(10)}
      `)).toBe(2); + expect(lengthOf(`
        ${items(100)}
      `)).toBe(3); + }); + + it("accounts for a start offset, which shifts every number up", () => { + expect(lengthOf(`
        ${items(3)}
      `)).toBe(4); + }); + + it("returns 1 for a bullet list, whose marker is a single glyph", () => { + expect(lengthOf(`
        ${items(100)}
      `, "bulletList")).toBe(1); + }); + + it("counts alphabetic markers, which stay one character through z", () => { + expect(lengthOf(`
        ${items(26)}
      `)).toBe(1); + expect(lengthOf(`
        ${items(27)}
      `)).toBe(2); + }); + + it("counts roman markers by numeral length, not by digits", () => { + // "viii" is four characters where the decimal 8 is one. + expect(lengthOf(`
        ${items(8)}
      `)).toBe(4); + expect(lengthOf(`
        ${items(38)}
      `)).toBe(7); // xxxviii + }); + + it("ignores nested items, which belong to their own list", () => { + expect(lengthOf(`
      1. a

          ${items(20)}
      `)).toBe(1); + }); +}); + +describe("marker format serializers", () => { + it("emits custom properties for inline mode", () => { + const style = markerFormatToInlineStyle({ + fontSize: "32px", + color: "red", + fontFamily: "Arial", + bold: true, + italic: true + }); + + expect(style).toBe( + "--rt-marker-font-size: 32px; --rt-marker-color: red; --rt-marker-font-family: Arial; " + + "--rt-marker-font-weight: bold; --rt-marker-font-style: italic" + ); + }); + + it("emits only the properties that are set", () => { + expect(markerFormatToInlineStyle({ fontSize: "20px" })).toBe("--rt-marker-font-size: 20px"); + }); + + it("emits one class per property for class mode", () => { + const attrs = markerFormatToClassAttrs({ + fontSize: "32px", + color: "red", + fontFamily: "Arial", + bold: true, + italic: true + }); + + expect(attrs).toEqual({ + "data-marker-font-size": "32", + "data-marker-color": "red", + "data-marker-font-family": "Arial", + class: "has-marker-font-size has-marker-color has-marker-font-family has-marker-bold has-marker-italic" + }); + }); + + it("omits classes for properties that are not set", () => { + expect(markerFormatToClassAttrs({ italic: true })).toEqual({ class: "has-marker-italic" }); + }); +}); + +describe("maxMarkerSizeToAttrs", () => { + it("emits nothing when no item has an enlarged marker", () => { + expect(maxMarkerSizeToAttrs(null, "inline", 3)).toEqual({}); + expect(maxMarkerSizeToAttrs(null, "class", 3)).toEqual({}); + }); + + it("omits the character count at 1, keeping the markup as it was before this feature", () => { + expect(maxMarkerSizeToAttrs("84px", "inline")).toEqual({ style: "--rt-marker-max-size: 84px" }); + expect(maxMarkerSizeToAttrs("84px", "class")).toEqual({ + "data-marker-max-size": "84", + class: "has-marker-gutter" + }); + }); + + it("emits the character count above 1, so the gutter fits the longest marker", () => { + expect(maxMarkerSizeToAttrs("84px", "inline", 3)).toEqual({ + style: "--rt-marker-max-size: 84px; --rt-marker-chars: 3" + }); + expect(maxMarkerSizeToAttrs("84px", "class", 3)).toEqual({ + "data-marker-max-size": "84", + "data-marker-chars": "3", + class: "has-marker-gutter has-marker-chars" + }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts b/packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts new file mode 100644 index 0000000000..30675f13fc --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts @@ -0,0 +1,63 @@ +import type { TranslationKey } from "./i18n"; + +/** + * Shared image-file handling for every path that turns a `File` into a base64 + * image: the image dialog's Upload tab and the editor's drop/paste handling + * (`extensions/ImagePasteDrop`). Both must accept and reject exactly the same + * files, so the rules live here and nowhere else. + * + * Errors are returned as a translation key plus its `###` substitution rather + * than as finished text: the drop/paste path runs inside a ProseMirror plugin, + * outside React, where `useT()` is not available. + */ + +/** Maximum file size for image uploads (5MB). */ +export const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export interface ImageFileError { + key: TranslationKey; + /** Substituted into the message's `###` placeholder, when the key has one. */ + arg?: string; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Returns the rejection reason, or `null` when the file may be inserted. */ +export function validateImageFile(file: File): ImageFileError | null { + if (file.size > MAX_FILE_SIZE) { + return { key: "image.errorTooLarge", arg: formatFileSize(file.size) }; + } + if (!file.type.startsWith("image/")) { + return { key: "image.errorNotImage" }; + } + return null; +} + +export function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("read failed")); + reader.readAsDataURL(file); + }); +} + +/** + * The `image/*` files carried by a drop or paste, in their original order. + * An empty result means the event carries no image file and must be left to + * ProseMirror (HTML slices, an `` dragged from another tab, Word paste). + */ +export function pickImageFiles(files: FileList | File[] | null | undefined): File[] { + if (!files) { + return []; + } + return Array.from(files).filter(file => file.type.startsWith("image/")); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts b/packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts new file mode 100644 index 0000000000..f32a25790a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts @@ -0,0 +1,56 @@ +/** + * Conversions between an image node's stored `width`/`height` and the two forms + * it has to take: a CSS length for the node view, and a `width`/`height` HTML + * attribute value for serialized content. + * + * The stored value is not one shape. Rich Text 4 wrote bare numbers as HTML + * attributes (`width="300"`), version 5 writes pixel strings (`"300px"`), and + * pasted HTML can carry any CSS length or a percentage. Parsing deliberately + * keeps whatever it found, so both conversions below have to cope with all of + * them. + */ + +const UNITLESS = /^\d+(\.\d+)?$/; +const PIXELS = /^(\d+(\.\d+)?)px$/i; +const PERCENTAGE = /^\d+(\.\d+)?%$/; + +function normalize(value: string | number | null | undefined): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + const str = String(value).trim(); + return str === "" ? undefined : str; +} + +/** + * Stored dimension as a CSS length. A bare number is pixels — that is what + * version 4 stored, and `width: 300` on its own is invalid CSS, so without this + * the browser drops the declaration and the image falls back to its natural size. + */ +export function toCssLength(value: string | number | null | undefined): string | undefined { + const str = normalize(value); + if (str === undefined) { + return undefined; + } + return UNITLESS.test(str) ? `${str}px` : str; +} + +/** + * Stored dimension as a `width`/`height` attribute value. The attribute is + * defined as a non-negative integer, so the `px` suffix is dropped; percentages + * are kept because browsers accept them here and dropping one would resize the + * image. Anything else (`20em`, `auto`) returns undefined so the attribute is + * omitted: legacy dimension parsing would read `20em` as 20 pixels and silently + * shrink the image. + */ +export function toHtmlDimension(value: string | number | null | undefined): string | undefined { + const str = normalize(value); + if (str === undefined) { + return undefined; + } + if (UNITLESS.test(str) || PERCENTAGE.test(str)) { + return str; + } + const pixels = PIXELS.exec(str); + return pixels ? pixels[1] : undefined; +} diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts b/packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts new file mode 100644 index 0000000000..aae90ca037 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts @@ -0,0 +1,332 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { isSafeCssColor, isSafeCssFontFamily, isSafeCssSize, normalizeCssSize } from "./helpers"; + +/** + * Formatting a list marker inherits from the first inline run of its list item. + * + * `::marker` inherits from its `
    • `, but every format the user can apply lands on an + * inline mark two levels down (`
    • >

      > `), and CSS has no child-to-ancestor + * selector. So the format has to be lifted onto the `

    • ` by JavaScript. + * + * Word and Google Docs both take the format of the item's *first text run*, which also + * gives a well-defined answer when the user formats only part of the item. Since partial + * selection splits the text into separate marked runs, "read the first inline child" + * expresses that rule directly: + * + * select all "Hello" -> Hello marker 32px + * select only "H" -> Hello marker 32px + * select only "llo" -> Hello marker unchanged + */ +export interface MarkerFormat { + fontSize?: string; + color?: string; + fontFamily?: string; + bold?: true; + italic?: true; +} + +/** Only px sizes participate in gutter math; see `parsePxSize`. */ +const PX_SIZE = /^(\d+(?:\.\d+)?)px$/; + +/** Class-mode `attr()` consumers take the bare number, matching `has-font-size`. */ +const LEADING_NUMBER = /^(\d+(?:\.\d+)?)/; + +/** + * Read the marker format from a `listItem` node. + * + * `listItem`'s content expression is `paragraph block*`, so the first child is always a + * paragraph and "the first inline run" is unambiguous. Returns `null` when the first run + * carries none of the relevant marks, so an unformatted item emits no attributes at all + * and renders byte-identically to before this feature existed. + */ +export function computeMarkerFormat(node: ProseMirrorNode): MarkerFormat | null { + // Non-text leading nodes (images, hard breaks) have an empty mark set, so they fall + // through to `null` without special-casing. + const marks = node.firstChild?.firstChild?.marks; + if (!marks?.length) { + return null; + } + + const format: MarkerFormat = {}; + + // `textStyle` is the single mark behind FontSize, TextColorClass and FontFamilyClass. + const textStyle = marks.find(mark => mark.type.name === "textStyle"); + if (textStyle) { + // The colour attribute is `textColor`, registered by TextColorClass — not the + // `color` attribute of @tiptap/extension-color, which this widget does not load. + const { fontSize, textColor, fontFamily } = textStyle.attrs; + + const normalizedSize = typeof fontSize === "string" ? normalizeCssSize(fontSize) : null; + if (normalizedSize && isSafeCssSize(normalizedSize)) { + format.fontSize = normalizedSize; + } + if (typeof textColor === "string" && isSafeCssColor(textColor)) { + format.color = textColor; + } + // `fontFamily` always holds the CSS value in both style modes; `fontValue` is only + // the kebab-case identifier the toolbar dropdown matches on, so it is not used here. + if (typeof fontFamily === "string" && isSafeCssFontFamily(fontFamily)) { + format.fontFamily = fontFamily; + } + } + + // Bold and italic are their own marks, not `textStyle` attributes. + if (marks.some(mark => mark.type.name === "bold")) { + format.bold = true; + } + if (marks.some(mark => mark.type.name === "italic")) { + format.italic = true; + } + + return Object.keys(format).length > 0 ? format : null; +} + +/** + * Largest first-run font size among a list's *direct* items, as a px string. + * + * Drives the marker gutter: an enlarged marker grows leftward out of the list's + * `padding-left`, so the padding has to grow with it. Returns `null` when no item has a + * px font size, leaving the gutter at its existing value. + * + * Non-px units are skipped rather than converted — they are not comparable without a + * layout context, and every size the toolbar offers is px. + */ +export function computeMaxMarkerSize(listNode: ProseMirrorNode): string | null { + let max = 0; + + listNode.forEach(child => { + const size = parsePxSize(computeMarkerFormat(child)?.fontSize); + if (size > max) { + max = size; + } + }); + + return max > 0 ? `${max}px` : null; +} + +function parsePxSize(value: string | undefined): number { + const match = value ? PX_SIZE.exec(value) : null; + return match ? parseFloat(match[1]) : 0; +} + +/** Roman numerals, descending, for `romanLength`. */ +const ROMAN_NUMERALS: Array<[number, string]> = [ + [1000, "m"], + [900, "cm"], + [500, "d"], + [400, "cd"], + [100, "c"], + [90, "xc"], + [50, "l"], + [40, "xl"], + [10, "x"], + [9, "ix"], + [5, "v"], + [4, "iv"], + [1, "i"] +]; + +/** + * Character count of the longest marker the list will render, excluding the trailing `.`. + * + * The gutter must fit the *longest* marker, not a typical one: markers are laid out to the + * left of the item text, so `998.` needs roughly twice the room `9.` does. Measured in + * Chrome at 98px, a 1.5x-of-font-size gutter fits one and two digits but clips three, which + * is why this is counted rather than assumed. + * + * Returns 1 for bullet lists, whose marker is a single glyph. + */ +export function computeMarkerLength(listNode: ProseMirrorNode): number { + if (listNode.type.name !== "orderedList") { + return 1; + } + + // The last item carries the highest counter, so it is the widest. + const start = typeof listNode.attrs.start === "number" ? listNode.attrs.start : 1; + const highest = Math.max(start + listNode.childCount - 1, 1); + + switch (resolveCounterStyle(listNode.attrs)) { + case "lower-alpha": + return alphaLength(highest); + case "lower-roman": + return romanLength(highest); + default: + return String(highest).length; + } +} + +/** + * The counter style, from the widget's own `listStyleType` attribute or, failing that, the + * HTML `type` attribute that `OrderedList` parses. + */ +function resolveCounterStyle(attrs: Record): string { + if (typeof attrs.listStyleType === "string") { + return attrs.listStyleType; + } + + switch (attrs.type) { + case "a": + case "A": + return "lower-alpha"; + case "i": + case "I": + return "lower-roman"; + default: + return "decimal"; + } +} + +/** `a`..`z`, then `aa`..`zz`: base-26 with no zero digit, so 26 is still one character. */ +function alphaLength(highest: number): number { + let length = 0; + let remaining = highest; + + while (remaining > 0) { + remaining = Math.ceil(remaining / 26) - 1; + length++; + } + + return length; +} + +function romanLength(highest: number): number { + let length = 0; + let remaining = highest; + + for (const [value, numeral] of ROMAN_NUMERALS) { + while (remaining >= value) { + remaining -= value; + length += numeral.length; + } + } + + return length; +} + +/** + * Inline-mode attributes: custom properties consumed by the `li::marker` rule. + * + * Custom properties are used rather than real font properties because real ones would + * cascade into the item's own content, which would need a `li > p` reset — and CSS cannot + * express "inherit from grandparent". Faking the base size would mean baking a + * theme-dependent pixel value into stored content. See design.md, Decision 3. + */ +export function markerFormatToInlineStyle(format: MarkerFormat): string { + const declarations: string[] = []; + + if (format.fontSize) { + declarations.push(`--rt-marker-font-size: ${format.fontSize}`); + } + if (format.color) { + declarations.push(`--rt-marker-color: ${format.color}`); + } + if (format.fontFamily) { + declarations.push(`--rt-marker-font-family: ${format.fontFamily}`); + } + if (format.bold) { + declarations.push("--rt-marker-font-weight: bold"); + } + if (format.italic) { + declarations.push("--rt-marker-font-style: italic"); + } + + return declarations.join("; "); +} + +/** + * Class-mode attributes: one class per property, mirroring `has-font-size` / + * `has-text-color` / `has-font-family`. + * + * Per-property classes rather than one combined class so each `attr()` rule only applies + * where its attribute actually exists — a combined class would leave `attr()` referencing + * a missing attribute whenever the user set only some of the five properties. + */ +export function markerFormatToClassAttrs(format: MarkerFormat): Record { + const classes: string[] = []; + const attrs: Record = {}; + + if (format.fontSize) { + // Bare number, matching what `attr(data-marker-font-size px)` expects. + const match = LEADING_NUMBER.exec(format.fontSize); + if (match) { + classes.push("has-marker-font-size"); + attrs["data-marker-font-size"] = match[1]; + } + } + if (format.color) { + classes.push("has-marker-color"); + attrs["data-marker-color"] = format.color; + } + if (format.fontFamily) { + classes.push("has-marker-font-family"); + attrs["data-marker-font-family"] = format.fontFamily; + } + if (format.bold) { + classes.push("has-marker-bold"); + } + if (format.italic) { + classes.push("has-marker-italic"); + } + + if (classes.length > 0) { + attrs.class = classes.join(" "); + } + + return attrs; +} + +/** Marker attributes for a `listItem`, in whichever shape the widget is configured for. */ +export function markerFormatToAttrs( + format: MarkerFormat | null, + styleDataFormat: "inline" | "class" +): Record { + if (!format) { + return {}; + } + + if (styleDataFormat === "class") { + return markerFormatToClassAttrs(format); + } + + const style = markerFormatToInlineStyle(format); + return style ? { style } : {}; +} + +/** + * Gutter attributes for an `orderedList`/`bulletList`, in the configured shape. + * + * `markerLength` is only emitted above 1, so a bullet list or a short numbered list keeps + * the markup it had before this feature and falls back to the stylesheet's default of 1. + */ +export function maxMarkerSizeToAttrs( + maxSize: string | null, + styleDataFormat: "inline" | "class", + markerLength = 1 +): Record { + if (!maxSize) { + return {}; + } + + if (styleDataFormat === "class") { + const match = LEADING_NUMBER.exec(maxSize); + if (!match) { + return {}; + } + + const classes = ["has-marker-gutter"]; + const attrs: Record = { "data-marker-max-size": match[1] }; + if (markerLength > 1) { + classes.push("has-marker-chars"); + attrs["data-marker-chars"] = String(markerLength); + } + + return { ...attrs, class: classes.join(" ") }; + } + + const declarations = [`--rt-marker-max-size: ${maxSize}`]; + if (markerLength > 1) { + declarations.push(`--rt-marker-chars: ${markerLength}`); + } + + return { style: declarations.join("; ") }; +} diff --git a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts index 392e504a72..546ad55dee 100644 --- a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts +++ b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts @@ -33,6 +33,8 @@ export type StatusBarContentEnum = "wordCount" | "characterCount" | "characterCo export type StyleDataFormatEnum = "inline" | "class"; +export type DialogStyleEnum = "inline" | "focused"; + export type ToolbarConfigEnum = "basic" | "advanced"; export type CtItemTypeEnum = @@ -119,6 +121,7 @@ export interface RichTextContainerProps { enableDefaultUpload: boolean; statusBarContent: StatusBarContentEnum; styleDataFormat: StyleDataFormatEnum; + dialogStyle: DialogStyleEnum; toolbarConfig: ToolbarConfigEnum; history: boolean; fontStyle: boolean; @@ -170,6 +173,7 @@ export interface RichTextPreviewProps { enableDefaultUpload: boolean; statusBarContent: StatusBarContentEnum; styleDataFormat: StyleDataFormatEnum; + dialogStyle: DialogStyleEnum; toolbarConfig: ToolbarConfigEnum; history: boolean; fontStyle: boolean;