Compare commits
2 Commits
image-inli
...
fix/269-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e70c7bd6a | ||
|
|
2b36997c63 |
@@ -12,13 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
|
||||||
images as a row that wraps onto the next line on narrow screens. Unlike the
|
|
||||||
float modes, text does not wrap around inline images. The mode round-trips
|
|
||||||
losslessly through markdown as `data-align`, like the other alignment
|
|
||||||
values.
|
|
||||||
|
|
||||||
- **Editable captions for images.** Images gain an optional caption shown
|
- **Editable captions for images.** Images gain an optional caption shown
|
||||||
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
||||||
losslessly through markdown as a `data-caption` attribute on the image, so
|
losslessly through markdown as a `data-caption` attribute on the image, so
|
||||||
|
|||||||
@@ -1322,7 +1322,6 @@
|
|||||||
"Move to space": "Move to space",
|
"Move to space": "Move to space",
|
||||||
"Float left (wrap text)": "Float left (wrap text)",
|
"Float left (wrap text)": "Float left (wrap text)",
|
||||||
"Float right (wrap text)": "Float right (wrap text)",
|
"Float right (wrap text)": "Float right (wrap text)",
|
||||||
"Inline (side by side)": "Inline (side by side)",
|
|
||||||
"Switch to tree": "Switch to tree",
|
"Switch to tree": "Switch to tree",
|
||||||
"Switch to flat list": "Switch to flat list",
|
"Switch to flat list": "Switch to flat list",
|
||||||
"Toggle subpages display mode": "Toggle subpages display mode",
|
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||||
|
|||||||
@@ -1175,7 +1175,6 @@
|
|||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа",
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
"Inline (side by side)": "В ряд",
|
|
||||||
"Switch to tree": "Переключить на дерево",
|
"Switch to tree": "Переключить на дерево",
|
||||||
"Switch to flat list": "Переключить на плоский список",
|
"Switch to flat list": "Переключить на плоский список",
|
||||||
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
IconFloatLeft,
|
IconFloatLeft,
|
||||||
IconFloatRight,
|
IconFloatRight,
|
||||||
IconLayoutColumns,
|
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -47,7 +46,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||||
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
||||||
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||||
isInline: ctx.editor.isActive("image", { align: "inline" }),
|
|
||||||
src: imageAttrs?.src || null,
|
src: imageAttrs?.src || null,
|
||||||
alt: imageAttrs?.alt || "",
|
alt: imageAttrs?.alt || "",
|
||||||
caption: imageAttrs?.caption || "",
|
caption: imageAttrs?.caption || "",
|
||||||
@@ -128,14 +126,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignImageInline = useCallback(() => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setImageAlign("inline")
|
|
||||||
.run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!editorState?.src) return;
|
if (!editorState?.src) return;
|
||||||
const url = getFileUrl(editorState.src);
|
const url = getFileUrl(editorState.src);
|
||||||
@@ -269,18 +259,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Inline (side by side)")} withinPortal={false}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={alignImageInline}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Inline (side by side)")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({ [classes.active]: editorState?.isInline })}
|
|
||||||
>
|
|
||||||
<IconLayoutColumns size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
{altTextButton}
|
{altTextButton}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import clsx from "clsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||||
|
import { refocusEditorAfterMenuClose } from "./hooks/use-column-row-menu-lifecycle";
|
||||||
import classes from "./handle.module.css";
|
import classes from "./handle.module.css";
|
||||||
|
|
||||||
interface CellChevronProps {
|
interface CellChevronProps {
|
||||||
@@ -87,6 +88,7 @@ export const CellChevron = React.memo(function CellChevron({
|
|||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
editor.commands.unfreezeHandles();
|
editor.commands.unfreezeHandles();
|
||||||
|
refocusEditorAfterMenuClose(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
if (!cellDom) return null;
|
if (!cellDom) return null;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { refocusEditorAfterMenuClose } from "./use-column-row-menu-lifecycle";
|
||||||
|
|
||||||
|
// A minimal fake editor. `view.dom` is a real element so `.contains()` works,
|
||||||
|
// and `view.focus` is a spy so we assert on it without relying on real DOM
|
||||||
|
// focus (unreliable in jsdom). rAF is stubbed to a `setTimeout(0)` so fake
|
||||||
|
// timers can flush the deferred callback deterministically.
|
||||||
|
function makeEditor() {
|
||||||
|
const dom = document.createElement("div");
|
||||||
|
document.body.appendChild(dom);
|
||||||
|
const focus = vi.fn();
|
||||||
|
const editor = { isDestroyed: false, view: { dom, focus } };
|
||||||
|
return { editor: editor as unknown as Editor, focus, dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("refocusEditorAfterMenuClose", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||||
|
setTimeout(() => cb(0), 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(a) does not refocus the editor when an external <input> is active", () => {
|
||||||
|
const { editor, focus } = makeEditor();
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
refocusEditorAfterMenuClose(editor);
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(focus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(b) refocuses the editor when a non-focusable element (body) is active", () => {
|
||||||
|
const { editor, focus } = makeEditor();
|
||||||
|
// Ensure focus rests on body: nothing is focused / an <input> was blurred.
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
refocusEditorAfterMenuClose(editor);
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,39 @@ interface Args {
|
|||||||
tablePos: number;
|
tablePos: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore focus to the editor after a table handle/cell menu closes.
|
||||||
|
*
|
||||||
|
* The grip/chevron menus are Mantine `<Menu>`s with `returnFocus: true`, and
|
||||||
|
* their targets live in a floating/portaled layer OUTSIDE the editor's
|
||||||
|
* contenteditable. After an action (delete row/column, insert, etc.) the menu
|
||||||
|
* closes and Mantine returns focus to that outside target, so ProseMirror's
|
||||||
|
* undo keymap never sees Ctrl+Z until the user clicks back into a cell.
|
||||||
|
*
|
||||||
|
* We defer with `requestAnimationFrame` so this runs AFTER Mantine's
|
||||||
|
* returnFocus, and guard against stealing focus if the user intentionally
|
||||||
|
* moved to another input/editable (e.g. the page title).
|
||||||
|
*/
|
||||||
|
export function refocusEditorAfterMenuClose(editor: Editor) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (editor.isDestroyed) return;
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
// Already inside the editor — nothing to do.
|
||||||
|
if (active && editor.view.dom.contains(active)) return;
|
||||||
|
// Respect a deliberate move to another field/editable.
|
||||||
|
const tag = active?.tagName;
|
||||||
|
if (
|
||||||
|
tag === "INPUT" ||
|
||||||
|
tag === "TEXTAREA" ||
|
||||||
|
tag === "SELECT" ||
|
||||||
|
active?.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.view.focus(); // pure DOM focus, no extra transaction
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useColumnRowMenuLifecycle({
|
export function useColumnRowMenuLifecycle({
|
||||||
editor,
|
editor,
|
||||||
orientation,
|
orientation,
|
||||||
@@ -34,6 +67,7 @@ export function useColumnRowMenuLifecycle({
|
|||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
editor.commands.unfreezeHandles();
|
editor.commands.unfreezeHandles();
|
||||||
|
refocusEditorAfterMenuClose(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return { onOpen, onClose };
|
return { onOpen, onClose };
|
||||||
|
|||||||
@@ -63,38 +63,6 @@ describe("applyAlignment", () => {
|
|||||||
expect(el.dataset.imageAlign).toBe("center");
|
expect(el.dataset.imageAlign).toBe("center");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inline -> inline-block + top alignment + gap padding, no float", () => {
|
|
||||||
applyAlignment(el, "inline");
|
|
||||||
expect(el.style.display).toBe("inline-block");
|
|
||||||
expect(el.style.verticalAlign).toBe("top");
|
|
||||||
expect(el.style.padding).toBe("0px 10px 10px 0px");
|
|
||||||
expect(el.dataset.imageAlign).toBe("inline");
|
|
||||||
expect(el.style.cssFloat).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears inline-block when switching inline -> center (reset-then-apply)", () => {
|
|
||||||
applyAlignment(el, "inline");
|
|
||||||
expect(el.style.display).toBe("inline-block");
|
|
||||||
// Switching back to a flex alignment must replace the inline-block
|
|
||||||
// override with the constructor-style flex, not just clear it.
|
|
||||||
applyAlignment(el, "center");
|
|
||||||
expect(el.style.display).toBe("flex");
|
|
||||||
expect(el.style.verticalAlign).toBe("");
|
|
||||||
expect(el.style.padding).toBe("");
|
|
||||||
expect(el.dataset.imageAlign).toBe("center");
|
|
||||||
expect(el.style.justifyContent).toBe("center");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears a previous float when switching floatLeft -> inline", () => {
|
|
||||||
applyAlignment(el, "floatLeft");
|
|
||||||
expect(el.style.cssFloat).toBe("left");
|
|
||||||
applyAlignment(el, "inline");
|
|
||||||
expect(el.style.cssFloat).toBe("");
|
|
||||||
expect(el.style.display).toBe("inline-block");
|
|
||||||
expect(el.style.verticalAlign).toBe("top");
|
|
||||||
expect(el.dataset.imageAlign).toBe("inline");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
|
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
|
||||||
applyAlignment(el, "floatLeft");
|
applyAlignment(el, "floatLeft");
|
||||||
expect(el.style.cssFloat).toBe("left");
|
expect(el.style.cssFloat).toBe("left");
|
||||||
|
|||||||
@@ -53,13 +53,7 @@ declare module "@tiptap/core" {
|
|||||||
attributes: ImageAttributes & { pos: number | Range },
|
attributes: ImageAttributes & { pos: number | Range },
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageAlign: (
|
setImageAlign: (
|
||||||
align:
|
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
|
||||||
| "left"
|
|
||||||
| "center"
|
|
||||||
| "right"
|
|
||||||
| "floatLeft"
|
|
||||||
| "floatRight"
|
|
||||||
| "inline",
|
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageWidth: (width: number) => ReturnType;
|
setImageWidth: (width: number) => ReturnType;
|
||||||
setImageSize: (width: number, height: number) => ReturnType;
|
setImageSize: (width: number, height: number) => ReturnType;
|
||||||
@@ -421,14 +415,6 @@ export function applyAlignment(container: HTMLElement, align: string) {
|
|||||||
// (a previous float must not leak into a later left/center/right).
|
// (a previous float must not leak into a later left/center/right).
|
||||||
container.style.cssFloat = "";
|
container.style.cssFloat = "";
|
||||||
container.style.padding = "";
|
container.style.padding = "";
|
||||||
// The ResizableNodeView constructor sets an inline `display: flex` on the
|
|
||||||
// container; the inline mode overrides it with `inline-block`, so the reset
|
|
||||||
// restores the constructor's flex here. This keeps the container's layout
|
|
||||||
// independent of any app-level CSS class (which also happens to set flex)
|
|
||||||
// and makes non-inline modes carry exactly the same inline styles as before
|
|
||||||
// the inline mode existed.
|
|
||||||
container.style.display = "flex";
|
|
||||||
container.style.verticalAlign = "";
|
|
||||||
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
|
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
|
||||||
// responsive stylesheet can neutralize the float on small screens (an inline
|
// responsive stylesheet can neutralize the float on small screens (an inline
|
||||||
// `float` can only be overridden by `!important`, which keys off this attr).
|
// `float` can only be overridden by `!important`, which keys off this attr).
|
||||||
@@ -444,15 +430,6 @@ export function applyAlignment(container: HTMLElement, align: string) {
|
|||||||
container.style.cssFloat = "right";
|
container.style.cssFloat = "right";
|
||||||
container.style.padding = "0 0 0 10px";
|
container.style.padding = "0 0 0 10px";
|
||||||
container.style.justifyContent = "flex-end";
|
container.style.justifyContent = "flex-end";
|
||||||
} else if (align === "inline") {
|
|
||||||
// Consecutive inline images sit side by side on one line box and wrap to
|
|
||||||
// the next line when the viewport is narrow. The right/bottom padding
|
|
||||||
// provides the gap between images in a row and between wrapped rows;
|
|
||||||
// vertical-align: top keeps rows of different-height images aligned by
|
|
||||||
// their top edge.
|
|
||||||
container.style.display = "inline-block";
|
|
||||||
container.style.verticalAlign = "top";
|
|
||||||
container.style.padding = "0 10px 10px 0";
|
|
||||||
} else if (align === "left") {
|
} else if (align === "left") {
|
||||||
container.style.justifyContent = "flex-start";
|
container.style.justifyContent = "flex-start";
|
||||||
} else if (align === "right") {
|
} else if (align === "right") {
|
||||||
|
|||||||
Reference in New Issue
Block a user