feat: enable native mobile image gallery editing (#342)
This commit is contained in:
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
+428
-415
File diff suppressed because it is too large.
Load diff
@@ -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" }],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }");
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
`;
|
||||
Reference in new issue
Block a user