Compare commits
1 Commits
feat/275-c
...
image-inli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20032be921 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Переключить режим отображения подстраниц",
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// Covers the read-only render branch (PR #278): the language <Select> renders
|
||||
// only when `editor.isEditable`; in read-only the copy button still shows.
|
||||
// Mocks mirror the #146 structural harness (footnote-views.structure.test.tsx),
|
||||
// except Select becomes a detectable node so we can assert its presence/absence.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
NodeViewWrapper: ({ children }: any) => <div>{children}</div>,
|
||||
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Group: ({ children }: any) => <div>{children}</div>,
|
||||
Select: () => <div data-testid="language-select" />,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
ActionIcon: ({ children, onClick }: any) => (
|
||||
<button data-testid="copy-button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/common/copy-button", () => ({
|
||||
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
|
||||
}));
|
||||
vi.mock("@tabler/icons-react", () => ({
|
||||
IconCheck: () => null,
|
||||
IconCopy: () => null,
|
||||
}));
|
||||
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import CodeBlockView from "./code-block-view";
|
||||
|
||||
const makeProps = (isEditable: boolean) =>
|
||||
({
|
||||
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
|
||||
editor: {
|
||||
state: { selection: { from: 0, to: 0 } },
|
||||
isEditable,
|
||||
commands: {},
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
extension: {
|
||||
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
|
||||
},
|
||||
getPos: () => 0,
|
||||
updateAttributes: () => {},
|
||||
deleteNode: () => {},
|
||||
}) as any;
|
||||
|
||||
describe("CodeBlockView language selector visibility (#278)", () => {
|
||||
it("renders the language selector when the editor is editable", () => {
|
||||
const { queryByTestId } = render(<CodeBlockView {...makeProps(true)} />);
|
||||
expect(queryByTestId("language-select")).not.toBeNull();
|
||||
expect(queryByTestId("copy-button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the language selector in read-only but keeps the copy button", () => {
|
||||
const { queryByTestId } = render(<CodeBlockView {...makeProps(false)} />);
|
||||
expect(queryByTestId("language-select")).toBeNull();
|
||||
expect(queryByTestId("copy-button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,10 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
|
||||
With the non-editable menu rendered before it, the browser's click
|
||||
hit-testing snapped the caret up one line. Render content first; the
|
||||
menu is rendered after it and floated into the top-right corner as an
|
||||
absolute overlay (see `.menuGroup` in code-block.module.css, anchored
|
||||
to the `position: relative` `.codeBlock` wrapper in code.css). It no
|
||||
longer takes a full-width row above the code. The second #146
|
||||
menu is rendered after it and lifted back above visually via flex
|
||||
`order: -1` (the `.codeBlock` wrapper is a flex column — see
|
||||
code-block.module.css). It stays fully in flow as a full-width row
|
||||
above the code: no overlay/absolute positioning. The second #146
|
||||
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||
<pre
|
||||
spellCheck="false"
|
||||
@@ -67,23 +67,22 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
<Group contentEditable={false} className={classes.menuGroup}>
|
||||
{/* In read-only (published) there is no language selector at all —
|
||||
only the copy button. When editable the selector is hidden until
|
||||
the block is hovered/focused (or its dropdown is open) via the
|
||||
`.languageSelect` class (see code-block.module.css). */}
|
||||
{editor.isEditable && (
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ root: classes.languageSelect, input: classes.selectInput }}
|
||||
/>
|
||||
)}
|
||||
<Group
|
||||
justify="flex-end"
|
||||
contentEditable={false}
|
||||
className={classes.menuGroup}
|
||||
>
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
disabled={!editor.isEditable}
|
||||
/>
|
||||
|
||||
<CopyButton value={node?.textContent} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
|
||||
@@ -17,37 +17,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* #146: the menu follows the <pre> in the DOM (so the editable contentDOM is
|
||||
FIRST and click hit-testing is correct). Instead of sitting in-flow, it is
|
||||
floated into the top-right corner as an absolute overlay anchored to the
|
||||
`position: relative` .codeBlock wrapper (see code.css), so it no longer
|
||||
takes a full-width row above the code. The Mantine dropdown is portaled, so
|
||||
it is never clipped by the overlay. */
|
||||
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
|
||||
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
|
||||
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
|
||||
so the menu still reads as a row above the code, exactly as before, without
|
||||
sitting in-flow before the contentDOM. */
|
||||
.menuGroup {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
gap: 4px;
|
||||
order: -1;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The language selector is hidden until the block is hovered, or the selector
|
||||
itself is focused / its dropdown is open. It keeps its width in the flex
|
||||
Group (only opacity toggles) so the copy button never jumps, and
|
||||
`pointer-events: none` while hidden lets clicks fall through to the code.
|
||||
`.codeBlock` is the global NodeViewWrapper class → use :global(). */
|
||||
.languageSelect {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
:global(.codeBlock):hover .languageSelect,
|
||||
.languageSelect:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
.ProseMirror {
|
||||
.codeBlock {
|
||||
/* #146: flex column keeps the editable <pre> (first in the DOM so click
|
||||
hit-testing is correct) laid out above any Mermaid diagram. `position:
|
||||
relative` anchors the control panel, which is floated into the top-right
|
||||
corner as an absolute overlay (see `.menuGroup` in code-block.module.css). */
|
||||
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
|
||||
editable contentDOM is first) is lifted back above the code via `order`. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 4px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user