feat(editor): add per-image left, center, and right alignment (#654)
Select a standalone image to align it left, center, or right. The choice stays with that image through save, Markdown edits, and reload. Existing images keep their previous layout until an alignment is chosen. Galleries keep their own layout. Native editors apply a saved alignment and do not add a picker in this change.
This commit is contained in:
1 parent
eccda710f8
commit
a5bda92e14
17 files changed
+475
-21
No files matched your search
@@ -13,6 +13,7 @@ import mermaid from "mermaid";
|
||||
import { toCanvas } from "html-to-image";
|
||||
import {
|
||||
createEdgeEverDocumentExtensions,
|
||||
applyImageAlignment,
|
||||
createNativeUnsupportedContentExtensions,
|
||||
DETAILS_EDITOR_CSS,
|
||||
diagramDocumentToX6Cells,
|
||||
@@ -407,7 +408,7 @@ function clampImageWidth(n: number): number {
|
||||
* with the picture — otherwise WKWebView keeps a full-width slot and paints
|
||||
* a pale selection wash in the empty half.
|
||||
*/
|
||||
function applyFigureWidth(el: HTMLElement, width: number | null) {
|
||||
function applyFigureWidth(el: HTMLElement, width: number | null, alignment?: unknown) {
|
||||
const w = width == null ? null : clampImageWidth(width);
|
||||
if (w == null) {
|
||||
el.style.removeProperty("width");
|
||||
@@ -415,6 +416,7 @@ function applyFigureWidth(el: HTMLElement, width: number | null) {
|
||||
el.style.removeProperty("--ee-image-width");
|
||||
delete el.dataset.width;
|
||||
el.classList.remove("has-width");
|
||||
applyImageAlignment(el, alignment);
|
||||
return;
|
||||
}
|
||||
const pct = `${w}%`;
|
||||
@@ -428,6 +430,7 @@ function applyFigureWidth(el: HTMLElement, width: number | null) {
|
||||
el.style.setProperty("margin-right", "auto");
|
||||
el.style.setProperty("box-sizing", "border-box");
|
||||
el.style.setProperty("display", "block");
|
||||
applyImageAlignment(el, alignment);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -533,7 +536,7 @@ function createEdgeEverImageExtension() {
|
||||
/* keep markup update even if selection fails */
|
||||
}
|
||||
ed.view.dispatch(tr);
|
||||
applyFigureWidth(wrapper, nextWidth);
|
||||
applyFigureWidth(wrapper, nextWidth, currentNode.attrs.alignment);
|
||||
setActiveWidth(nextWidth);
|
||||
};
|
||||
|
||||
@@ -660,7 +663,7 @@ function createEdgeEverImageExtension() {
|
||||
const alt = String(attrs.alt ?? "");
|
||||
image.alt = alt;
|
||||
const width = parseImageWidth(attrs.width);
|
||||
applyFigureWidth(wrapper, width);
|
||||
applyFigureWidth(wrapper, width, attrs.alignment);
|
||||
setActiveWidth(width);
|
||||
const src = String(attrs.src ?? "");
|
||||
const rid = getResourceIdFromHref(src);
|
||||
@@ -728,7 +731,7 @@ function createEdgeEverImageExtension() {
|
||||
loadImage(updated.attrs as Record<string, unknown>);
|
||||
} else {
|
||||
setActiveWidth(parseImageWidth(updated.attrs.width));
|
||||
applyFigureWidth(wrapper, parseImageWidth(updated.attrs.width));
|
||||
applyFigureWidth(wrapper, parseImageWidth(updated.attrs.width), updated.attrs.alignment);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -740,7 +743,7 @@ function createEdgeEverImageExtension() {
|
||||
parseImageWidth(
|
||||
(ed.state.selection as { node?: { attrs?: { width?: unknown } } }).node?.attrs?.width
|
||||
) ?? parseImageWidth(wrapper.dataset.width) ?? parseImageWidth(currentNode.attrs.width);
|
||||
applyFigureWidth(wrapper, selWidth);
|
||||
applyFigureWidth(wrapper, selWidth, currentNode.attrs.alignment);
|
||||
// Width bar only in editor when the image is the active node selection.
|
||||
if (ed.isEditable && mode === "editor" && !image.hidden) {
|
||||
sizeControls.hidden = false;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
markdownToDoc,
|
||||
MEMO_CONTENT_STYLE,
|
||||
createEdgeEverDocumentExtensions,
|
||||
applyImageAlignment,
|
||||
DETAILS_EDITOR_CSS,
|
||||
wrapDetailsContentHtml,
|
||||
NativeAttachmentMetadata,
|
||||
@@ -2299,6 +2300,7 @@ const applyImageWidth = (
|
||||
const width = parseImageWidth(attributes.width) ?? NEW_IMAGE_WIDTH_PERCENT;
|
||||
element.style.width = `${width}%`;
|
||||
element.dataset.width = String(width);
|
||||
applyImageAlignment(element, attributes.alignment);
|
||||
return width;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type Pointer
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer, useEditorState, type NodeViewProps } from "@tiptap/react";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { AlignLeft, AlignCenter, AlignRight, Maximize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
|
||||
import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE, imageAlignmentMargins, parseImageAlignment, type ImageAlignment } from "@edgeever/shared";
|
||||
import {
|
||||
IMAGE_WIDTH_PRESETS,
|
||||
NEW_IMAGE_WIDTH_PERCENT,
|
||||
@@ -49,6 +49,8 @@ const ResizableImageNodeView = ({
|
||||
const [previewWidth, setPreviewWidth] = useState<number | null>(null);
|
||||
const nodeWidth = parseImageWidth(node.attrs.width) ?? NEW_IMAGE_WIDTH_PERCENT;
|
||||
const width = previewWidth ?? nodeWidth;
|
||||
const explicitAlignment = parseImageAlignment(node.attrs.alignment);
|
||||
const alignment = explicitAlignment ?? "left";
|
||||
const { editable, inGallery } = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: activeEditor }) => ({
|
||||
@@ -144,6 +146,10 @@ const ResizableImageNodeView = ({
|
||||
window.dispatchEvent(new CustomEvent(IMAGE_MENU_HIDE_EVENT));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSizeControls) hideImageMenu();
|
||||
}, [hideImageMenu, showSizeControls]);
|
||||
|
||||
const requestImagePreview = useCallback(() => {
|
||||
if (!src) return;
|
||||
hideImageMenu();
|
||||
@@ -160,6 +166,11 @@ const ResizableImageNodeView = ({
|
||||
updateAttributes({ width: clampImageWidth(nextWidth) });
|
||||
}, [editor, getPos, updateAttributes]);
|
||||
|
||||
const updateAlignment = (next: ImageAlignment) => {
|
||||
if (!editor.isEditable || isImageInGallery(editor.state.doc, getPos())) return;
|
||||
updateAttributes({ alignment: next });
|
||||
};
|
||||
|
||||
const startResize = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (!editable || isImageInGallery(editor.state.doc, getPos())) return;
|
||||
|
||||
@@ -169,13 +180,16 @@ const ResizableImageNodeView = ({
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
const parentWidth = parent.getBoundingClientRect().width;
|
||||
const parentBounds = parent.getBoundingClientRect();
|
||||
const parentWidth = parentBounds.width;
|
||||
if (parentWidth <= 0) return;
|
||||
|
||||
let pendingWidth = nodeWidth;
|
||||
const startX = event.clientX;
|
||||
const previewFromPointer = (clientX: number) => {
|
||||
const wrapperLeft = wrapper.getBoundingClientRect().left;
|
||||
pendingWidth = clampImageWidth(((clientX - wrapperLeft) / parentWidth) * 100);
|
||||
// Resizing is relative to the initial width: centered/right-aligned boxes
|
||||
// move their left edge as they resize, so measuring that edge causes jumps.
|
||||
pendingWidth = clampImageWidth(nodeWidth + ((clientX - startX) / parentWidth) * 100);
|
||||
setPreviewWidth(pendingWidth);
|
||||
};
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => previewFromPointer(moveEvent.clientX);
|
||||
@@ -222,9 +236,10 @@ const ResizableImageNodeView = ({
|
||||
ref={wrapperRef}
|
||||
as="figure"
|
||||
className={cn("edgeever-image-node", selected && "is-selected")}
|
||||
style={{ width: `${width}%` }}
|
||||
style={{ width: `${width}%`, ...(explicitAlignment ? imageAlignmentMargins(explicitAlignment) : {}) }}
|
||||
data-width={width}
|
||||
onMouseEnter={requestImageMenu}
|
||||
data-image-align={alignment}
|
||||
onMouseEnter={showSizeControls ? undefined : requestImageMenu}
|
||||
onMouseLeave={hideImageMenu}
|
||||
onContextMenu={(event: MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -238,6 +253,15 @@ const ResizableImageNodeView = ({
|
||||
title={title || undefined}
|
||||
draggable={false}
|
||||
referrerPolicy={getImageReferrerPolicy(src)}
|
||||
onClick={(event) => {
|
||||
if (!editor.isEditable || isImageInGallery(editor.state.doc, getPos())) return;
|
||||
const position = getPos();
|
||||
if (typeof position !== "number") return;
|
||||
// A positioned figure does not occupy the full block wrapper. Select
|
||||
// its node explicitly instead of relying on coordinate hit testing.
|
||||
event.preventDefault();
|
||||
editor.chain().focus().setNodeSelection(position).run();
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -245,7 +269,7 @@ const ResizableImageNodeView = ({
|
||||
}}
|
||||
/>
|
||||
{showSizeControls && (
|
||||
<div className="edgeever-image-controls" contentEditable={false}>
|
||||
<div className="edgeever-image-controls" contentEditable={false} onMouseEnter={hideImageMenu}>
|
||||
{previewButton}
|
||||
<div className="edgeever-image-presets" aria-label={t("editor.imageScale")}>
|
||||
{IMAGE_WIDTH_PRESETS.map((preset) => (
|
||||
@@ -262,6 +286,31 @@ const ResizableImageNodeView = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
|
||||
<div className="edgeever-image-alignments" role="group" aria-label={t("editor.imageAlignment")}>
|
||||
{([
|
||||
{ value: "left", Icon: AlignLeft },
|
||||
{ value: "center", Icon: AlignCenter },
|
||||
{ value: "right", Icon: AlignRight },
|
||||
] as const).map(({ value, Icon }) => (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("edgeever-image-alignment", alignment === value && "is-active")}
|
||||
aria-label={t(`editor.imageAlignments.${value}`)}
|
||||
aria-pressed={alignment === value}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => updateAlignment(value)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t(`editor.imageAlignments.${value}`)}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{canGroupAdjacentImages ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2132,11 +2132,13 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-controls {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
|
||||
@@ -2327,10 +2329,39 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
|
||||
.ProseMirror .edgeever-image-presets {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignments {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignment {
|
||||
display: inline-flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignment:hover,
|
||||
.ProseMirror .edgeever-image-alignment.is-active {
|
||||
background: var(--brand-green-muted);
|
||||
color: var(--brand-green-text);
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignment:focus-visible {
|
||||
outline: 2px solid var(--brand-green);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-preset {
|
||||
display: inline-flex;
|
||||
min-width: 3rem;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Individual image alignment
|
||||
|
||||
Select a standalone image in the rich text editor to choose **Left**, **Center**, or **Right**. Alignment belongs to that image and works with its size setting. The selected image's controls occupy a wrapping row below the picture so controls remain usable on smaller images. Dragging to resize retains the chosen alignment without jumping at the start of the drag.
|
||||
|
||||
Existing images retain their default layout until an alignment is chosen. Galleries continue to use their own layout controls. Explicitly aligned images remain separate during Markdown import, instead of becoming a gallery merely because they are adjacent.
|
||||
|
||||
Alignment is saved in rich text JSON and exported as an HTML image tag in Markdown, including its percentage width and portable alignment styles. The shared codec supports the tags without a DOM, including inside expandable details. Shared and native image schemas retain the attribute, and native figure renderers apply explicit alignment. Native alignment-picker controls are outside this change, and native-device testing has not been performed.
|
||||
@@ -0,0 +1,7 @@
|
||||
# 单张图片对齐
|
||||
|
||||
在富文本编辑器中选中独立图片,即可选择**左对齐**、**居中**或**右对齐**。对齐方式单独保存在该图片上,并可与尺寸设置配合使用。选中图片的操作按钮在图片下方换行排列,使较小图片的控件也能正常使用。拖动调整尺寸时会保留所选对齐方式,且开始拖动时不会突然改变尺寸。
|
||||
|
||||
原有图片在选择对齐方式前保持默认布局。图库继续使用自己的布局控件。显式设置对齐的图片在导入 Markdown 时保持独立,不会仅因相邻而自动变成图库。
|
||||
|
||||
对齐方式保存在富文本 JSON 中,并在 Markdown 中导出为 HTML 图片标签,同时保留百分比宽度与通用对齐样式。共享编解码器无需 DOM 即可处理这些标签,包括可折叠详情中的图片。共享及原生图片结构会保留该属性,原生图片视图会应用显式对齐。本次变更不包含原生端对齐选择界面,也尚未进行原生设备实测。
|
||||
@@ -3,6 +3,7 @@ import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-detai
|
||||
import { resolveAttachmentKind, resolveVideoMimeType } from "./attachment-kind";
|
||||
import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment";
|
||||
import { parseImageWidth } from "./image-display";
|
||||
import { alignedImageToHtml, parseImageAlignment, type ImageAlignment } from "./image-alignment";
|
||||
import { getResourceIdFromUrl } from "./resource-links";
|
||||
|
||||
export const DETAILS_NODE_TYPE = "details" as const;
|
||||
@@ -149,7 +150,7 @@ const remoteVideoFromSrc = (src: string): RemoteVideo | null => {
|
||||
|
||||
type PreparedDetailsBody = {
|
||||
markdown: string;
|
||||
images: Array<{ src: string; width: number | null }>;
|
||||
images: Array<{ src: string; width: number | null; alignment?: ImageAlignment }>;
|
||||
videoUrls: string[];
|
||||
};
|
||||
|
||||
@@ -189,7 +190,8 @@ const prepareDetailsBody = (source: string): PreparedDetailsBody => {
|
||||
if (!src) return tag;
|
||||
const alt = readHtmlAttr(attrs || "", "alt");
|
||||
const title = readHtmlAttr(attrs || "", "title");
|
||||
images.push({ src, width: readPercentWidth(readHtmlAttr(attrs || "", "width")) });
|
||||
const alignment = parseImageAlignment(readHtmlAttr(attrs || "", "data-image-align"));
|
||||
images.push({ src, width: readPercentWidth(readHtmlAttr(attrs || "", "width")), ...(alignment ? { alignment } : {}) });
|
||||
return title ? `` : ``;
|
||||
},
|
||||
);
|
||||
@@ -209,8 +211,11 @@ const applyDetailsMedia = (
|
||||
const src = typeof node.attrs?.src === "string" ? node.attrs.src : "";
|
||||
if (expected && expected.src === src) {
|
||||
imageIndex += 1;
|
||||
if (expected.width != null) {
|
||||
return { ...node, attrs: { ...node.attrs, width: expected.width } };
|
||||
if (expected.width != null || expected.alignment) {
|
||||
return { ...node, attrs: { ...node.attrs,
|
||||
...(expected.width != null ? { width: expected.width } : {}),
|
||||
...(expected.alignment ? { alignment: expected.alignment } : {}),
|
||||
} };
|
||||
}
|
||||
}
|
||||
return node;
|
||||
@@ -240,6 +245,7 @@ const htmlAttribute = (name: string, value: string) => `${name}="${escapeHtml(va
|
||||
const renderDetailsImage = (node: JSONContent) => {
|
||||
const src = typeof node.attrs?.src === "string" ? node.attrs.src.trim() : "";
|
||||
if (!isDetailsImageSrc(src)) return null;
|
||||
if (parseImageAlignment(node.attrs?.alignment)) return alignedImageToHtml(node);
|
||||
const alt = typeof node.attrs?.alt === "string" ? node.attrs.alt.trim() : "";
|
||||
const title = typeof node.attrs?.title === "string" ? node.attrs.title.trim() : "";
|
||||
const width = node.attrs?.width == null || node.attrs.width === ""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { EdgeEverImage as Image, ImageAlignmentAttributes } from "./image-alignment";
|
||||
import { TaskList } from "@tiptap/extension-list";
|
||||
import { EdgeEverTableKit as TableKit } from "./markdown-table";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
@@ -49,6 +49,7 @@ export const createEdgeEverDocumentExtensions = (
|
||||
createEdgeEverTaskItem(),
|
||||
options.table === undefined ? TableKit : TableKit.configure(options.table),
|
||||
...withOptional(options.image, Image),
|
||||
ImageAlignmentAttributes,
|
||||
...withOptional(options.gallery, ImageGallery),
|
||||
...withOptional(options.pdf, PdfAttachment),
|
||||
...withOptional(options.file, FileAttachment),
|
||||
|
||||
@@ -2315,6 +2315,8 @@ export const enUS = {
|
||||
},
|
||||
editor: {
|
||||
imageScale: "Image scale",
|
||||
imageAlignment: "Image alignment",
|
||||
imageAlignments: { left: "Align image left", center: "Center image", right: "Align image right" },
|
||||
scaleTo: "Scale to {{percent}}%",
|
||||
imageSizeSmall: "Small",
|
||||
imageSizeMedium: "Medium",
|
||||
|
||||
@@ -2304,6 +2304,8 @@ export const ja = {
|
||||
},
|
||||
editor: {
|
||||
imageScale: "画像の倍率",
|
||||
imageAlignment: "画像の配置",
|
||||
imageAlignments: { left: "画像を左揃え", center: "画像を中央揃え", right: "画像を右揃え" },
|
||||
scaleTo: "{{percent}}% に拡大縮小",
|
||||
imageSizeSmall: "小",
|
||||
imageSizeMedium: "中",
|
||||
|
||||
@@ -2304,6 +2304,8 @@ export const pl = {
|
||||
},
|
||||
editor: {
|
||||
imageScale: "Skala obrazu",
|
||||
imageAlignment: "Wyrównanie obrazu",
|
||||
imageAlignments: { left: "Wyrównaj obraz do lewej", center: "Wyśrodkuj obraz", right: "Wyrównaj obraz do prawej" },
|
||||
scaleTo: "Skaluj do {{percent}}%",
|
||||
imageSizeSmall: "Mały",
|
||||
imageSizeMedium: "Średni",
|
||||
|
||||
@@ -2315,6 +2315,8 @@ export const zhCN = {
|
||||
},
|
||||
editor: {
|
||||
imageScale: "图片缩放",
|
||||
imageAlignment: "图片对齐",
|
||||
imageAlignments: { left: "图片左对齐", center: "图片居中", right: "图片右对齐" },
|
||||
scaleTo: "缩放到 {{percent}}%",
|
||||
imageSizeSmall: "较小",
|
||||
imageSizeMedium: "适中",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createEdgeEverDocumentExtensions } from "./document-extensions.ts";
|
||||
import { createEdgeEverMarkdownMathematics } from "./mathematics-markdown.ts";
|
||||
import { docToMarkdown, markdownToDoc, docToText } from "./content.ts";
|
||||
import { alignedImageToHtml, applyImageAlignment, IMAGE_ALIGNMENTS, imageAlignmentMargins } from "./image-alignment.ts";
|
||||
|
||||
const image = (alignment, width = 50) => ({ type: "image", attrs: {
|
||||
src: "/api/v1/resources/example/blob?x=1&y=2", alt: 'A "quoted" <image>', title: "An image & title", width, alignment,
|
||||
} });
|
||||
|
||||
describe("individual image alignment", () => {
|
||||
test("round-trips each alignment and width in Workers without a DOM", () => {
|
||||
expect(typeof window).toBe("undefined");
|
||||
for (const alignment of IMAGE_ALIGNMENTS) {
|
||||
const original = image(alignment, 35);
|
||||
const markdown = docToMarkdown({ type: "doc", content: [original] });
|
||||
expect(markdown).toBe(alignedImageToHtml(original));
|
||||
expect(markdownToDoc(markdown).content).toEqual([original]);
|
||||
expect(docToMarkdown(markdownToDoc(markdown))).toBe(markdown);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy Markdown images keep their existing serialization and grouping", () => {
|
||||
const markdown = "\n\n";
|
||||
const doc = markdownToDoc(markdown);
|
||||
expect(doc.content[0].type).toBe("edgeeverImageGallery");
|
||||
expect(docToMarkdown(doc)).toBe(markdown);
|
||||
expect(docToMarkdown({ type: "doc", content: [image(null)] })).not.toContain("<img");
|
||||
});
|
||||
|
||||
test("adjacent aligned images stay independent after Markdown edits", () => {
|
||||
const markdown = [alignedImageToHtml(image("left")), alignedImageToHtml(image("center")), alignedImageToHtml(image("right"))].join("\n\n");
|
||||
const doc = markdownToDoc(markdown);
|
||||
expect(doc.content.map((node) => node.type)).toEqual(["image", "image", "image"]);
|
||||
expect(doc.content.map((node) => node.attrs.alignment)).toEqual(IMAGE_ALIGNMENTS);
|
||||
expect(docToMarkdown(doc)).toBe(markdown);
|
||||
});
|
||||
|
||||
test("preserves alignment inside expandable details", () => {
|
||||
const markdown = `<details>\n<summary>Details</summary>\n\n${alignedImageToHtml(image("center"))}\n\n</details>`;
|
||||
const doc = markdownToDoc(markdown);
|
||||
expect(doc.content[0].content[1].content[0]).toMatchObject(image("center"));
|
||||
expect(docToMarkdown(doc)).toContain('data-image-align="center"');
|
||||
expect(markdownToDoc(docToMarkdown(doc))).toEqual(doc);
|
||||
});
|
||||
|
||||
test("custom image schemas retain alignment and old images default to null", () => {
|
||||
const schema = getSchema(createEdgeEverDocumentExtensions({ image: Image, mathematics: createEdgeEverMarkdownMathematics() }));
|
||||
expect(schema.nodeFromJSON(image("right")).attrs.alignment).toBe("right");
|
||||
expect(schema.nodeFromJSON({ type: "image", attrs: { src: "/old.png" } }).attrs.alignment).toBeNull();
|
||||
});
|
||||
|
||||
test("native figures apply explicit alignment and can undo back to legacy layout", () => {
|
||||
const { document } = parseHTML("<figure></figure>");
|
||||
const figure = document.querySelector("figure");
|
||||
applyImageAlignment(figure, "right");
|
||||
expect(figure.dataset.imageAlign).toBe("right");
|
||||
expect(figure.style.marginLeft).toBe("auto");
|
||||
expect(figure.style.marginRight).toBe("0");
|
||||
applyImageAlignment(figure, null);
|
||||
expect(figure.dataset.imageAlign).toBeUndefined();
|
||||
expect(figure.style.marginLeft ?? "").toBe("");
|
||||
expect(imageAlignmentMargins("invalid")).toEqual({ marginLeft: "0", marginRight: "auto" });
|
||||
});
|
||||
|
||||
test("plain image readers render the percentage width and alignment together", () => {
|
||||
const schema = getSchema(createEdgeEverDocumentExtensions({ image: Image, mathematics: createEdgeEverMarkdownMathematics() }));
|
||||
const { document } = parseHTML("<html></html>");
|
||||
const element = DOMSerializer.fromSchema(schema).serializeNode(schema.nodeFromJSON(image("center")), { document });
|
||||
expect(element.getAttribute("data-image-align")).toBe("center");
|
||||
expect(element.getAttribute("data-width")).toBe("50");
|
||||
expect(element.style.width).toBe("50%");
|
||||
expect(element.style.marginLeft).toBe("auto");
|
||||
expect(element.style.marginRight).toBe("auto");
|
||||
});
|
||||
|
||||
test("treats fenced HTML as code and never imports executable image sources", () => {
|
||||
const html = alignedImageToHtml(image("center"));
|
||||
const code = `\`\`\`html\n${html}\n\`\`\``;
|
||||
expect(docToMarkdown(markdownToDoc(code))).toBe(code);
|
||||
const unsafe = '<img src="javascript:alert(1)" data-image-align="center" />';
|
||||
const doc = markdownToDoc(unsafe);
|
||||
expect(doc.content.some((node) => node.type === "image")).toBe(false);
|
||||
expect(docToText(doc)).toContain("javascript:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Extension, type JSONContent } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { parseImageWidth } from "./image-display";
|
||||
|
||||
export const IMAGE_ALIGNMENTS = ["left", "center", "right"] as const;
|
||||
export type ImageAlignment = (typeof IMAGE_ALIGNMENTS)[number];
|
||||
|
||||
export const parseImageAlignment = (value: unknown): ImageAlignment | null =>
|
||||
IMAGE_ALIGNMENTS.includes(value as ImageAlignment) ? value as ImageAlignment : null;
|
||||
|
||||
export const imageAlignmentMargins = (value: unknown) => {
|
||||
const alignment = parseImageAlignment(value) ?? "left";
|
||||
return {
|
||||
marginLeft: alignment === "left" ? "0" : "auto",
|
||||
marginRight: alignment === "right" ? "0" : "auto",
|
||||
};
|
||||
};
|
||||
|
||||
export const imageAlignmentStyle = (value: unknown, widthValue?: unknown) => {
|
||||
const margins = imageAlignmentMargins(value);
|
||||
const width = parseImageWidth(widthValue);
|
||||
return `display: block; margin-left: ${margins.marginLeft}; margin-right: ${margins.marginRight}${width === null ? "" : `; width: ${width}%`}`;
|
||||
};
|
||||
|
||||
/** Native figure views keep their legacy layout until an alignment is explicit. */
|
||||
export const applyImageAlignment = (element: HTMLElement, value: unknown) => {
|
||||
const alignment = parseImageAlignment(value);
|
||||
if (!alignment) {
|
||||
if (element.dataset.imageAlign) {
|
||||
delete element.dataset.imageAlign;
|
||||
element.style.removeProperty("margin-left");
|
||||
element.style.removeProperty("margin-right");
|
||||
}
|
||||
return;
|
||||
}
|
||||
element.dataset.imageAlign = alignment;
|
||||
Object.assign(element.style, imageAlignmentMargins(alignment));
|
||||
};
|
||||
|
||||
// Register against every image implementation, including custom native views.
|
||||
export const ImageAlignmentAttributes = Extension.create({
|
||||
name: "imageAlignment",
|
||||
addGlobalAttributes() {
|
||||
return [{ types: ["image"], attributes: {
|
||||
alignment: {
|
||||
default: null,
|
||||
parseHTML: (element) => parseImageAlignment(element.getAttribute("data-image-align")),
|
||||
renderHTML: (attributes) => {
|
||||
const alignment = parseImageAlignment(attributes.alignment);
|
||||
const width = parseImageWidth(attributes.width);
|
||||
return alignment ? {
|
||||
"data-image-align": alignment,
|
||||
...(width === null ? {} : { "data-width": String(width) }),
|
||||
style: imageAlignmentStyle(alignment, width),
|
||||
} : {};
|
||||
},
|
||||
},
|
||||
} }];
|
||||
},
|
||||
});
|
||||
|
||||
const escapeAttribute = (value: string) => value.replaceAll("&", "&")
|
||||
.replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
||||
const decodeAttribute = (value: string) => value.replace(/"/gi, '"').replace(/'/g, "'")
|
||||
.replace(/</gi, "<").replace(/>/gi, ">").replace(/&/gi, "&");
|
||||
|
||||
export const readImageHtmlAttribute = (source: string, name: string) => {
|
||||
const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(source);
|
||||
return decodeAttribute(match?.[1] ?? match?.[2] ?? match?.[3] ?? "");
|
||||
};
|
||||
|
||||
const supportedImageSource = (source: string) => {
|
||||
// oxlint-disable-next-line no-control-regex -- do not accept executable or ambiguous URL schemes
|
||||
if (!source || /[\u0000-\u0020]/.test(source)) return false;
|
||||
return /^(?:https?:\/\/|edgeever-resource:\/\/|edgeever-staged:\/\/|\/(?!\/))/i.test(source);
|
||||
};
|
||||
|
||||
/** HTML keeps the alignment useful in exports outside EdgeEver, too. */
|
||||
export const alignedImageToHtml = (node: JSONContent): string => {
|
||||
const attrs = node.attrs ?? {};
|
||||
const alignment = parseImageAlignment(attrs.alignment) ?? "left";
|
||||
const width = parseImageWidth(attrs.width);
|
||||
const attribute = (key: string, value: unknown) => `${key}="${escapeAttribute(String(value ?? ""))}"`;
|
||||
const values = [attribute("src", attrs.src), attribute("alt", attrs.alt)];
|
||||
if (attrs.title) values.push(attribute("title", attrs.title));
|
||||
values.push(attribute("data-image-align", alignment));
|
||||
if (width !== null) values.push(attribute("width", `${width}%`));
|
||||
const style = imageAlignmentStyle(alignment, width);
|
||||
values.push(attribute("style", style));
|
||||
return `<img ${values.join(" ")} />`;
|
||||
};
|
||||
|
||||
export const EdgeEverImage = Image.extend({
|
||||
renderMarkdown(node, helpers, context) {
|
||||
return parseImageAlignment(node.attrs?.alignment)
|
||||
? alignedImageToHtml(node)
|
||||
: this.parent?.(node, helpers, context) ?? "";
|
||||
},
|
||||
parseMarkdown(token, helpers) {
|
||||
return token.imageAttributes
|
||||
? helpers.createNode("image", token.imageAttributes)
|
||||
: this.parent?.(token, helpers) ?? [];
|
||||
},
|
||||
markdownTokenizer: {
|
||||
name: "image",
|
||||
level: "block",
|
||||
start: (source) => source.search(/<img\b/i),
|
||||
tokenize(source) {
|
||||
const match = /^<img\b(?:[^"'>]|"[^"]*"|'[^']*')*\/?>[ \t]*(?:\n|$)/i.exec(source);
|
||||
if (!match) return undefined;
|
||||
const tag = match[0];
|
||||
const alignment = parseImageAlignment(readImageHtmlAttribute(tag, "data-image-align"));
|
||||
const src = readImageHtmlAttribute(tag, "src");
|
||||
if (!alignment || !supportedImageSource(src)) return undefined;
|
||||
return { type: "image", raw: tag, imageAttributes: {
|
||||
src, alignment,
|
||||
alt: readImageHtmlAttribute(tag, "alt") || null,
|
||||
title: readImageHtmlAttribute(tag, "title") || null,
|
||||
width: parseImageWidth(readImageHtmlAttribute(tag, "width")),
|
||||
} };
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content";
|
||||
import { parseImageAlignment } from "./image-alignment";
|
||||
|
||||
export const IMAGE_GALLERY_NODE_TYPE = "edgeeverImageGallery" as const;
|
||||
export const IMAGE_GALLERY_LAYOUTS = ["auto", "2", "3", "1"] as const;
|
||||
@@ -81,6 +82,12 @@ export const groupConsecutiveImagesIntoGalleries = (nodes: TiptapNode[]): Tiptap
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
// Explicitly positioned images remain standalone during Markdown import.
|
||||
if (node.type === "image" && parseImageAlignment(node.attrs?.alignment)) {
|
||||
flushImages();
|
||||
grouped.push(node);
|
||||
continue;
|
||||
}
|
||||
if (node.type === "image") {
|
||||
images.push(node);
|
||||
continue;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./content";
|
||||
export * from "./document-extensions";
|
||||
export * from "./image-alignment";
|
||||
export * from "./companion";
|
||||
export * from "./public-demo";
|
||||
export * from "./ai-assistant";
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
test.use({ locale: "en-US" });
|
||||
|
||||
const selectImage = async (figure: Locator) => {
|
||||
const image = figure.locator("img");
|
||||
await expect.poll(() => image.evaluate((element) => element.complete && element.naturalWidth > 0)).toBe(true);
|
||||
await image.click();
|
||||
await expect(figure).toHaveClass(/is-selected/);
|
||||
};
|
||||
|
||||
const expectPosition = async (figure: Locator, alignment: "left" | "center" | "right") => {
|
||||
await expect(figure).toHaveAttribute("data-image-align", alignment);
|
||||
await expect.poll(() => figure.evaluate((element, value) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const parent = element.parentElement!.getBoundingClientRect();
|
||||
const expectedLeft = value === "left" ? parent.left : value === "center"
|
||||
? parent.left + (parent.width - bounds.width) / 2 : parent.right - bounds.width;
|
||||
return Math.abs(bounds.left - expectedLeft);
|
||||
}, alignment)).toBeLessThan(2);
|
||||
};
|
||||
|
||||
test("positions individual images and preserves alignment through resize, undo, Markdown edits and reload", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("edgeever.locale.preference", "en-US");
|
||||
localStorage.setItem("edgeever.editorToolbarExpanded", "true");
|
||||
});
|
||||
await page.route("https://example.test/alignment.png", (route) => route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="280"><rect width="640" height="280" fill="#dbeafe"/><text x="320" y="155" text-anchor="middle" font-size="40" fill="#1e3a8a">Alignment test</text></svg>',
|
||||
}));
|
||||
const { notebooks } = await (await page.request.get("/api/v1/notebooks")).json() as { notebooks: Array<{ id: string }> };
|
||||
const title = `Image alignment ${Date.now()}`;
|
||||
const created = await page.request.post("/api/v1/memos", { data: {
|
||||
notebookId: notebooks[0].id, title,
|
||||
contentMarkdown: "\n\nBetween images.\n\n",
|
||||
} });
|
||||
expect(created.status()).toBe(201);
|
||||
const { memo } = await created.json() as { memo: { id: string } };
|
||||
const readMemo = async () => (await (await page.request.get(`/api/v1/memos/${memo.id}`)).json()).memo;
|
||||
try {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "All notes", exact: true }).click();
|
||||
await page.getByPlaceholder("Search notes").fill(title);
|
||||
await page.locator(`[data-memo-id="${memo.id}"]`).locator("button").first().click();
|
||||
const rich = page.locator(".ProseMirror[contenteditable='true']");
|
||||
const images = rich.locator("figure.edgeever-image-node");
|
||||
const first = images.nth(0);
|
||||
const second = images.nth(1);
|
||||
await expect(images).toHaveCount(2);
|
||||
await expectPosition(first, "left");
|
||||
await selectImage(first);
|
||||
await first.getByRole("button", { name: "Center image", exact: true }).click();
|
||||
await expectPosition(first, "center");
|
||||
await first.getByRole("button", { name: "Small", exact: true }).click();
|
||||
await expect(first).toHaveAttribute("data-width", "35");
|
||||
await expectPosition(first, "center");
|
||||
|
||||
// Starting a drag must not resize the image; moving the handle changes its
|
||||
// width without losing its center anchor.
|
||||
const handle = first.getByRole("button", { name: "Drag to resize image width" });
|
||||
await handle.hover();
|
||||
const point = await handle.boundingBox();
|
||||
expect(point).not.toBeNull();
|
||||
await page.mouse.move(point!.x + point!.width / 2, point!.y + point!.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(first).toHaveAttribute("data-width", "35");
|
||||
await page.mouse.move(point!.x + point!.width / 2 + 25, point!.y + point!.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => Number(await first.getAttribute("data-width"))).toBeGreaterThan(35);
|
||||
const resizedWidth = Number(await first.getAttribute("data-width"));
|
||||
await expectPosition(first, "center");
|
||||
|
||||
await selectImage(second);
|
||||
await second.getByRole("button", { name: "Align image right", exact: true }).click();
|
||||
await expectPosition(second, "right");
|
||||
await expectPosition(first, "center");
|
||||
await page.keyboard.press("ControlOrMeta+z");
|
||||
await expectPosition(second, "left");
|
||||
await page.keyboard.press("ControlOrMeta+Shift+z");
|
||||
await expectPosition(second, "right");
|
||||
await page.keyboard.press("ControlOrMeta+s");
|
||||
await expect.poll(async () => (await readMemo()).contentJson.content[2].attrs.alignment).toBe("right");
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+/");
|
||||
const source = page.locator(".cm-content");
|
||||
await expect(source).toContainText('data-image-align="center"');
|
||||
await page.keyboard.press("ControlOrMeta+End");
|
||||
await page.keyboard.insertText("\n\nSource edit preserves alignment.");
|
||||
await page.keyboard.press("ControlOrMeta+/");
|
||||
await expect(rich).toContainText("Source edit preserves alignment.");
|
||||
await expectPosition(first, "center");
|
||||
await expectPosition(second, "right");
|
||||
await expect(first).toHaveAttribute("data-width", String(resizedWidth));
|
||||
await page.keyboard.press("ControlOrMeta+s");
|
||||
await expect.poll(async () => (await readMemo()).contentMarkdown).toContain("Source edit preserves alignment.");
|
||||
const reloadedMemo = page.waitForResponse((response) => response.url().endsWith(`/api/v1/memos/${memo.id}`) && response.request().method() === "GET" && response.ok());
|
||||
await page.reload();
|
||||
await reloadedMemo;
|
||||
await expect(page.getByRole("status", { name: /^Synced/ })).toBeVisible();
|
||||
await expectPosition(first, "center");
|
||||
await expectPosition(second, "right");
|
||||
await expect(first).toHaveAttribute("data-width", String(resizedWidth));
|
||||
|
||||
// Returning to Left is a per-image change, with an accessible active state.
|
||||
await rich.focus();
|
||||
await selectImage(first);
|
||||
await first.getByRole("button", { name: "Align image left", exact: true }).click();
|
||||
await expectPosition(first, "left");
|
||||
await expect(first.getByRole("button", { name: "Align image left", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await expectPosition(second, "right");
|
||||
await page.getByRole("button", { name: /^Enable reading protection/ }).click();
|
||||
await expect(page.getByRole("group", { name: "Image alignment", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: /^Disable reading protection and edit/ }).click();
|
||||
} finally {
|
||||
await page.request.delete(`/api/v1/memos/${memo.id}`, { timeout: 5_000 }).catch(() => {});
|
||||
await page.request.delete(`/api/v1/memos/${memo.id}?permanent=1`, { timeout: 5_000 }).catch(() => {});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user