Compare commits

..

1 Commits

Author SHA1 Message Date
claude_code
20032be921 feat(editor): inline image alignment — place several images side by side
Add a new value "inline" to the image align attribute (alongside
left/center/right/floatLeft/floatRight). Inline images render as
inline-block containers, so consecutive ones form a row that wraps
naturally on narrow viewports; unlike the float modes, text does not
wrap around them.

- applyAlignment: reset-then-apply extended to display/vertical-align;
  the reset restores the constructor's inline display:flex so non-inline
  modes keep byte-identical styles and editor-ext stays independent of
  the client CSS class
- image bubble menu: new "Inline (side by side)" button (IconLayoutColumns)
  with active state, mirroring the float buttons
- i18n: key registered in en-US and ru-RU ("В ряд"), like the float labels
- tests: 3 new applyAlignment specs (apply, reset on switch-away, float->inline)
- no schema/MCP/markdown changes needed: align round-trips as data-align
2026-07-02 04:22:25 +03:00
9 changed files with 87 additions and 93 deletions

View File

@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
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

View File

@@ -1322,6 +1322,7 @@
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (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 flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",

View File

@@ -1175,6 +1175,7 @@
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
"Inline (side by side)": "В ряд",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",

View File

@@ -15,6 +15,7 @@ import {
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconLayoutColumns,
IconDownload,
IconRefresh,
IconTrash,
@@ -46,6 +47,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
isInline: ctx.editor.isActive("image", { align: "inline" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
@@ -126,6 +128,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignImageInline = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("inline")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -259,6 +269,18 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</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} />
{altTextButton}

View File

@@ -11,7 +11,6 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import { refocusEditorAfterMenuClose } from "./hooks/use-column-row-menu-lifecycle";
import classes from "./handle.module.css";
interface CellChevronProps {
@@ -88,7 +87,6 @@ export const CellChevron = React.memo(function CellChevron({
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
refocusEditorAfterMenuClose(editor);
}, [editor]);
if (!cellDom) return null;

View File

@@ -1,56 +0,0 @@
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);
});
});

View File

@@ -11,39 +11,6 @@ interface Args {
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({
editor,
orientation,
@@ -67,7 +34,6 @@ export function useColumnRowMenuLifecycle({
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
refocusEditorAfterMenuClose(editor);
}, [editor]);
return { onOpen, onClose };

View File

@@ -63,6 +63,38 @@ describe("applyAlignment", () => {
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)", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");

View File

@@ -53,7 +53,13 @@ declare module "@tiptap/core" {
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
align:
| "left"
| "center"
| "right"
| "floatLeft"
| "floatRight"
| "inline",
) => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
@@ -415,6 +421,14 @@ export function applyAlignment(container: HTMLElement, align: string) {
// (a previous float must not leak into a later left/center/right).
container.style.cssFloat = "";
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
// responsive stylesheet can neutralize the float on small screens (an inline
// `float` can only be overridden by `!important`, which keys off this attr).
@@ -430,6 +444,15 @@ export function applyAlignment(container: HTMLElement, align: string) {
container.style.cssFloat = "right";
container.style.padding = "0 0 0 10px";
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") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {