fix(mobile): bound note image export rendering
This commit is contained in:
1 parent
a8d500df9f
commit
8d1509e724
4 files changed
+151
-40
No files matched your search
@@ -108,6 +108,7 @@ import {
|
||||
createMobileNoteSearchHighlightPlugin,
|
||||
getMobileNoteSearchMatches,
|
||||
} from "../lib/mobile-note-search";
|
||||
import { planMobileNoteImageRender } from "../lib/mobile-note-image-export";
|
||||
import { toProtectedResourceLoadPath } from "../lib/mobile-protected-resources";
|
||||
|
||||
type EditorDoc = TiptapDoc;
|
||||
@@ -173,8 +174,7 @@ const TRANSIENT_IMAGE_UPLOAD_META = "edgeeverImageUploadPlaceholder";
|
||||
const ignoreSearchResult = async () => undefined;
|
||||
const ignoreAiRequest = async () => undefined;
|
||||
const AI_PROMPT_OPTION_PREFIX = "prompt:";
|
||||
const IMAGE_EXPORT_PIXEL_RATIO = 2;
|
||||
const IMAGE_EXPORT_CHUNK_SIZE = 256 * 1024;
|
||||
const IMAGE_EXPORT_CHUNK_BYTES = 192 * 1024;
|
||||
|
||||
type ImageExportRequest = {
|
||||
requestId: string;
|
||||
@@ -196,8 +196,6 @@ type ImageExportRequest = {
|
||||
branding?: boolean;
|
||||
};
|
||||
|
||||
const blobToBytes = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer());
|
||||
|
||||
const bytesToBase64 = (bytes: Uint8Array) => {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
@@ -1296,26 +1294,43 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
document.body.appendChild(host);
|
||||
|
||||
try {
|
||||
await document.fonts?.ready;
|
||||
await Promise.all(Array.from(documentRoot.querySelectorAll("img")).map(async (image) => {
|
||||
if (image.complete) return;
|
||||
try { await image.decode(); } catch { /* Export the readable remainder. */ }
|
||||
}));
|
||||
const images = Array.from(documentRoot.querySelectorAll<HTMLImageElement>("img"));
|
||||
await Promise.race([
|
||||
Promise.all([
|
||||
document.fonts?.ready,
|
||||
...images.map(async (image) => {
|
||||
if (image.complete) return;
|
||||
try { await image.decode(); } catch { /* Export the readable remainder. */ }
|
||||
}),
|
||||
]),
|
||||
new Promise<void>((resolve) => window.setTimeout(resolve, 8_000)),
|
||||
]);
|
||||
const exportedImages = Array.from(
|
||||
documentRoot.querySelectorAll<HTMLImageElement>(".edgeever-card-body img"),
|
||||
);
|
||||
const failedImages = exportedImages.filter((image) => !image.complete || image.naturalWidth === 0).length;
|
||||
const totalHeight = Math.max(1, Math.ceil(documentRoot.getBoundingClientRect().height));
|
||||
const renderPlan = planMobileNoteImageRender(targetWidth, totalHeight);
|
||||
let captureRoot = documentRoot;
|
||||
if (renderPlan.sourceScale < 1) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.cssText = `position:relative;width:${renderPlan.sourceWidth}px;height:${renderPlan.sourceHeight}px;overflow:hidden;`;
|
||||
documentRoot.replaceWith(wrapper);
|
||||
wrapper.appendChild(documentRoot);
|
||||
documentRoot.style.transform = `scale(${renderPlan.sourceScale})`;
|
||||
documentRoot.style.transformOrigin = "top left";
|
||||
captureRoot = wrapper;
|
||||
}
|
||||
const backgroundColor = NOTE_IMAGE_BACKGROUND_COLORS[resolvedTheme] || themeCfg.canvasBg;
|
||||
|
||||
const { toCanvas } = await import("html-to-image");
|
||||
const canvas = await toCanvas(documentRoot, {
|
||||
const canvas = await toCanvas(captureRoot, {
|
||||
backgroundColor,
|
||||
cacheBust: false,
|
||||
height: totalHeight,
|
||||
pixelRatio: IMAGE_EXPORT_PIXEL_RATIO,
|
||||
height: renderPlan.sourceHeight,
|
||||
pixelRatio: renderPlan.pixelRatio,
|
||||
skipFonts: true,
|
||||
width: targetWidth,
|
||||
width: renderPlan.sourceWidth,
|
||||
});
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
@@ -1327,13 +1342,12 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
|
||||
const extension = request.format === "jpeg" ? "jpg" : "png";
|
||||
const basename = buildImageExportBasename(request.title, request.fallbackTitle);
|
||||
const bytes = await blobToBytes(blob);
|
||||
const filename = `${basename}.${extension}`;
|
||||
const mimeType = request.format === "jpeg" ? "image/jpeg" : "image/png";
|
||||
|
||||
const base64 = bytesToBase64(bytes);
|
||||
for (let offset = 0; offset < base64.length; offset += IMAGE_EXPORT_CHUNK_SIZE) {
|
||||
await notify({ type: "chunk", chunk: base64.slice(offset, offset + IMAGE_EXPORT_CHUNK_SIZE) });
|
||||
for (let offset = 0; offset < blob.size; offset += IMAGE_EXPORT_CHUNK_BYTES) {
|
||||
const bytes = new Uint8Array(await blob.slice(offset, offset + IMAGE_EXPORT_CHUNK_BYTES).arrayBuffer());
|
||||
await notify({ type: "chunk", chunk: bytesToBase64(bytes) });
|
||||
}
|
||||
await notify({
|
||||
type: "complete",
|
||||
@@ -3173,24 +3187,24 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean }
|
||||
.edgeever-editor-content ul[data-type="taskList"] li[data-checked] > div > p { margin-bottom: 0; }
|
||||
.edgeever-editor-content ul[data-type="taskList"] li[data-checked="true"] > div > p { color: #94a3b8; text-decoration: line-through; }
|
||||
.edgeever-editor-content ul[data-type="taskList"] ul[data-type="taskList"] { margin: 4px 0 0; padding-left: 24px; }
|
||||
.edgeever-editor-content blockquote { margin-left: 0; max-width: 100%; padding: 6px 12px; border-left: 3px solid #16a06e; border-radius: 1px 4px 4px 1px; background: ${theme === "dark" ? "rgba(22, 160, 110, 0.08)" : "rgba(22, 160, 110, 0.04)"}; color: ${theme === "dark" ? "#cbd5e1" : "#334155"}; }
|
||||
.edgeever-editor-content blockquote { margin-left: 0; max-width: 100%; padding: 6px 12px; border-left: 3px solid ${theme === "dark" ? "#475569" : "#cbd5e1"}; border-radius: 1px 4px 4px 1px; background: ${theme === "dark" ? "rgba(255, 255, 255, 0.05)" : "#f3f5f7"}; color: ${theme === "dark" ? "#f8fafc" : "#3d4450"}; }
|
||||
.edgeever-editor-content pre { max-width: 100%; overflow-x: auto; border-radius: 8px; border: 1px solid ${theme === "dark" ? "#334155" : "#e2e8f0"}; padding: 12px 90px 12px 14px; background: ${theme === "dark" ? "#1e293b" : "#f8fafc"}; color: ${theme === "dark" ? "#e2e8f0" : "#0f172a"}; font-size: 0.88rem; box-shadow: 0 1px 2px ${theme === "dark" ? "rgba(0, 0, 0, 0.2)" : "rgba(15, 23, 42, 0.03)"}; }
|
||||
.edgeever-editor-content code { border-radius: 4px; padding: 2px 5px; border: 1px solid ${theme === "dark" ? "rgba(22, 160, 110, 0.28)" : "#d4ebdc"}; background: ${theme === "dark" ? "rgba(22, 160, 110, 0.12)" : "#f2f9f5"}; color: ${theme === "dark" ? "#6ee7b7" : "#0d5f3a"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em; font-weight: 550; }
|
||||
.edgeever-editor-content code { border-radius: 4px; padding: 2px 5px; border: 1px solid ${theme === "dark" ? "rgba(255, 255, 255, 0.12)" : "#e1e5ea"}; background: ${theme === "dark" ? "rgba(255, 255, 255, 0.06)" : "#f3f5f7"}; color: ${theme === "dark" ? "#f8fafc" : "#3d4450"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em; font-weight: 550; }
|
||||
.edgeever-editor-content pre code { padding: 0; border: 0; background: transparent; font-size: inherit; font-weight: normal; color: inherit; }
|
||||
.edgeever-editor-content .tiptap-mathematics-render[data-type="block-math"] { max-width: 100%; margin: 16px 0; overflow-x: auto; overflow-y: hidden; padding: 4px 0; text-align: center; -webkit-overflow-scrolling: touch; }
|
||||
.edgeever-editor-content .inline-math-error, .edgeever-editor-content .block-math-error { color: ${theme === "dark" ? "#fda4af" : "#be123c"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
/* External hyperlinks (match Web default ProseMirror). Attachment chips override below. */
|
||||
.edgeever-editor-content a {
|
||||
color: ${theme === "dark" ? "#86efac" : "#00751f"};
|
||||
color: ${theme === "dark" ? "#cad4ce" : "#404040"};
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: ${theme === "dark" ? "rgba(134, 239, 172, 0.45)" : "rgba(0, 117, 31, 0.45)"};
|
||||
text-decoration-color: ${theme === "dark" ? "rgba(202, 212, 206, 0.45)" : "rgba(64, 64, 64, 0.45)"};
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.edgeever-editor-content a:active {
|
||||
color: ${theme === "dark" ? "#4ade80" : "#00a82d"};
|
||||
text-decoration-color: ${theme === "dark" ? "#4ade80" : "#00a82d"};
|
||||
color: ${theme === "dark" ? "#eef3f0" : "#0a0a0a"};
|
||||
text-decoration-color: ${theme === "dark" ? "#eef3f0" : "#0a0a0a"};
|
||||
}
|
||||
/* Compact attachment chips: still ≥48px touch height, less vertical bulk than 58px. */
|
||||
.edgeever-editor-content a.edgeever-attachment-link, .edgeever-editor-content a[href*="/api/v1/resources/"] {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { planMobileNoteImageRender } from "./mobile-note-image-export";
|
||||
|
||||
describe("planMobileNoteImageRender", () => {
|
||||
test("keeps a short card at full quality", () => {
|
||||
expect(planMobileNoteImageRender(680, 1_000)).toEqual({
|
||||
pixelRatio: 2,
|
||||
sourceHeight: 1_000,
|
||||
sourceScale: 1,
|
||||
sourceWidth: 680,
|
||||
});
|
||||
});
|
||||
|
||||
test("fits a long digest within the mobile image budget", () => {
|
||||
const plan = planMobileNoteImageRender(680, 20_000);
|
||||
expect(plan.sourceScale).toBe(0.75);
|
||||
expect(plan.sourceHeight).toBeLessThanOrEqual(15_000);
|
||||
expect(plan.sourceHeight * plan.pixelRatio).toBeLessThanOrEqual(15_000);
|
||||
expect(plan.sourceWidth * plan.sourceHeight * plan.pixelRatio ** 2).toBeLessThanOrEqual(12_000_000);
|
||||
});
|
||||
|
||||
test("rejects a card that would be unreadable as one image", () => {
|
||||
expect(() => planMobileNoteImageRender(680, 40_000)).toThrow("NOTE_IMAGE_TOO_LONG");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const MAX_SOURCE_HEIGHT = 15_000;
|
||||
const MAX_OUTPUT_HEIGHT = 15_000;
|
||||
const MAX_OUTPUT_PIXELS = 12_000_000;
|
||||
const PREFERRED_PIXEL_RATIO = 2;
|
||||
|
||||
/** Keep a complete long card within the dimensions Android WebView can rasterize. */
|
||||
export const planMobileNoteImageRender = (width: number, height: number) => {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
throw new Error("Invalid note image dimensions");
|
||||
}
|
||||
|
||||
const sourceScale = Math.min(1, MAX_SOURCE_HEIGHT / height);
|
||||
if (sourceScale < 0.5) {
|
||||
throw new Error("NOTE_IMAGE_TOO_LONG");
|
||||
}
|
||||
|
||||
const sourceWidth = Math.max(1, Math.floor(width * sourceScale));
|
||||
const sourceHeight = Math.max(1, Math.floor(height * sourceScale));
|
||||
const pixelRatio = Math.min(
|
||||
PREFERRED_PIXEL_RATIO,
|
||||
MAX_OUTPUT_HEIGHT / sourceHeight,
|
||||
Math.sqrt(MAX_OUTPUT_PIXELS / (sourceWidth * sourceHeight)),
|
||||
);
|
||||
|
||||
return { pixelRatio, sourceHeight, sourceScale, sourceWidth };
|
||||
};
|
||||
@@ -47,6 +47,7 @@ import { NotebookPickerModal, SmartTagButton, TagPickerModal } from "./Workspace
|
||||
|
||||
const ANDROID_SYSTEM_NAVIGATION_FALLBACK = 48;
|
||||
const RESOURCE_DATA_URL_CACHE_LIMIT = 32;
|
||||
const IMAGE_EXPORT_TIMEOUT_MS = 60_000;
|
||||
|
||||
type MobileImageExportEvent =
|
||||
| { type: "chunk"; requestId: string; chunk: string }
|
||||
@@ -63,7 +64,6 @@ type MobileImageExportEvent =
|
||||
| { type: "error"; requestId: string; message?: string };
|
||||
|
||||
type MobilePreparedNoteImage = {
|
||||
base64: string;
|
||||
failedImages: number;
|
||||
filename: string;
|
||||
height: number;
|
||||
@@ -494,10 +494,12 @@ export const MemoDetailModal = ({
|
||||
const [imageShareBranding, setImageShareBranding] = useState(true);
|
||||
const [viewerNotebookPickerOpen, setViewerNotebookPickerOpen] = useState(false);
|
||||
const [preparedNoteImage, setPreparedNoteImage] = useState<MobilePreparedNoteImage | null>(null);
|
||||
const [viewerGeneration, setViewerGeneration] = useState(0);
|
||||
const viewerRef = useRef<LocalTiptapEditorRef>(null);
|
||||
const imageExportIntentRef = useRef<"preview" | "share">("share");
|
||||
const imageExportRequestRef = useRef<string | null>(null);
|
||||
const imageExportChunksRef = useRef<string[]>([]);
|
||||
const imageExportTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resourceDataUrlCacheRef = useRef(new Map<string, Promise<string | null>>());
|
||||
// One user-visible notice per opened memo (multi-image notes should not spam alerts).
|
||||
const imageLoadFailureNotifier = useMemo(
|
||||
@@ -756,6 +758,31 @@ export const MemoDetailModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const clearImageExportTimeout = useCallback(() => {
|
||||
if (imageExportTimeoutRef.current !== null) {
|
||||
clearTimeout(imageExportTimeoutRef.current);
|
||||
imageExportTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearImageExportTimeout, [clearImageExportTimeout]);
|
||||
|
||||
const failImageExport = useCallback((message?: string) => {
|
||||
clearImageExportTimeout();
|
||||
imageExportChunksRef.current = [];
|
||||
imageExportRequestRef.current = null;
|
||||
setIsExportingImage(false);
|
||||
const localizedMessage = message === "NOTE_IMAGE_TOO_LONG"
|
||||
? (resolvedLocale !== "zh-CN"
|
||||
? "This note is too long for one readable image. Choose a smaller font size or split the note."
|
||||
: "这篇笔记太长,无法生成清晰的单张图片。请调小导出字号,或拆分笔记后重试。")
|
||||
: message;
|
||||
Alert.alert(
|
||||
resolvedLocale !== "zh-CN" ? "Image export failed" : "导出笔记图片失败",
|
||||
localizedMessage || (resolvedLocale !== "zh-CN" ? "Try again later." : "请稍后重试。")
|
||||
);
|
||||
}, [clearImageExportTimeout, resolvedLocale]);
|
||||
|
||||
const handleImageExportEvent = useCallback(async (payloadJson: string) => {
|
||||
let event: MobileImageExportEvent;
|
||||
try {
|
||||
@@ -769,16 +796,11 @@ export const MemoDetailModal = ({
|
||||
return;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
imageExportChunksRef.current = [];
|
||||
imageExportRequestRef.current = null;
|
||||
setIsExportingImage(false);
|
||||
Alert.alert(
|
||||
resolvedLocale !== "zh-CN" ? "Image export failed" : "导出笔记图片失败",
|
||||
event.message || (resolvedLocale !== "zh-CN" ? "Try again later." : "请稍后重试。")
|
||||
);
|
||||
failImageExport(event.message);
|
||||
return;
|
||||
}
|
||||
|
||||
clearImageExportTimeout();
|
||||
try {
|
||||
const { Directory, File, Paths } = await import("expo-file-system");
|
||||
const directory = new Directory(Paths.cache, "edgeever-note-exports");
|
||||
@@ -786,10 +808,8 @@ export const MemoDetailModal = ({
|
||||
const file = new File(directory, event.filename);
|
||||
if (file.exists) file.delete();
|
||||
file.create({ overwrite: true, intermediates: true });
|
||||
const base64 = imageExportChunksRef.current.join("");
|
||||
file.write(decodeBase64Chunks(imageExportChunksRef.current));
|
||||
const prepared: MobilePreparedNoteImage = {
|
||||
base64,
|
||||
failedImages: event.failedImages ?? 0,
|
||||
filename: event.filename,
|
||||
height: event.height ?? 0,
|
||||
@@ -801,6 +821,7 @@ export const MemoDetailModal = ({
|
||||
if (imageExportIntentRef.current === "preview") {
|
||||
setPreparedNoteImage(prepared);
|
||||
} else {
|
||||
setIsExportingImage(false);
|
||||
const Sharing = await import("expo-sharing");
|
||||
if (!(await Sharing.isAvailableAsync())) throw new Error(resolvedLocale !== "zh-CN" ? "Sharing is unavailable on this device." : "当前设备无法打开系统分享面板。");
|
||||
await Sharing.shareAsync(file.uri, {
|
||||
@@ -818,7 +839,7 @@ export const MemoDetailModal = ({
|
||||
imageExportRequestRef.current = null;
|
||||
setIsExportingImage(false);
|
||||
}
|
||||
}, [resolvedLocale]);
|
||||
}, [clearImageExportTimeout, failImageExport, resolvedLocale]);
|
||||
|
||||
const exportMemoImage = useCallback((
|
||||
format: "jpeg" | "png",
|
||||
@@ -836,12 +857,25 @@ export const MemoDetailModal = ({
|
||||
} = {},
|
||||
) => {
|
||||
if (!memo || !viewerReady || isExportingImage) return;
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) {
|
||||
failImageExport(resolvedLocale !== "zh-CN" ? "The note viewer is not ready. Try again." : "笔记尚未准备好,请重试。");
|
||||
return;
|
||||
}
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
imageExportRequestRef.current = requestId;
|
||||
imageExportChunksRef.current = [];
|
||||
imageExportIntentRef.current = options.intent ?? "share";
|
||||
setIsExportingImage(true);
|
||||
safeDomCall(() => viewerRef.current?.exportImage(JSON.stringify({
|
||||
imageExportTimeoutRef.current = setTimeout(() => {
|
||||
if (imageExportRequestRef.current !== requestId) return;
|
||||
failImageExport(resolvedLocale !== "zh-CN"
|
||||
? "Image generation took too long. Try a smaller font size or export a shorter note."
|
||||
: "生成图片超时。请调小导出字号,或对较短的笔记重试。");
|
||||
setViewerReady(false);
|
||||
setViewerGeneration((generation) => generation + 1);
|
||||
}, IMAGE_EXPORT_TIMEOUT_MS);
|
||||
const request = JSON.stringify({
|
||||
requestId,
|
||||
format,
|
||||
title: localizeUntitledMemoTitle(memo.title, resolvedLocale),
|
||||
@@ -858,8 +892,17 @@ export const MemoDetailModal = ({
|
||||
showTags: options.showTags ?? false,
|
||||
showUpdatedAt: options.showUpdatedAt ?? true,
|
||||
branding: options.showBranding ?? true,
|
||||
})));
|
||||
}, [isExportingImage, memo, notebookName, resolvedLocale, viewerReady]);
|
||||
});
|
||||
try {
|
||||
void Promise.resolve(viewer.exportImage(request)).catch((error: unknown) => {
|
||||
if (imageExportRequestRef.current === requestId) {
|
||||
failImageExport(error instanceof Error ? error.message : undefined);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
failImageExport(error instanceof Error ? error.message : undefined);
|
||||
}
|
||||
}, [failImageExport, isExportingImage, memo, notebookName, resolvedLocale, viewerReady]);
|
||||
|
||||
const sharePreparedNoteImage = useCallback(async (prepared: MobilePreparedNoteImage) => {
|
||||
try {
|
||||
@@ -876,7 +919,9 @@ export const MemoDetailModal = ({
|
||||
|
||||
const copyPreparedNoteImage = useCallback(async (prepared: MobilePreparedNoteImage) => {
|
||||
try {
|
||||
await Clipboard.setImageAsync(prepared.base64);
|
||||
const FileSystem = await import("expo-file-system/legacy");
|
||||
const base64 = await FileSystem.readAsStringAsync(prepared.uri, { encoding: FileSystem.EncodingType.Base64 });
|
||||
await Clipboard.setImageAsync(base64);
|
||||
Alert.alert(resolvedLocale !== "zh-CN" ? "Copied" : "复制成功", resolvedLocale !== "zh-CN" ? "The image is on your clipboard." : "图片已复制到剪贴板。");
|
||||
} catch {
|
||||
Alert.alert(resolvedLocale !== "zh-CN" ? "Copy failed" : "复制失败", resolvedLocale !== "zh-CN" ? "Try saving the image instead." : "请尝试保存图片。" );
|
||||
@@ -893,7 +938,8 @@ export const MemoDetailModal = ({
|
||||
prepared.filename,
|
||||
prepared.mimeType
|
||||
);
|
||||
await FileSystem.StorageAccessFramework.writeAsStringAsync(destination, prepared.base64, {
|
||||
const base64 = await FileSystem.readAsStringAsync(prepared.uri, { encoding: FileSystem.EncodingType.Base64 });
|
||||
await FileSystem.StorageAccessFramework.writeAsStringAsync(destination, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
Alert.alert(resolvedLocale !== "zh-CN" ? "Saved" : "保存成功", prepared.filename);
|
||||
@@ -1236,7 +1282,7 @@ export const MemoDetailModal = ({
|
||||
)}
|
||||
{baseUrl ? (
|
||||
<LocalTiptapEditor
|
||||
key={memo.id}
|
||||
key={`${memo.id}:${viewerGeneration}`}
|
||||
aiPromptsJson={isEditing ? editor.aiPromptsJson : undefined}
|
||||
autoFocus={isEditing && editingSession?.initialFocus === "body"}
|
||||
baseUrl={baseUrl}
|
||||
|
||||
Reference in new issue
Block a user