feat: enable native mobile image gallery editing (#342)

This commit is contained in:
tianma-if committed 2026-09-03 01:26:14 +08:00
1 parent f3034fbf0e
commit 3aea120ea6
17 files changed
+1821 -1006

No files matched your search

@@ -10,6 +10,7 @@ enum ImagePickerResult {
case cancelled
case failed(String)
case picked(Data, filename: String)
case pickedImages([(data: Data, filename: String)])
}
enum CameraCaptureNextStep: Equatable {
@@ -45,7 +46,8 @@ struct SystemImagePicker: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = 1
config.selectionLimit = 20
config.selection = .ordered
config.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
@@ -68,18 +70,22 @@ struct SystemImagePicker: UIViewControllerRepresentable {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard !settled else { return }
guard let provider = results.first?.itemProvider else {
guard !results.isEmpty else {
settled = true
DispatchQueue.main.async { self.onFinish(.cancelled) }
return
}
Task {
do {
let (data, name) = try await Self.loadImage(from: provider)
var images: [(data: Data, filename: String)] = []
for result in results {
let (data, name) = try await Self.loadImage(from: result.itemProvider)
images.append((data: data, filename: name))
}
await MainActor.run {
guard !self.settled else { return }
self.settled = true
self.onFinish(.picked(data, filename: name))
self.onFinish(.pickedImages(images))
}
} catch {
await MainActor.run {
@@ -383,6 +383,24 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
return true
}
/// Group only successfully inserted images from this picker batch.
func groupImages(sources: [String]) async -> Bool {
guard ready, session?.mode == .editor,
let json = try? JSONEncoder().encode(sources) else { return false }
let encoded = json.base64EncodedString()
let js = """
(function(){
const bytes = Uint8Array.from(atob('\(encoded)'), c => c.charCodeAt(0));
return window.EdgeEverEditor.groupImages(JSON.parse(new TextDecoder().decode(bytes)));
})();
"""
return await withCheckedContinuation { continuation in
webView.evaluateJavaScript(js) { result, _ in
continuation.resume(returning: (result as? Bool) ?? false)
}
}
}
/// Read current editor markdown + JSON after a mutation (avoids racing the async bridge onChange).
func snapshotContent() async -> (markdown: String, json: String)? {
guard ready else { return nil }
@@ -39,6 +39,7 @@ struct MemoEditView: View {
@State private var showTagPicker = false
@State private var showImageSourcePicker = false
@State private var imagePickerRoute: ImagePickerRoute?
@State private var isImportingImageBatch = false
@State private var showCameraAccessAlert = false
@State private var cameraAccessCanOpenSettings = false
@State private var cameraAccessMessage = ""
@@ -72,7 +73,7 @@ struct MemoEditView: View {
private var isDirty: Bool { get { viewModel.isDirty } nonmutating set { viewModel.isDirty = newValue } }
private var isSaving: Bool { get { viewModel.isSaving } nonmutating set { viewModel.isSaving = newValue } }
private var isCreating: Bool { get { viewModel.isCreating } nonmutating set { viewModel.isCreating = newValue } }
private var isUploading: Bool { get { viewModel.isUploading } nonmutating set { viewModel.isUploading = newValue } }
private var isUploading: Bool { get { viewModel.isUploading || isImportingImageBatch } nonmutating set { viewModel.isUploading = newValue } }
private var editorReady: Bool { get { viewModel.editorReady } nonmutating set { viewModel.editorReady = newValue } }
private var suppressPersistence: Bool { get { viewModel.suppressPersistence } nonmutating set { viewModel.suppressPersistence = newValue } }
private var contentHydrated: Bool { get { viewModel.contentHydrated } nonmutating set { viewModel.contentHydrated = newValue } }
@@ -677,6 +678,8 @@ struct MemoEditView: View {
showUploadError = true
case .picked(let data, let filename):
Task { _ = await insertImageData(data, filename: filename) }
case .pickedImages(let images):
Task { await insertImageBatch(images) }
}
}
@@ -1190,7 +1193,27 @@ struct MemoEditView: View {
}
/// Upload bytes from the system PHPicker and insert into TipTap.
private func insertImageData(_ data: Data, filename: String) async -> Bool {
private func insertImageBatch(_ images: [(data: Data, filename: String)]) async {
guard !isUploading, !images.isEmpty else { return }
isImportingImageBatch = true
var sources: [String] = []
for image in images {
let succeeded = await insertImageData(image.data, filename: image.filename) { sources.append($0) }
if !succeeded { break }
}
if sources.count > 1 {
_ = await SharedTipTapRuntime.editor.groupImages(sources: sources)
await pullEditorSnapshotIfPossible()
}
isImportingImageBatch = false
if !sources.isEmpty {
editGeneration &+= 1
isDirty = true
await drainPendingSave()
}
}
private func insertImageData(_ data: Data, filename: String, onInserted: ((String) -> Void)? = nil) async -> Bool {
let succeeded = await viewModel.performUpload {
NSLog("MemoEditView insertImageData: start bytes=%d name=%@", data.count, filename)
let compress = env.preferences.useCompression
@@ -1242,6 +1265,7 @@ struct MemoEditView: View {
)
)
}
onInserted?(imageSrc)
// Snapshot TipTap JSON (order is authoritative). Only inject if the resource
// is truly missing — never append a second image node at document end.
await pullEditorSnapshotIfPossible()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17 -13
View File
@@ -36,6 +36,11 @@ import {
generateCardCss,
} from "@edgeever/shared/note-image-card";
import { createEdgeEverMathematics } from "./mathematics";
import { createImageInsertTransaction, createNativeImageGalleryView, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "@edgeever/shared/native-image-gallery";
const galleryStyle = document.createElement("style");
galleryStyle.textContent = NATIVE_IMAGE_GALLERY_CSS;
document.head.append(galleryStyle);
/** Keep in sync with packages/shared MergeDivider (iOS bundle cannot import monorepo shared). */
const MERGE_DIVIDER_MARKDOWN_MARKER = "<!-- edgeever:merge-divider -->";
@@ -91,6 +96,7 @@ const ImageGallery = Node.create({
content: "image+",
defining: true,
isolating: true,
addNodeView() { return createNativeImageGalleryView(() => locale); },
addAttributes() {
return {
layout: {
@@ -1302,6 +1308,7 @@ export type EdgeEverEditorAPI = {
exec: (actionId: string) => void;
beginImageUpload: (uploadId: string, previewDataUrl: string) => void;
completeImageUpload: (uploadId: string, imageUrl: string, alt: string) => void;
groupImages: (sources: string[]) => boolean;
cancelImageUpload: (uploadId: string) => void;
search: (query: string, requestedIndex: number) => void;
exportImage: (request: ImageExportRequest) => void;
@@ -1504,22 +1511,19 @@ const api: EdgeEverEditorAPI = {
},
completeImageUpload(uploadId, imageUrl, alt) {
// Always mutate ProseMirror via setImage — DOM-only attr writes do not persist.
// Native hydrate rewrites display src under file:// after emitChange.
editor
.chain()
.focus()
.setImage({ src: imageUrl, alt: alt || uploadId || "" })
.run();
const imgs = editorEl.querySelectorAll("img");
const last = imgs[imgs.length - 1] as HTMLImageElement | undefined;
if (last) {
last.dataset.originalSrc = imageUrl;
delete last.dataset.uploadId;
}
if (!editor.isEditable) return;
editor.view.dispatch(createImageInsertTransaction(editor.state, {
src: imageUrl, alt: alt || uploadId || "",
}));
emitChange(editor);
},
groupImages(sources) {
const grouped = groupUploadedImages(editor, sources);
if (grouped) emitChange(editor);
return grouped;
},
cancelImageUpload(uploadId) {
const img = editorEl.querySelector(`img[data-upload-id="${uploadId}"]`);
img?.remove();
@@ -1,8 +1,57 @@
import XCTest
import WebKit
@testable import EdgeEver
/// Ensures the TipTap editor HTML is actually shipped in the app (not the plain-text fallback).
final class EditorBundleTests: XCTestCase {
@MainActor
func testNativeImageBatchCreatesAnEditableGallery() async throws {
let url = try XCTUnwrap(TipTapWebView.Coordinator.packagedEditorHTMLURL())
let loaded = expectation(description: "editor bridge ready")
final class Loader: NSObject, WKScriptMessageHandler {
let loaded: XCTestExpectation
init(_ loaded: XCTestExpectation) { self.loaded = loaded }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if (message.body as? [String: Any])?["type"] as? String == "ready" { loaded.fulfill() }
}
}
let loader = Loader(loaded)
let config = WKWebViewConfiguration()
config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
config.userContentController.add(loader, name: "edgeever")
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: config)
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
await fulfillment(of: [loaded], timeout: 30)
let result = try await webView.evaluateJavaScript("""
(() => {
const api = window.EdgeEverEditor;
api.configure({mode:'editor', locale:'en-US'});
api.setDocumentFromJSON(JSON.stringify({type:'doc',content:[{type:'paragraph'}]}));
const images = ['green','red','blue'].map(color => 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20" fill="' + color + '"/></svg>'));
images.forEach((src, i) => api.completeImageUpload('test-' + i, src, 'photo-' + i));
const grouped = api.groupImages(images);
const toolbar = document.querySelector('.edgeever-native-gallery-toolbar');
toolbar?.querySelectorAll('button')[3].click();
const doc = JSON.parse(api.getDocument());
const gallery = doc.content.find(node => node.type === 'edgeeverImageGallery');
const controlsHidden = [...document.querySelectorAll('.edgeever-native-gallery-content .edgeever-image-size-controls')]
.every(node => getComputedStyle(node).display === 'none');
api.configure({mode:'viewer', locale:'en-US'});
return { grouped, layout:gallery?.attrs.layout, sources:gallery?.content.map(node => node.attrs.src),
orderPreserved:JSON.stringify(gallery?.content.map(node => node.attrs.src)) === JSON.stringify(images),
controlsHidden, viewerToolbarHidden:toolbar?.hidden };
})()
""") as? [String: Any]
XCTAssertEqual(result?["grouped"] as? Bool, true)
XCTAssertEqual(result?["layout"] as? String, "1")
XCTAssertEqual((result?["sources"] as? [String])?.count, 3)
XCTAssertEqual(result?["orderPreserved"] as? Bool, true)
XCTAssertEqual(result?["controlsHidden"] as? Bool, true)
XCTAssertEqual(result?["viewerToolbarHidden"] as? Bool, true)
config.userContentController.removeScriptMessageHandler(forName: "edgeever")
}
func testPackagedEditorHTMLIsPresentAndLarge() throws {
let url = TipTapWebView.Coordinator.packagedEditorHTMLURL()
XCTAssertNotNil(url, "EditorBundle/index.html must be in the app bundle so Markdown renders")
@@ -73,6 +73,7 @@ import {
generateCardCss,
} from "@edgeever/shared/note-image-card";
import { useDOMImperativeHandle, type DOMImperativeFactory, type DOMProps } from "expo/dom";
import { createImageInsertTransaction, createNativeImageGalleryView, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "@edgeever/shared/native-image-gallery";
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type Ref, type SetStateAction } from "react";
import {
createMobileImageUploadPlaceholderSource,
@@ -103,6 +104,7 @@ export interface LocalTiptapEditorRef extends DOMImperativeFactory {
beginImageUpload: (uploadId: DOMValue, previewDataUrl: DOMValue) => void;
cancelImageUpload: (uploadId: DOMValue) => void;
completeImageUpload: (uploadId: DOMValue, imageUrl: DOMValue, alt: DOMValue) => void;
finishImageBatch: (sources: DOMValue) => void;
appendAttachment: (attachmentUrl: DOMValue, filename: DOMValue, mimeType: DOMValue, byteSize: DOMValue) => void;
removeResource: (targetJson: DOMValue) => void;
renameResource: (targetJson: DOMValue, filename: DOMValue) => void;
@@ -694,7 +696,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
MergeDivider,
...createEdgeEverMathematics(),
mermaidCodeBlockExtension,
ImageGallery,
ImageGallery.extend({
addNodeView() { return createNativeImageGalleryView(() => props.locale); },
}),
protectedImageExtension,
searchHighlightExtension,
TableKit.configure({
@@ -841,6 +845,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
previewDataUrlValue,
pendingImageSelectionRef.current
);
// The initial selection is consumed once; subsequent batch images follow
// the previous placeholder instead of replacing it.
pendingImageSelectionRef.current = null;
}, [editor, isViewer, props.locale]);
const cancelImageUpload = useCallback((uploadIdValue: DOMValue) => {
@@ -862,6 +869,12 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
);
}, [editor, props.baseUrl]);
const finishImageBatch = useCallback((sources: DOMValue) => {
if (!editor || !Array.isArray(sources)) return;
groupUploadedImages(editor, sources.filter((source): source is string => typeof source === "string")
.map((source) => resolveUrl(source, props.baseUrl)));
}, [editor, props.baseUrl]);
const appendAttachment = useCallback((
attachmentUrlValue: DOMValue,
filenameValue: DOMValue,
@@ -1246,6 +1259,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
beginImageUpload,
cancelImageUpload,
completeImageUpload,
finishImageBatch,
appendAttachment,
setContent,
flush,
@@ -1264,7 +1278,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
pushAiStreamEvent,
exportImage,
}),
[appendAttachment, beginImageUpload, cancelImageUpload, completeImageUpload, editor, exportImage, flush, isViewer, pushAiStreamEvent, removeResource, renameResource, replaceAll, search, setContent]
[appendAttachment, beginImageUpload, cancelImageUpload, completeImageUpload, finishImageBatch, editor, exportImage, flush, isViewer, pushAiStreamEvent, removeResource, renameResource, replaceAll, search, setContent]
);
useEffect(() => {
@@ -2763,19 +2777,14 @@ const insertImageUploadPlaceholder = (
if (!imageType) {
return;
}
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({
const tr = createImageInsertTransaction(editor.state, {
alt,
src: source,
title: previewDataUrl,
width: DEFAULT_IMAGE_WIDTH_PERCENT,
}));
tr.setMeta(TRANSIENT_IMAGE_UPLOAD_META, true);
dispatch?.(tr);
return true;
}).run();
}, selection ?? editor.state.selection);
tr.setMeta(TRANSIENT_IMAGE_UPLOAD_META, true);
editor.view.dispatch(tr);
};
const replaceImageUploadPlaceholder = (
@@ -3181,6 +3190,7 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean }
[data-edgeever-image-gallery][data-image-gallery-layout="1"] { grid-template-columns: minmax(0, 1fr); }
[data-edgeever-image-gallery] > .edgeever-image-node, [data-edgeever-image-gallery] > img { width: 100% !important; min-width: 0; height: 100%; min-height: 112px; max-height: 220px; margin: 0 !important; overflow: hidden; border-radius: 10px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; }
[data-edgeever-image-gallery] > .edgeever-image-node > img, [data-edgeever-image-gallery] > img { width: 100%; height: 100%; min-height: 112px; max-height: 220px; object-fit: cover; }
${NATIVE_IMAGE_GALLERY_CSS}
.edgeever-image-node.is-selected > img, .edgeever-image-upload-result.is-selected > img { outline: 2px solid #0f766e; outline-offset: 3px; }
.edgeever-image-actions { position: absolute; right: 8px; bottom: 8px; z-index: 3; display: inline-flex; width: 42px; height: 42px; appearance: none; align-items: center; justify-content: center; border: 1px solid ${theme === "dark" ? "#475569" : "#cbd5e1"}; border-radius: 999px; background: ${theme === "dark" ? "rgba(15, 23, 42, 0.9)" : "rgba(255, 255, 255, 0.92)"}; color: ${theme === "dark" ? "#e2e8f0" : "#334155"}; font-size: 24px; font-weight: 700; line-height: 1; box-shadow: 0 3px 12px rgba(15, 23, 42, 0.2); }
.edgeever-image-actions[hidden] { display: none; }
@@ -6,19 +6,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Camera, FileText, Image as ImageIcon, X } from "../components/icons";
import { Alert, Pressable, Text } from "../components/LocalizedText";
import type { MobileImageUploadAsset } from "../lib/mobile-image-upload";
import { normalizeMobileImagePickerAsset } from "../lib/mobile-image-picker";
import { normalizeMobileImagePickerAssets } from "../lib/mobile-image-picker";
import { resolveMobileThemeStyles, useMobileTheme } from "../lib/mobile-theme";
type MobileEditorUploadSource = "camera" | "file" | "library";
type SourceResolver = (source: MobileEditorUploadSource | null) => void;
const NATIVE_PICKER_PRESENTATION_DELAY_MS = 120;
const getImagePickerAsset = (result: ImagePicker.ImagePickerResult): MobileImageUploadAsset | null => {
if (result.canceled || !result.assets[0]) {
return null;
}
return normalizeMobileImagePickerAsset(result.assets[0]);
};
const getImagePickerAssets = normalizeMobileImagePickerAssets;
const requestCameraAccess = async () => {
const current = await ImagePicker.getCameraPermissionsAsync();
@@ -44,17 +39,18 @@ const requestCameraAccess = async () => {
const pickWithImagePicker = async (source: "camera" | "library") => {
if (source === "camera" && !(await requestCameraAccess())) {
return null;
return [];
}
const options: ImagePicker.ImagePickerOptions = {
allowsEditing: false,
mediaTypes: ["images"],
quality: 1,
...(source === "library" ? { allowsMultipleSelection: true, selectionLimit: 20, orderedSelection: true } : {}),
};
const result = source === "camera"
? await ImagePicker.launchCameraAsync(options)
: await ImagePicker.launchImageLibraryAsync(options);
return getImagePickerAsset(result);
return getImagePickerAssets(result);
};
const pickWithDocumentPicker = async (): Promise<MobileImageUploadAsset | null> => {
@@ -83,7 +79,7 @@ const getPendingAndroidImage = async () => {
return null;
}
if ("canceled" in pending) {
return getImagePickerAsset(pending);
return getImagePickerAssets(pending);
}
throw new Error(pending.message || "系统未能恢复上次选择的图片,请重试");
};
@@ -170,23 +166,25 @@ export const useMobileEditorUploadAsset = () => {
setSourcePickerVisible(true);
}), []);
const pickUploadAsset = useCallback(async (): Promise<MobileImageUploadAsset | null> => {
const pickUploadAssets = useCallback(async (): Promise<MobileImageUploadAsset[]> => {
const pendingImage = await getPendingAndroidImage();
if (pendingImage) {
return pendingImage;
}
const source = await chooseSource();
if (!source) {
return null;
return [];
}
// Let the native source sheet finish dismissing before presenting another
// native controller. This avoids intermittent "already presenting" errors.
await new Promise((resolve) => setTimeout(resolve, NATIVE_PICKER_PRESENTATION_DELAY_MS));
return source === "file" ? pickWithDocumentPicker() : pickWithImagePicker(source);
if (source !== "file") return pickWithImagePicker(source);
const asset = await pickWithDocumentPicker();
return asset ? [asset] : [];
}, [chooseSource]);
return {
pickUploadAsset,
pickUploadAssets,
uploadSourcePicker: (
<MobileEditorUploadSourcePicker
onClose={() => settleSource(null)}
@@ -1,5 +1,15 @@
import { expect, test } from "bun:test";
import { normalizeMobileImagePickerAsset } from "./mobile-image-picker";
import { normalizeMobileImagePickerAsset, normalizeMobileImagePickerAssets } from "./mobile-image-picker";
test("keeps every picked image in selection order and handles cancellation", () => {
const assets = [
{ uri: "file:///first.png", fileName: "first.png" },
{ uri: "file:///second.jpg", fileName: "second.jpg" },
];
expect(normalizeMobileImagePickerAssets({ canceled: false, assets }).map((asset) => asset.name))
.toEqual(["first.png", "second.jpg"]);
expect(normalizeMobileImagePickerAssets({ canceled: true, assets: null })).toEqual([]);
});
test("normalizes an image-picker asset for the existing upload pipeline", () => {
expect(normalizeMobileImagePickerAsset({
@@ -25,6 +25,11 @@ const getFilenameExtension = (filename?: string | null) => {
const getTimestampedPhotoFilename = (timestamp: number) =>
`photo-${new Date(timestamp).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}.jpg`;
export const normalizeMobileImagePickerAssets = (result: {
canceled: boolean;
assets: MobileImagePickerAsset[] | null;
}): MobileImageUploadAsset[] => result.canceled ? [] : (result.assets ?? []).map((asset) => normalizeMobileImagePickerAsset(asset));
export const normalizeMobileImagePickerAsset = (
asset: MobileImagePickerAsset,
timestamp = Date.now()
@@ -4,6 +4,15 @@ import {
stripMobileImageUploadPlaceholders,
} from "./mobile-image-upload-placeholder";
test("cleans up empty or singleton galleries after removing failed upload placeholders", () => {
const pending = { type: "image", attrs: { src: createMobileImageUploadPlaceholderSource("batch") } };
const saved = { type: "image", attrs: { src: "/api/v1/resources/done/blob" } };
expect(stripMobileImageUploadPlaceholders({ type: "doc", content: [
{ type: "edgeeverImageGallery", content: [pending] },
{ type: "edgeeverImageGallery", content: [pending, saved] },
] }).content).toEqual([saved]);
});
test("removes transient image upload placeholders before a note is persisted", () => {
const placeholderSource = createMobileImageUploadPlaceholderSource("upload-1");
const result = stripMobileImageUploadPlaceholders({
@@ -1,4 +1,4 @@
import type { TiptapDoc, TiptapNode } from "@edgeever/shared";
import { normalizeImageGalleries, type TiptapDoc, type TiptapNode } from "@edgeever/shared";
const IMAGE_UPLOAD_PLACEHOLDER_PREFIX = "edgeever-image-upload://";
@@ -21,8 +21,8 @@ export const stripMobileImageUploadPlaceholders = (doc: TiptapDoc): TiptapDoc =>
});
const content = stripNodes(doc.content);
return {
return normalizeImageGalleries({
...doc,
content: content.length > 0 ? content : [{ type: "paragraph" }],
};
});
};
+36 -16
View File
@@ -209,7 +209,7 @@ export const CreateMemoModal = ({
const [submitStarted, setSubmitStarted] = useState(false);
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
const editorStartup = useMobileEditorStartupGuard({ active: draftLoaded && Boolean(baseUrl), ready: editorReady });
const { pickUploadAsset, uploadSourcePicker } = useMobileEditorUploadAsset();
const { pickUploadAssets, uploadSourcePicker } = useMobileEditorUploadAsset();
const targetNotebookId = notebookId || fallbackNotebookId;
const selectedNotebookName = notebooks.find((notebook) => notebook.id === targetNotebookId)?.name ?? "选择笔记本";
const titleRef = useRef(title);
@@ -518,7 +518,7 @@ export const CreateMemoModal = ({
return response.memo;
};
const uploadImageAsset = async (asset: MobileImageUploadAsset | null) => {
const uploadImageAsset = async (asset: MobileImageUploadAsset | null, keepBusy = false) => {
let uploadId: string | null = null;
try {
if (!asset) {
@@ -535,17 +535,30 @@ export const CreateMemoModal = ({
const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled);
const { resource } = await client!.uploadMemoResource(memo.id, new ExpoFile(uploadAsset.uri));
applyMobileEditorUpload(editorRef, resource, uploadId, uploadAsset.name || (resource.kind === "image" ? "图片" : "附件"));
return resource.kind === "image" ? resource.url : null;
} catch (error) {
cancelMobileEditorUpload(editorRef, uploadId);
Alert.alert("附件上传失败", error instanceof Error ? error.message : "请检查网络连接后重试");
} finally {
setImageOperation("idle");
if (!keepBusy) setImageOperation("idle");
}
};
const pickAndUploadImage = async () => {
const asset = await pickUploadAsset();
await uploadImageAsset(asset);
const assets = await pickUploadAssets();
if (!assets.length) return;
const sources: string[] = [];
setImageOperation("uploading");
try {
for (const asset of assets) {
const source = await uploadImageAsset(asset, true);
if (source === undefined) break;
if (source) sources.push(source);
}
safeDomCall(() => editorRef.current?.finishImageBatch(sources));
} finally {
setImageOperation("idle");
}
};
useEffect(() => {
@@ -842,7 +855,7 @@ export const RichEditorModal = ({
const [startupMs, setStartupMs] = useState<number | null>(null);
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
const editorStartup = useMobileEditorStartupGuard({ active: Boolean(memo && baseUrl), ready });
const { pickUploadAsset, uploadSourcePicker } = useMobileEditorUploadAsset();
const { pickUploadAssets, uploadSourcePicker } = useMobileEditorUploadAsset();
const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? "未分类";
const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中";
const titleRef = useRef(title);
@@ -986,28 +999,35 @@ export const RichEditorModal = ({
Alert.alert("正在同步新笔记", "首次同步完成后即可上传本地图片;图片链接现在就可以直接粘贴到正文。");
return;
}
const asset = await pickUploadAsset();
if (!asset) {
const assets = await pickUploadAssets();
if (!assets.length) {
return;
}
const isImage = asset.mimeType?.startsWith("image/") ?? false;
const uploadId = isImage ? createMobileImageUploadId() : null;
let uploadId: string | null = null;
const sources: string[] = [];
uploadingRef.current = true;
setUploading(true);
setError(null);
try {
if (isImage && uploadId) {
const previewDataUrl = await createLocalImagePreviewDataUrl(asset);
safeDomCall(() => editorRef.current?.beginImageUpload(uploadId, previewDataUrl));
for (const asset of assets) {
const isImage = asset.mimeType?.startsWith("image/") ?? false;
uploadId = isImage ? createMobileImageUploadId() : null;
if (isImage && uploadId) {
const previewDataUrl = await createLocalImagePreviewDataUrl(asset);
safeDomCall(() => editorRef.current?.beginImageUpload(uploadId, previewDataUrl));
}
const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled);
const { resource } = await client.uploadMemoResource(memo.id, new ExpoFile(uploadAsset.uri));
applyMobileEditorUpload(editorRef, resource, uploadId, uploadAsset.name || (resource.kind === "image" ? "图片" : "附件"));
if (resource.kind === "image") sources.push(resource.url);
uploadId = null;
}
const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled);
const { resource } = await client.uploadMemoResource(memo.id, new ExpoFile(uploadAsset.uri));
applyMobileEditorUpload(editorRef, resource, uploadId, uploadAsset.name || (resource.kind === "image" ? "图片" : "附件"));
} catch (uploadError) {
cancelMobileEditorUpload(editorRef, uploadId);
setError(uploadError instanceof Error ? uploadError.message : "附件上传失败");
} finally {
safeDomCall(() => editorRef.current?.finishImageBatch(sources));
uploadingRef.current = false;
setUploading(false);
}
+1
View File
@@ -7,6 +7,7 @@
".": "./src/index.ts",
"./ai-prompt-seeds": "./src/ai-prompt-seeds.ts",
"./image-display": "./src/image-display.ts",
"./native-image-gallery": "./src/native-image-gallery.ts",
"./i18n": "./src/i18n/index.ts",
"./memo-template-seeds": "./src/memo-template-seeds.ts",
"./mathematics": "./src/mathematics.ts",
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { EditorState, NodeSelection } from "@tiptap/pm/state";
import { history, undo } from "@tiptap/pm/history";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "./image-gallery";
import { createImageInsertTransaction, getImageInsertionRange, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "./native-image-gallery";
const schema = getSchema([StarterKit, Image, ImageGallery]);
const image = (src) => schema.nodes.image.create({ src, alt: src });
const paragraph = (text) => schema.nodes.paragraph.create(null, text ? schema.text(text) : null);
const gallery = () => schema.nodes[IMAGE_GALLERY_NODE_TYPE].create({ layout: "3" }, [image("old-a"), image("old-b")]);
const makeEditor = (nodes) => {
const editor = {
schema, isEditable: true, isDestroyed: false,
state: EditorState.create({ schema, doc: schema.nodes.doc.create(null, nodes), plugins: [history()] }),
};
editor.view = { dispatch: (tr) => { editor.state = editor.state.apply(tr); } };
return editor;
};
describe("native gallery editing", () => {
test("advances the insertion target through a three-image upload batch", () => {
const editor = makeEditor([paragraph()]);
for (const src of ["one", "two", "three"]) {
editor.view.dispatch(createImageInsertTransaction(editor.state, { src }));
}
expect(editor.state.doc.content.content.map((node) => node.attrs.src)).toEqual(["one", "two", "three"]);
expect(groupUploadedImages(editor, ["one", "two", "three"])).toBe(true);
expect(editor.state.doc.firstChild.content.content.map((node) => node.attrs.src)).toEqual(["one", "two", "three"]);
});
test("groups only this batch, preserves order and is undoable", () => {
const editor = makeEditor([image("old"), image("one"), image("two"), paragraph("text")]);
expect(groupUploadedImages(editor, ["one", "two"])).toBe(true);
expect(editor.state.doc.child(0).attrs.src).toBe("old");
const group = editor.state.doc.child(1);
expect(group.type.name).toBe(IMAGE_GALLERY_NODE_TYPE);
expect(group.attrs.layout).toBe("auto");
expect(group.content.content.map((node) => node.attrs.src)).toEqual(["one", "two"]);
undo(editor.state, editor.view.dispatch);
expect(editor.state.doc.childCount).toBe(4);
expect(editor.state.doc.child(1).attrs.src).toBe("one");
});
test("never crosses text, nests a gallery or groups a single successful upload", () => {
const editor = makeEditor([gallery(), image("one"), paragraph("keep"), image("two")]);
const original = editor.state.doc;
expect(groupUploadedImages(editor, ["old-a", "old-b", "one", "two"])).toBe(false);
expect(groupUploadedImages(editor, ["one"])).toBe(false);
expect(editor.state.doc).toBe(original);
editor.isEditable = false;
expect(groupUploadedImages(editor, ["one", "two"])).toBe(false);
});
test("inserts after selected media instead of replacing it or nesting a gallery", () => {
const doc = schema.nodes.doc.create(null, [gallery(), image("last"), paragraph("text")]);
expect(getImageInsertionRange(doc, NodeSelection.create(doc, 0))).toEqual({ from: 4, to: 4 });
expect(getImageInsertionRange(doc, NodeSelection.create(doc, 1))).toEqual({ from: 4, to: 4 });
expect(getImageInsertionRange(doc, NodeSelection.create(doc, 4))).toEqual({ from: 5, to: 5 });
expect(getImageInsertionRange(doc, { from: 6, to: 8 })).toEqual({ from: 6, to: 8 });
});
test("layout and source metadata survive JSON round trips", () => {
const doc = schema.nodes.doc.create(null, [gallery()]);
expect(schema.nodeFromJSON(doc.toJSON()).toJSON()).toEqual(doc.toJSON());
expect(NATIVE_IMAGE_GALLERY_CSS).toContain("min-height: 44px");
expect(NATIVE_IMAGE_GALLERY_CSS).toContain(".edgeever-native-gallery-content .edgeever-image-size-controls { display: none !important; }");
});
});
+151
View File
@@ -0,0 +1,151 @@
import type { Editor, NodeViewRenderer } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection, type EditorState } from "@tiptap/pm/state";
import { IMAGE_GALLERY_LAYOUTS, IMAGE_GALLERY_NODE_TYPE, resolveImageGalleryLayout } from "./image-gallery";
/** Adding images while an image/gallery is selected must not replace that content. */
export const getImageInsertionRange = (doc: ProseMirrorNode, selection: { from: number; to: number }) => {
const from = Math.min(selection.from, doc.content.size);
const to = Math.min(selection.to, doc.content.size);
const resolved = doc.resolve(from);
for (let depth = resolved.depth; depth > 0; depth -= 1) {
if (resolved.node(depth).type.name === IMAGE_GALLERY_NODE_TYPE) {
const after = resolved.after(depth);
return { from: after, to: after };
}
}
const node = doc.nodeAt(from);
if (to > from && (node?.type.name === "image" || node?.type.name === IMAGE_GALLERY_NODE_TYPE)) {
const after = from + node.nodeSize;
return { from: after, to: after };
}
return { from, to };
};
export const createImageInsertTransaction = (
state: EditorState,
attrs: Record<string, unknown>,
selection = state.selection as { from: number; to: number },
) => {
const { from, to } = getImageInsertionRange(state.doc, selection);
const image = state.schema.nodes.image.create(attrs);
const tr = state.tr.replaceRangeWith(from, to, image);
// ProseMirror may keep the first image selected after an insertion. Explicitly
// advance to the inserted node so a batch of 3+ images cannot reverse order.
tr.doc.descendants((node, pos) => {
if (node === image) tr.setSelection(NodeSelection.create(tr.doc, pos));
});
return tr;
};
/** Group only consecutive top-level images from this upload batch. Never move user text. */
export const groupUploadedImages = (editor: Editor, sources: readonly string[]): boolean => {
if (!editor.isEditable || editor.isDestroyed || sources.length < 2) return false;
const type = editor.schema.nodes[IMAGE_GALLERY_NODE_TYPE];
if (!type) return false;
const wanted = new Set(sources);
const groups: Array<{ from: number; to: number; images: ProseMirrorNode[] }> = [];
let current: typeof groups[number] | null = null;
editor.state.doc.forEach((node, pos) => {
if (node.type.name === "image" && wanted.has(node.attrs.src)) {
if (!current) current = { from: pos, to: pos, images: [] };
current.images.push(node);
current.to = pos + node.nodeSize;
} else {
if (current && current.images.length > 1) groups.push(current);
current = null;
}
});
if (current && (current as typeof groups[number]).images.length > 1) groups.push(current);
if (!groups.length) return false;
const tr = editor.state.tr;
for (const group of groups.reverse()) {
tr.replaceWith(group.from, group.to, type.create({ layout: "auto" }, group.images));
}
editor.view.dispatch(tr);
return true;
};
/** A shared touch-sized, in-flow toolbar for Android and iOS WebView editors. */
export const createNativeImageGalleryView = (getLocale: () => string): NodeViewRenderer => (
({ editor, node, getPos }) => {
let current = node;
const dom = document.createElement("div");
dom.className = "edgeever-native-image-gallery";
dom.dataset.edgeeverImageGallery = "true";
const toolbar = document.createElement("div");
toolbar.className = "edgeever-native-gallery-toolbar";
toolbar.contentEditable = "false";
toolbar.setAttribute("role", "toolbar");
const contentDOM = document.createElement("div");
contentDOM.className = "edgeever-native-gallery-content";
const buttons = IMAGE_GALLERY_LAYOUTS.map((layout) => {
const button = document.createElement("button");
button.type = "button";
button.addEventListener("pointerdown", (event) => event.stopPropagation());
button.addEventListener("mousedown", (event) => event.preventDefault());
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const pos = getPos();
if (!editor.isEditable || typeof pos !== "number") return;
const target = editor.state.doc.nodeAt(pos);
if (target?.type.name !== IMAGE_GALLERY_NODE_TYPE) return;
editor.view.dispatch(editor.state.tr.setNodeMarkup(pos, undefined, { ...target.attrs, layout }));
});
toolbar.appendChild(button);
return { button, layout };
});
dom.appendChild(toolbar);
dom.appendChild(contentDOM);
const sync = () => {
const english = getLocale() === "en-US";
const layout = resolveImageGalleryLayout(current.attrs.layout);
dom.dataset.imageGalleryLayout = layout;
dom.dataset.imageCount = String(current.childCount);
toolbar.hidden = !editor.isEditable;
toolbar.setAttribute("aria-label", english ? "Image gallery layout" : "图片画廊布局");
for (const item of buttons) {
item.button.textContent = item.layout === "auto" ? (english ? "Auto" : "自动")
: item.layout === "1" ? (english ? "Single" : "单列")
: `${item.layout} ${english ? "columns" : "栏"}`;
item.button.setAttribute("aria-pressed", String(item.layout === layout));
}
};
sync();
editor.on("transaction", sync);
// setEditable emits update without a document transaction.
editor.on("update", sync);
return {
dom,
contentDOM,
update: (next) => {
if (next.type !== current.type) return false;
current = next;
sync();
return true;
},
stopEvent: (event) => toolbar.contains(event.target as globalThis.Node),
ignoreMutation: (mutation) => mutation.type !== "selection" && !contentDOM.contains(mutation.target),
destroy: () => {
editor.off("transaction", sync);
editor.off("update", sync);
},
};
}
);
export const NATIVE_IMAGE_GALLERY_CSS = `
[data-edgeever-image-gallery].edgeever-native-image-gallery { display: block; }
.edgeever-native-gallery-toolbar { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; line-height: 1.2; }
.edgeever-native-gallery-toolbar[hidden] { display: none; }
.edgeever-native-gallery-toolbar button { min-height: 44px; padding: 8px 12px; border: 1px solid #16a06e55; border-radius: 8px; background: transparent; color: inherit; font: inherit; font-size: 13px; }
.edgeever-native-gallery-toolbar button[aria-pressed="true"] { color: #16a06e; background: #16a06e22; }
.edgeever-native-gallery-toolbar button:focus-visible { outline: 2px solid #16a06e; outline-offset: 2px; }
.edgeever-native-gallery-content { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
[data-image-gallery-layout="1"] > .edgeever-native-gallery-content { grid-template-columns: minmax(0, 1fr); }
@media (min-width: 600px) { [data-image-gallery-layout="3"] > .edgeever-native-gallery-content { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.edgeever-native-gallery-content > * { width: 100% !important; min-width: 0; height: 100%; margin: 0 !important; overflow: hidden; border-radius: 10px; background: transparent; }
.edgeever-native-gallery-content > img, .edgeever-native-gallery-content > * > img { width: 100% !important; height: 100%; min-height: 112px; max-height: 220px; object-fit: cover; }
.edgeever-native-gallery-content .edgeever-image-size-controls { display: none !important; }
`;