From e90e93981518ae447a62da8986bc588c36306ada Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 21 Jul 2026 03:50:56 +0800 Subject: [PATCH] feat(mobile): support image size presets Add the four desktop-compatible image width presets to the native app and share width configuration across clients. --- .../src/components/LocalTiptapEditor.tsx | 160 +++++++++++++++++- .../mobile-image-upload-placeholder.test.ts | 4 +- apps/web/src/components/EditorPane.tsx | 40 ++--- packages/shared/package.json | 1 + packages/shared/src/image-display.test.mjs | 27 +++ packages/shared/src/image-display.ts | 28 +++ packages/shared/src/index.ts | 1 + packages/shared/src/mobile-editor.test.mjs | 4 + packages/shared/src/mobile-editor.ts | 24 +++ 9 files changed, 254 insertions(+), 35 deletions(-) create mode 100644 packages/shared/src/image-display.test.mjs create mode 100644 packages/shared/src/image-display.ts diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index b96bdc7c..8a031e34 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -5,10 +5,18 @@ import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import type { TiptapDoc } from "@edgeever/shared"; +import { + DEFAULT_IMAGE_WIDTH_PERCENT, + IMAGE_WIDTH_PRESETS, + clampImageWidth, + parseImageWidth, +} from "@edgeever/shared/image-display"; import { MOBILE_EDITOR_ACTIVE_FLAGS, MOBILE_EDITOR_TOOLBAR_ACTIONS, getMobileEditorInputAttributes, + getMobileEditorImageScaleLabel, + getMobileEditorImageWidthPresetLabel, getMobileEditorPlaceholder, getMobileEditorToolbarActionLabel, getMobileEditorToolbarLabel, @@ -436,13 +444,105 @@ const resolveUrl = (source: string, baseUrl: string) => { return `${baseUrl.replace(/\/+$/, "")}${source}`; }; +const applyImageWidth = ( + element: HTMLElement, + attributes: Record +): number => { + const width = parseImageWidth(attributes.width) ?? DEFAULT_IMAGE_WIDTH_PERCENT; + element.style.width = `${width}%`; + element.dataset.width = String(width); + return width; +}; + +const createMobileImageSizeControls = ( + locale: "zh-CN" | "en-US", + updateWidth: (width: number) => void +) => { + const controls = document.createElement("div"); + controls.className = "edgeever-image-size-controls"; + controls.contentEditable = "false"; + controls.hidden = true; + controls.setAttribute("role", "group"); + controls.setAttribute("aria-label", getMobileEditorImageScaleLabel(locale)); + + const buttons = IMAGE_WIDTH_PRESETS.map((preset) => { + const button = document.createElement("button"); + const label = getMobileEditorImageWidthPresetLabel(preset.id, locale); + button.type = "button"; + button.className = "edgeever-image-size-button"; + button.setAttribute("aria-label", `${label},${preset.width}%`); + button.setAttribute("aria-pressed", "false"); + + const labelNode = document.createElement("span"); + labelNode.textContent = label; + const percentNode = document.createElement("span"); + percentNode.className = "edgeever-image-size-percent"; + percentNode.textContent = `${preset.width}%`; + button.append(labelNode, percentNode); + + button.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + updateWidth(preset.width); + }); + controls.append(button); + return { button, width: preset.width }; + }); + + return { + dom: controls, + setActiveWidth: (width: number) => { + for (const item of buttons) { + const active = item.width === width; + item.button.classList.toggle("is-active", active); + item.button.setAttribute("aria-pressed", String(active)); + } + }, + setVisible: (visible: boolean) => { + controls.hidden = !visible; + }, + }; +}; + const createProtectedImageExtension = ( baseUrl: string, locale: "zh-CN" | "en-US", loadResource: (source: string) => Promise ) => Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: null, + parseHTML: (element) => + parseImageWidth(element.getAttribute("data-width") ?? element.getAttribute("width") ?? element.style.width), + renderHTML: (attributes) => { + const width = parseImageWidth(attributes.width); + return width ? { "data-width": String(width), style: `width: ${width}%` } : {}; + }, + }, + }; + }, addNodeView() { - return ({ node }) => { + return ({ editor, getPos, node }) => { + const updateWidth = (width: number) => { + const position = getPos(); + if (typeof position !== "number") { + return; + } + editor + .chain() + .focus() + .setNodeSelection(position) + .updateAttributes("image", { width: clampImageWidth(width) }) + .run(); + }; + const sizeControls = createMobileImageSizeControls(locale, updateWidth); + if (isMobileImageUploadPlaceholderSource(node.attrs.src)) { const placeholder = document.createElement("div"); placeholder.className = "edgeever-image-upload-placeholder"; @@ -467,10 +567,13 @@ const createProtectedImageExtension = ( if (previewSource) { placeholder.append(preview); } - placeholder.append(overlay); + placeholder.append(overlay, sizeControls.dom); + sizeControls.setActiveWidth(applyImageWidth(placeholder, node.attrs)); let requestId = 0; let renderedSource = String(node.attrs.src ?? ""); + let completed = false; + let selected = false; const applyImageAttributes = (attributes: Record) => { preview.alt = String(attributes.alt ?? ""); @@ -497,6 +600,11 @@ const createProtectedImageExtension = ( preview.className = ""; overlay.remove(); placeholder.className = "edgeever-image-upload-result"; + completed = true; + if (selected) { + placeholder.classList.add("is-selected"); + sizeControls.setVisible(true); + } placeholder.removeAttribute("role"); placeholder.removeAttribute("aria-live"); }; @@ -534,6 +642,7 @@ const createProtectedImageExtension = ( return false; } const source = String(updatedNode.attrs.src ?? ""); + sizeControls.setActiveWidth(applyImageWidth(placeholder, updatedNode.attrs)); if (isMobileImageUploadPlaceholderSource(source)) { return true; } @@ -544,13 +653,29 @@ const createProtectedImageExtension = ( loadCompletedImage(updatedNode.attrs); return true; }, + selectNode: () => { + selected = true; + if (completed) { + placeholder.classList.add("is-selected"); + sizeControls.setVisible(true); + } + }, + deselectNode: () => { + selected = false; + placeholder.classList.remove("is-selected"); + sizeControls.setVisible(false); + }, destroy: () => { requestId += 1; }, }; } + const wrapper = document.createElement("figure"); + wrapper.className = "edgeever-image-node"; + wrapper.contentEditable = "false"; const image = document.createElement("img"); + wrapper.append(image, sizeControls.dom); const imageType = node.type; let requestId = 0; @@ -560,6 +685,7 @@ const createProtectedImageExtension = ( const renderNode = (attributes: Record) => { clearRequest(); + sizeControls.setActiveWidth(applyImageWidth(wrapper, attributes)); const source = String(attributes.src ?? ""); const alt = String(attributes.alt ?? ""); const title = String(attributes.title ?? ""); @@ -594,7 +720,7 @@ const createProtectedImageExtension = ( renderNode(node.attrs); return { - dom: image, + dom: wrapper, update: (updatedNode) => { if (updatedNode.type !== imageType) { return false; @@ -602,6 +728,14 @@ const createProtectedImageExtension = ( renderNode(updatedNode.attrs); return true; }, + selectNode: () => { + wrapper.classList.add("is-selected"); + sizeControls.setVisible(true); + }, + deselectNode: () => { + wrapper.classList.remove("is-selected"); + sizeControls.setVisible(false); + }, destroy: clearRequest, }; }; @@ -642,7 +776,12 @@ const insertImageUploadPlaceholder = ( editor.chain().command(({ tr, dispatch }) => { const from = Math.min(selection?.from ?? tr.selection.from, tr.doc.content.size); const to = Math.min(Math.max(selection?.to ?? tr.selection.to, from), tr.doc.content.size); - tr.replaceRangeWith(from, to, imageType.create({ alt, src: source, title: previewDataUrl })); + tr.replaceRangeWith(from, to, imageType.create({ + alt, + src: source, + title: previewDataUrl, + width: DEFAULT_IMAGE_WIDTH_PERCENT, + })); tr.setMeta(TRANSIENT_IMAGE_UPLOAD_META, true); dispatch?.(tr); return true; @@ -704,11 +843,18 @@ const getEditorStyles = (theme: "light" | "dark") => ` .edgeever-editor-content code { border-radius: 4px; padding: 2px 4px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; } .edgeever-editor-content pre code { padding: 0; background: transparent; } .edgeever-editor-content img { display: block; max-width: 100%; height: auto; margin: 14px auto; border-radius: 10px; } - .edgeever-image-upload-placeholder { position: relative; min-height: 112px; margin: 14px 0; overflow: hidden; border-radius: 10px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; } + .edgeever-image-upload-placeholder { position: relative; max-width: 100%; min-height: 112px; margin: 14px auto; overflow: hidden; border-radius: 10px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; } .edgeever-image-upload-preview { display: block; width: 100%; max-height: 360px; margin: 0 !important; object-fit: contain; border-radius: 10px; } .edgeever-image-upload-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; background: rgba(15, 23, 42, 0.38); color: #fff; font-size: 14px; font-weight: 600; text-shadow: 0 1px 2px rgba(15, 23, 42, 0.45); } - .edgeever-image-upload-result { margin: 14px 0; } - .edgeever-image-upload-result > img { margin: 0 auto; } + .edgeever-image-node, .edgeever-image-upload-result { position: relative; display: block; max-width: 100%; margin: 14px auto; line-height: 0; } + .edgeever-image-node > img, .edgeever-image-upload-result > img { width: 100%; margin: 0; } + .edgeever-image-node.is-selected > img, .edgeever-image-upload-result.is-selected > img { outline: 2px solid #0f766e; outline-offset: 3px; } + .edgeever-image-size-controls { position: absolute; left: 50%; bottom: 8px; z-index: 2; display: flex; width: max-content; max-width: calc(100vw - 40px); align-items: center; gap: 3px; transform: translateX(-50%); border: 1px solid ${theme === "dark" ? "#475569" : "#bbf7d0"}; border-radius: 9px; padding: 4px; background: ${theme === "dark" ? "rgba(15, 23, 42, 0.96)" : "rgba(255, 255, 255, 0.96)"}; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.2); line-height: 1.15; } + .edgeever-image-size-controls[hidden] { display: none; } + .edgeever-image-size-button { display: inline-flex; min-width: 52px; min-height: 44px; appearance: none; flex-direction: column; align-items: center; justify-content: center; gap: 2px; border: 0; border-radius: 7px; padding: 4px 7px; background: transparent; color: ${theme === "dark" ? "#cbd5e1" : "#475569"}; font: inherit; font-size: 12px; font-weight: 700; } + .edgeever-image-size-button.is-active { background: ${theme === "dark" ? "#134e4a" : "#ccfbf1"}; color: ${theme === "dark" ? "#99f6e4" : "#0f766e"}; } + .edgeever-image-size-percent { color: ${theme === "dark" ? "#94a3b8" : "#94a3b8"}; font-size: 10px; font-weight: 600; } + .edgeever-image-size-button.is-active .edgeever-image-size-percent { color: inherit; } .edgeever-image-upload-spinner { width: 18px; height: 18px; border: 2px solid ${theme === "dark" ? "#475569" : "#cbd5e1"}; border-top-color: #0f766e; border-radius: 999px; animation: edgeever-image-upload-spin 0.8s linear infinite; } @keyframes edgeever-image-upload-spin { to { transform: rotate(360deg); } } .edgeever-editor-content hr { margin: 24px 0; border: 0; border-top: 1px solid #cbd5e1; } diff --git a/apps/mobile/src/lib/mobile-image-upload-placeholder.test.ts b/apps/mobile/src/lib/mobile-image-upload-placeholder.test.ts index a5b0a710..0036c89a 100644 --- a/apps/mobile/src/lib/mobile-image-upload-placeholder.test.ts +++ b/apps/mobile/src/lib/mobile-image-upload-placeholder.test.ts @@ -11,7 +11,7 @@ test("removes transient image upload placeholders before a note is persisted", ( content: [ { type: "paragraph", content: [{ type: "text", text: "before" }] }, { type: "image", attrs: { alt: "图片上传中…", src: placeholderSource } }, - { type: "image", attrs: { alt: "done", src: "/api/v1/resources/res_1" } }, + { type: "image", attrs: { alt: "done", src: "/api/v1/resources/res_1", width: 50 } }, ], }); @@ -19,7 +19,7 @@ test("removes transient image upload placeholders before a note is persisted", ( type: "doc", content: [ { type: "paragraph", content: [{ type: "text", text: "before" }] }, - { type: "image", attrs: { alt: "done", src: "/api/v1/resources/res_1" } }, + { type: "image", attrs: { alt: "done", src: "/api/v1/resources/res_1", width: 50 } }, ], }); }); diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index fa14673e..744bb0d3 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -55,7 +55,20 @@ import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog"; import { api } from "@/lib/api"; import { consumeStandaloneMobileEditorReturn, openStandaloneMobileEditor } from "@/lib/mobile-editor"; import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; -import { docToMarkdown, markdownToDoc, type Notebook, type MemoDetail, type MemoEditSession, type TiptapDoc } from "@edgeever/shared"; +import { + docToMarkdown, + markdownToDoc, + type Notebook, + type MemoDetail, + type MemoEditSession, + type TiptapDoc, +} from "@edgeever/shared"; +import { + DEFAULT_IMAGE_WIDTH_PERCENT, + IMAGE_WIDTH_PRESETS, + clampImageWidth, + parseImageWidth, +} from "@edgeever/shared/image-display"; import { codeBlockLowlight } from "@/lib/code-block"; import { compressImageForUpload } from "@/lib/image-compression"; import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; @@ -69,15 +82,6 @@ const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/g const MOBILE_EDITOR_QUERY = "(max-width: 639px)"; const EDITOR_AUTO_SAVE_DELAY_MS = 1200; const MOBILE_DRAFT_PERSIST_DELAY_MS = 800; -const DEFAULT_IMAGE_WIDTH_PERCENT = 72; -const MIN_IMAGE_WIDTH_PERCENT = 25; -const MAX_IMAGE_WIDTH_PERCENT = 100; -const IMAGE_WIDTH_PRESETS = [ - { width: 35, labelKey: "editor.imageSizeSmall" }, - { width: 50, labelKey: "editor.imageSizeMedium" }, - { width: 72, labelKey: "editor.imageSizeLarge" }, - { width: 100, labelKey: "editor.imageSizeFull" }, -] as const; type NoteSearchMatch = { from: number; @@ -226,22 +230,6 @@ const getImageFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => { return files.filter((file) => SUPPORTED_PASTE_IMAGE_TYPES.has(file.type)); }; -const clampImageWidth = (width: number) => - Math.min(MAX_IMAGE_WIDTH_PERCENT, Math.max(MIN_IMAGE_WIDTH_PERCENT, Math.round(width))); - -const parseImageWidth = (value: unknown) => { - if (typeof value === "number" && Number.isFinite(value)) { - return clampImageWidth(value); - } - - if (typeof value !== "string") { - return null; - } - - const match = /(\d+(?:\.\d+)?)/.exec(value); - return match ? clampImageWidth(Number(match[1])) : null; -}; - const ResizableImageNodeView = ({ editor, node, selected, updateAttributes }: NodeViewProps) => { const { t } = useTranslation(); const wrapperRef = useRef(null); diff --git a/packages/shared/package.json b/packages/shared/package.json index 05601c25..1e5b34b2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./image-display": "./src/image-display.ts", "./i18n": "./src/i18n/index.ts", "./mobile-editor": "./src/mobile-editor.ts", "./mobile-ui": "./src/mobile-ui.ts" diff --git a/packages/shared/src/image-display.test.mjs b/packages/shared/src/image-display.test.mjs new file mode 100644 index 00000000..f3064059 --- /dev/null +++ b/packages/shared/src/image-display.test.mjs @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_IMAGE_WIDTH_PERCENT, + IMAGE_WIDTH_PRESETS, + clampImageWidth, + parseImageWidth, +} from "./image-display.ts"; + +describe("shared image display widths", () => { + test("keeps the four editor presets stable across clients", () => { + expect(DEFAULT_IMAGE_WIDTH_PERCENT).toBe(72); + expect(IMAGE_WIDTH_PRESETS.map(({ id, width }) => [id, width])).toEqual([ + ["small", 35], + ["medium", 50], + ["large", 72], + ["full", 100], + ]); + }); + + test("parses persisted widths and clamps arbitrary resize values", () => { + expect(parseImageWidth("width: 50%")).toBe(50); + expect(parseImageWidth("120")).toBe(100); + expect(parseImageWidth(null)).toBeNull(); + expect(clampImageWidth(24.6)).toBe(25); + expect(clampImageWidth(71.6)).toBe(72); + }); +}); diff --git a/packages/shared/src/image-display.ts b/packages/shared/src/image-display.ts new file mode 100644 index 00000000..f650fb1d --- /dev/null +++ b/packages/shared/src/image-display.ts @@ -0,0 +1,28 @@ +export const DEFAULT_IMAGE_WIDTH_PERCENT = 72; +export const MIN_IMAGE_WIDTH_PERCENT = 25; +export const MAX_IMAGE_WIDTH_PERCENT = 100; + +export const IMAGE_WIDTH_PRESETS = [ + { id: "small", width: 35, labelKey: "editor.imageSizeSmall" }, + { id: "medium", width: 50, labelKey: "editor.imageSizeMedium" }, + { id: "large", width: 72, labelKey: "editor.imageSizeLarge" }, + { id: "full", width: 100, labelKey: "editor.imageSizeFull" }, +] as const; + +export type ImageWidthPresetId = (typeof IMAGE_WIDTH_PRESETS)[number]["id"]; + +export const clampImageWidth = (width: number): number => + Math.min(MAX_IMAGE_WIDTH_PERCENT, Math.max(MIN_IMAGE_WIDTH_PERCENT, Math.round(width))); + +export const parseImageWidth = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) { + return clampImageWidth(value); + } + + if (typeof value !== "string") { + return null; + } + + const match = /(\d+(?:\.\d+)?)/.exec(value); + return match ? clampImageWidth(Number(match[1])) : null; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2d4a2397..9f8ee6fc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export * from "./content"; export * from "./backup"; export * from "./github-feedback"; +export * from "./image-display"; export * from "./memo-safety"; export * from "./notebooks"; export * from "./revision-diff"; diff --git a/packages/shared/src/mobile-editor.test.mjs b/packages/shared/src/mobile-editor.test.mjs index d9601143..7f00247c 100644 --- a/packages/shared/src/mobile-editor.test.mjs +++ b/packages/shared/src/mobile-editor.test.mjs @@ -3,6 +3,8 @@ import { MOBILE_EDITOR_ACTIVE_FLAGS, MOBILE_EDITOR_TOOLBAR_ACTIONS, getMobileEditorInputAttributes, + getMobileEditorImageScaleLabel, + getMobileEditorImageWidthPresetLabel, getMobileEditorPlaceholder, getMobileEditorToolbarActionLabel, getMobileEditorToolbarLabel, @@ -27,6 +29,8 @@ describe("mobile editor contract", () => { expect(getMobileEditorPlaceholder("en-US")).toBe("Start writing..."); expect(getMobileEditorToolbarLabel("zh-CN")).toBe("编辑器工具栏"); expect(getMobileEditorToolbarActionLabel("bulletList", "en-US")).toBe("Bullet list"); + expect(getMobileEditorImageScaleLabel("zh-CN")).toBe("图片显示尺寸"); + expect(getMobileEditorImageWidthPresetLabel("medium", "en-US")).toBe("Medium"); }); test("keeps mobile typing assistance enabled", () => { diff --git a/packages/shared/src/mobile-editor.ts b/packages/shared/src/mobile-editor.ts index fe29f62b..39f8876c 100644 --- a/packages/shared/src/mobile-editor.ts +++ b/packages/shared/src/mobile-editor.ts @@ -1,3 +1,5 @@ +import type { ImageWidthPresetId } from "./image-display"; + export type MobileEditorLocale = "zh-CN" | "en-US"; export type MobileEditorToolbarActionId = @@ -35,6 +37,13 @@ const MOBILE_EDITOR_COPY = { blockquote: "引用", horizontalRule: "分割线", }, + imageScale: "图片显示尺寸", + imageSizes: { + small: "较小", + medium: "适中", + large: "较大", + full: "铺满", + }, }, "en-US": { placeholder: "Start writing...", @@ -46,6 +55,13 @@ const MOBILE_EDITOR_COPY = { blockquote: "Quote", horizontalRule: "Horizontal rule", }, + imageScale: "Image display size", + imageSizes: { + small: "Small", + medium: "Medium", + large: "Large", + full: "Full", + }, }, } as const; @@ -60,6 +76,14 @@ export const getMobileEditorToolbarActionLabel = ( locale: MobileEditorLocale ): string => MOBILE_EDITOR_COPY[locale].actions[action]; +export const getMobileEditorImageScaleLabel = (locale: MobileEditorLocale): string => + MOBILE_EDITOR_COPY[locale].imageScale; + +export const getMobileEditorImageWidthPresetLabel = ( + preset: ImageWidthPresetId, + locale: MobileEditorLocale +): string => MOBILE_EDITOR_COPY[locale].imageSizes[preset]; + export const getMobileEditorInputAttributes = (className: string): Record => ({ autocapitalize: "sentences", autocomplete: "on",