From 8ef66ba71219fb067c8b9038b2fa0050d79ba3bc Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 06:35:34 +0300 Subject: [PATCH 1/3] feat(editor): float image with text wrap (#145, port from Forkmost) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds floatLeft / floatRight image alignment so text wraps beside the image, beyond the existing block left/center/right. Ported from Forkmost PR #7 / upstream Docmost PR #1132 (fuscodev), adapted to gitmost's imperative image node-view (the upstream uses a React styled component; ours styles the node-view container directly via applyAlignment). - editor-ext image.ts: `setImageAlign` accepts `floatLeft`/`floatRight`; `applyAlignment` resets float/padding then, for a float mode, sets `float:left|right` + side padding on the (shrink-to-fit) container so text flows beside it (the inner already has max-width:100%). The resolved align is mirrored onto the container as `data-image-align` for the responsive rule. `data-align` already round-trips the value through parse/renderHTML, so float survives serialization / collab / history with no schema change. - image-menu.tsx: Float-left / Float-right bubble-menu buttons (IconFloatLeft/ Right) with active state. - image-resize.module.css: on narrow screens (<=600px) a floated image collapses to full width and drops the float (`!important`, keyed on data-image-align) — the upstream "100% width on small screen" follow-up. - i18n: en-US + ru-RU strings. editor-ext build + client tsc --noEmit clean. Visual wrap behavior is best confirmed in-browser (logic/serialization verified by build + types). Co-Authored-By: Claude Opus 4.8 --- .../public/locales/en-US/translation.json | 4 +- .../public/locales/ru-RU/translation.json | 4 +- .../editor/components/image/image-menu.tsx | 44 +++++++++++++++++++ .../components/image/image-resize.module.css | 14 ++++++ packages/editor-ext/src/lib/image/image.ts | 25 ++++++++++- 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index a4dd886b..652b4382 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1287,5 +1287,7 @@ "Analytics / tracker": "Analytics / tracker", "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.", "Go to login page": "Go to login page", - "Move to space": "Move to space" + "Move to space": "Move to space", + "Float left (wrap text)": "Float left (wrap text)", + "Float right (wrap text)": "Float right (wrap text)" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index ca14b406..f403854e 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1137,5 +1137,7 @@ "Create subpage of {{name}}": "Создать подстраницу для {{name}}", "Dictation language": "Язык диктовки", "Auto-detect": "Автоопределение", - "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 right (wrap text)": "Обтекание справа" } diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 179deda1..e328090b 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -13,6 +13,8 @@ import { IconLayoutAlignCenter, IconLayoutAlignLeft, IconLayoutAlignRight, + IconFloatLeft, + IconFloatRight, IconDownload, IconRefresh, IconTrash, @@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) { isAlignLeft: ctx.editor.isActive("image", { align: "left" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignRight: ctx.editor.isActive("image", { align: "right" }), + isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }), + isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }), src: imageAttrs?.src || null, alt: imageAttrs?.alt || "", }; @@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); + const alignImageFloatLeft = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setImageAlign("floatLeft") + .run(); + }, [editor]); + + const alignImageFloatRight = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setImageAlign("floatRight") + .run(); + }, [editor]); + const handleDownload = useCallback(() => { if (!editorState?.src) return; const url = getFileUrl(editorState.src); @@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) { + + + + + + + + + + + +
{altTextButton} diff --git a/apps/client/src/features/editor/components/image/image-resize.module.css b/apps/client/src/features/editor/components/image/image-resize.module.css index 24414171..e527fae0 100644 --- a/apps/client/src/features/editor/components/image/image-resize.module.css +++ b/apps/client/src/features/editor/components/image/image-resize.module.css @@ -62,3 +62,17 @@ .resizing .handleBar { background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); } + +/* Float image (#145): on narrow screens a floated image would crowd the text to + an unreadable column, so collapse it to full width and drop the float. + `!important` is required because applyAlignment sets `float`/`padding` inline, + which a normal rule cannot override. Keys off the data-image-align attribute + the node view mirrors onto the container. */ +@media (max-width: 600px) { + :global([data-image-align="floatLeft"]), + :global([data-image-align="floatRight"]) { + float: none !important; + width: 100% !important; + padding: 0 !important; + } +} diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 6a921926..50e40491 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -51,7 +51,9 @@ declare module "@tiptap/core" { setImageAt: ( attributes: ImageAttributes & { pos: number | Range }, ) => ReturnType; - setImageAlign: (align: "left" | "center" | "right") => ReturnType; + setImageAlign: ( + align: "left" | "center" | "right" | "floatLeft" | "floatRight", + ) => ReturnType; setImageWidth: (width: number) => ReturnType; setImageSize: (width: number, height: number) => ReturnType; }; @@ -375,7 +377,26 @@ export const TiptapImage = Image.extend({ }); function applyAlignment(container: HTMLElement, align: string) { - if (align === "left") { + // Reset the float-mode styles first so toggling between any two modes is clean + // (a previous float must not leak into a later left/center/right). + container.style.float = ""; + container.style.padding = ""; + // 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). + container.dataset.imageAlign = align; + + if (align === "floatLeft") { + // Real text wrap: the (shrink-to-fit) container floats left, text flows on + // its right. The inner already carries max-width:100%. + container.style.float = "left"; + container.style.padding = "0 10px 0 0"; + container.style.justifyContent = "flex-start"; + } else if (align === "floatRight") { + container.style.float = "right"; + container.style.padding = "0 0 0 10px"; + container.style.justifyContent = "flex-end"; + } else if (align === "left") { container.style.justifyContent = "flex-start"; } else if (align === "right") { container.style.justifyContent = "flex-end"; -- 2.49.1 From 99359fa0fa817fd8e814a1f1d7223ba4eda1c187 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 12:49:33 +0300 Subject: [PATCH 2/3] fix(editor): load the float responsive rule + test applyAlignment (#145 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #157 (Request changes) caught two blockers: 1. DEAD responsive CSS: the `@media (max-width:600px)` float-reset was added to `image-resize.module.css`, which is imported NOWHERE — the image container's classes come from `common/node-resize.module.css` (via buildResizeClasses). So on mobile a floated image kept its px width + float and crushed the text, exactly the failure the rule promised to prevent. Moved the rule to `common/node-resize.module.css` (the module actually imported by the resize node views); its `:global([data-image-align=...])` selectors are data-attr based, so they work unchanged. Reverted the dead addition from the (pre-existing, orphaned) image-resize.module.css. 2. `applyAlignment` was untested. Exported it and added `image.spec.ts` (vitest/ jsdom) covering all five align values, the data-image-align mirror, and the floatLeft -> left reset-then-apply (the guard against a leaked float). Switched the float writes to the canonical CSSOM `cssFloat` property (portable: browsers + jsdom; behavior identical to the `.float` alias). editor-ext build + client tsc clean; 6 image.spec tests green. Co-Authored-By: Claude Opus 4.8 --- .../components/common/node-resize.module.css | 15 +++++ .../components/image/image-resize.module.css | 14 ---- .../editor-ext/src/lib/image/image.spec.ts | 67 +++++++++++++++++++ packages/editor-ext/src/lib/image/image.ts | 8 +-- 4 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 packages/editor-ext/src/lib/image/image.spec.ts diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css index 4159e44e..ec72f955 100644 --- a/apps/client/src/features/editor/components/common/node-resize.module.css +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -73,3 +73,18 @@ display: none !important; } } + +/* Float image (#145): on narrow screens a floated image would crowd the text to + an unreadable column, so collapse it to full width and drop the float. + `!important` is required because applyAlignment sets `float`/`padding` inline, + which a normal rule cannot override. Keys off the `data-image-align` attribute + the image node view mirrors onto its container. This module is the one actually + imported by the resize node views (node-resize-handles.ts), so the rule loads. */ +@media (max-width: 600px) { + :global([data-image-align="floatLeft"]), + :global([data-image-align="floatRight"]) { + float: none !important; + width: 100% !important; + padding: 0 !important; + } +} diff --git a/apps/client/src/features/editor/components/image/image-resize.module.css b/apps/client/src/features/editor/components/image/image-resize.module.css index e527fae0..24414171 100644 --- a/apps/client/src/features/editor/components/image/image-resize.module.css +++ b/apps/client/src/features/editor/components/image/image-resize.module.css @@ -62,17 +62,3 @@ .resizing .handleBar { background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); } - -/* Float image (#145): on narrow screens a floated image would crowd the text to - an unreadable column, so collapse it to full width and drop the float. - `!important` is required because applyAlignment sets `float`/`padding` inline, - which a normal rule cannot override. Keys off the data-image-align attribute - the node view mirrors onto the container. */ -@media (max-width: 600px) { - :global([data-image-align="floatLeft"]), - :global([data-image-align="floatRight"]) { - float: none !important; - width: 100% !important; - padding: 0 !important; - } -} diff --git a/packages/editor-ext/src/lib/image/image.spec.ts b/packages/editor-ext/src/lib/image/image.spec.ts new file mode 100644 index 00000000..2a1b7f8c --- /dev/null +++ b/packages/editor-ext/src/lib/image/image.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { applyAlignment } from "./image"; + +// applyAlignment is a pure DOM mutation: it sets the float / padding / +// justify-content / data-image-align on an image node-view container per the +// resolved `align`. Tested directly (issue #145 review) since the five-way +// branch, the reset-then-apply guard, and the data-image-align mirror (which the +// responsive @media rule keys off) are otherwise uncovered. + +describe("applyAlignment", () => { + let el: HTMLElement; + beforeEach(() => { + el = document.createElement("div"); + }); + + it("floatLeft -> float:left + right padding, mirrored on data-image-align", () => { + applyAlignment(el, "floatLeft"); + expect(el.style.cssFloat).toBe("left"); + expect(el.style.padding).toBe("0px 10px 0px 0px"); + expect(el.dataset.imageAlign).toBe("floatLeft"); + expect(el.style.justifyContent).toBe("flex-start"); + }); + + it("floatRight -> float:right + left padding", () => { + applyAlignment(el, "floatRight"); + expect(el.style.cssFloat).toBe("right"); + expect(el.style.padding).toBe("0px 0px 0px 10px"); + expect(el.dataset.imageAlign).toBe("floatRight"); + expect(el.style.justifyContent).toBe("flex-end"); + }); + + it("left -> justify flex-start, no float", () => { + applyAlignment(el, "left"); + expect(el.style.justifyContent).toBe("flex-start"); + expect(el.style.cssFloat).toBe(""); + expect(el.style.padding).toBe(""); + expect(el.dataset.imageAlign).toBe("left"); + }); + + it("right -> justify flex-end, no float", () => { + applyAlignment(el, "right"); + expect(el.style.justifyContent).toBe("flex-end"); + expect(el.style.cssFloat).toBe(""); + expect(el.dataset.imageAlign).toBe("right"); + }); + + it("center (default) -> justify center, no float", () => { + applyAlignment(el, "center"); + expect(el.style.justifyContent).toBe("center"); + expect(el.style.cssFloat).toBe(""); + expect(el.style.padding).toBe(""); + expect(el.dataset.imageAlign).toBe("center"); + }); + + it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => { + applyAlignment(el, "floatLeft"); + expect(el.style.cssFloat).toBe("left"); + expect(el.style.padding).toBe("0px 10px 0px 0px"); + // Switching to a block alignment must drop the float and its padding, not + // leak them (the bug the reset guard prevents). + applyAlignment(el, "left"); + expect(el.style.cssFloat).toBe(""); + expect(el.style.padding).toBe(""); + expect(el.dataset.imageAlign).toBe("left"); + expect(el.style.justifyContent).toBe("flex-start"); + }); +}); diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 50e40491..7856ecb6 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -376,10 +376,10 @@ export const TiptapImage = Image.extend({ }, }); -function applyAlignment(container: HTMLElement, align: string) { +export function applyAlignment(container: HTMLElement, align: string) { // Reset the float-mode styles first so toggling between any two modes is clean // (a previous float must not leak into a later left/center/right). - container.style.float = ""; + container.style.cssFloat = ""; container.style.padding = ""; // Mirror the resolved alignment onto the CONTAINER as a data attribute so the // responsive stylesheet can neutralize the float on small screens (an inline @@ -389,11 +389,11 @@ function applyAlignment(container: HTMLElement, align: string) { if (align === "floatLeft") { // Real text wrap: the (shrink-to-fit) container floats left, text flows on // its right. The inner already carries max-width:100%. - container.style.float = "left"; + container.style.cssFloat = "left"; container.style.padding = "0 10px 0 0"; container.style.justifyContent = "flex-start"; } else if (align === "floatRight") { - container.style.float = "right"; + container.style.cssFloat = "right"; container.style.padding = "0 0 0 10px"; container.style.justifyContent = "flex-end"; } else if (align === "left") { -- 2.49.1 From 43cf1913e003745b72569c4c86c05a6b5e27ec52 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 13:08:44 +0300 Subject: [PATCH 3/3] style(editor): scope the float responsive :global to .container (#145 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the file's other :global is locally scoped (.container:global(...)), but the new float-reset media rule was fully global in a *.module.css. Scope it to .container — the image node-view container carries BOTH the .container class and the data-image-align attribute (same element), so behavior is unchanged while the selector no longer leaks globally. Co-Authored-By: Claude Opus 4.8 --- .../features/editor/components/common/node-resize.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css index ec72f955..d2d7d4fe 100644 --- a/apps/client/src/features/editor/components/common/node-resize.module.css +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -81,8 +81,8 @@ the image node view mirrors onto its container. This module is the one actually imported by the resize node views (node-resize-handles.ts), so the rule loads. */ @media (max-width: 600px) { - :global([data-image-align="floatLeft"]), - :global([data-image-align="floatRight"]) { + .container:global([data-image-align="floatLeft"]), + .container:global([data-image-align="floatRight"]) { float: none !important; width: 100% !important; padding: 0 !important; -- 2.49.1