feat(markdown): add images from relative path

This commit is contained in:
Aaron Liu 2025-08-21 11:45:28 +08:00
parent 5a1665a96a
commit 0feed521f0
8 changed files with 105 additions and 15 deletions

View File

@ -12,7 +12,7 @@
"uploadImage": {
"dialogTitle": "Upload image",
"uploadInstructions": "Upload an image from your device:",
"addViaUrlInstructions": "Or add an image from an URL:",
"addViaUrlInstructions": "Or add an image from an URL / relative path (relative to the current file):",
"autoCompletePlaceholder": "Select or paste an image src",
"addViaUrlInstructionsNoUpload": "Image URL:",
"alt": "Alt:",
@ -110,4 +110,4 @@
"contentArea": {
"editableMarkdown": "editable markdown"
}
}
}

View File

@ -12,8 +12,8 @@
"uploadImage": {
"dialogTitle": "画像アップロード",
"uploadInstructions": "デバイスから画像をアップロード:",
"addViaUrlInstructions": "またはURLから画像を追加",
"autoCompletePlaceholder": "画像を選択または貼り付け",
"addViaUrlInstructions": "または画像URL / 相対パス(現在のファイルに対して)",
"autoCompletePlaceholder": "画像URLを選択または貼り付け",
"addViaUrlInstructionsNoUpload": "画像URL",
"alt": "代替テキスト:",
"title": "タイトル:"
@ -110,4 +110,4 @@
"contentArea": {
"editableMarkdown": "編集可能なMarkdown"
}
}
}

View File

@ -12,8 +12,8 @@
"uploadImage": {
"dialogTitle": "上传图片",
"uploadInstructions": "从您的设备中上传图片:",
"addViaUrlInstructions": "或从网址新增图片",
"autoCompletePlaceholder": "选择或粘贴图片",
"addViaUrlInstructions": "或填写图片 URL / 相对路径 (相对于当前文件)",
"autoCompletePlaceholder": "选择或粘贴图片 URL",
"addViaUrlInstructionsNoUpload": "图片网址:",
"alt": "替代文本:",
"title": "标题:"
@ -110,4 +110,4 @@
"contentArea": {
"editableMarkdown": "可编辑的 Markdown"
}
}
}

View File

@ -12,8 +12,8 @@
"uploadImage": {
"dialogTitle": "上傳圖片",
"uploadInstructions": "從您的裝置上傳圖片:",
"addViaUrlInstructions": "或從網址新增圖片",
"autoCompletePlaceholder": "選擇或貼上圖片網址",
"addViaUrlInstructions": "或填寫圖片 URL / 相對路徑 (相對於當前文件)",
"autoCompletePlaceholder": "選擇或貼上圖片 URL",
"addViaUrlInstructionsNoUpload": "圖片網址:",
"alt": "替代文字:",
"title": "標題:"
@ -111,4 +111,4 @@
"contentArea": {
"editableMarkdown": "可編輯的 Markdown"
}
}
}

View File

@ -54,6 +54,8 @@ export interface MarkdownEditorProps {
readOnly?: boolean;
displayOnly?: boolean;
onSaveShortcut?: () => void;
imageAutocompleteSuggestions?: string[] | null;
imagePreviewHandler?: (imageSource: string) => Promise<string>;
}
function whenInAdmonition(editorInFocus: EditorInFocus | null) {
@ -193,7 +195,13 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }),
linkPlugin(),
linkDialogPlugin(),
imagePlugin({}),
imagePlugin({
imageUploadHandler: () => {
return Promise.resolve("https://picsum.photos/200/300");
},
imagePreviewHandler: props.imagePreviewHandler,
imageAutocompleteSuggestions: props.imageAutocompleteSuggestions ?? undefined,
}),
tablePlugin(),
thematicBreakPlugin(),
frontmatterPlugin(),

View File

@ -1,11 +1,15 @@
import { LoadingButton } from "@mui/lab";
import { Box, Button, ButtonGroup, ListItemText, Menu, useTheme } from "@mui/material";
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeMarkdownViewer } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { getEntityContent } from "../../../redux/thunks/file.ts";
import { saveMarkdown } from "../../../redux/thunks/viewer.ts";
import {
markdownImageAutocompleteSuggestions,
markdownImagePreviewHandler,
saveMarkdown,
} from "../../../redux/thunks/viewer.ts";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
import CaretDown from "../../Icons/CaretDown.tsx";
@ -58,6 +62,13 @@ const MarkdownViewer = () => {
loadContent();
}, [viewerState?.open]);
const imageAutocompleteSuggestions = useMemo(() => {
if (!viewerState?.open) {
return null;
}
return dispatch(markdownImageAutocompleteSuggestions());
}, [viewerState?.open]);
const onClose = useCallback(() => {
dispatch(closeMarkdownViewer());
}, [dispatch]);
@ -106,6 +117,13 @@ const MarkdownViewer = () => {
};
}, [saved, supportUpdate, onSave]);
const imagePreviewHandler = useCallback(
(imageSource: string) => {
return dispatch(markdownImagePreviewHandler(imageSource, viewerState?.file?.path ?? ""));
},
[dispatch, viewerState?.file?.path],
);
return (
<ViewerDialog
file={viewerState?.file}
@ -159,6 +177,8 @@ const MarkdownViewer = () => {
initialValue={value}
onChange={(v) => onChange(v as string)}
onSaveShortcut={onSaveShortcut}
imagePreviewHandler={imagePreviewHandler}
imageAutocompleteSuggestions={imageAutocompleteSuggestions}
/>
</Suspense>
)}

View File

@ -10,7 +10,7 @@ import SessionManager, { UserSettings } from "../../session";
import { isTrueVal } from "../../session/utils.ts";
import { dataUrlToBytes, fileExtension, fileNameNoExt, getFileLinkedUri, sizeToString } from "../../util";
import { base64Encode } from "../../util/base64.ts";
import CrUri from "../../util/uri.ts";
import CrUri, { CrUriPrefix } from "../../util/uri.ts";
import { closeContextMenu, ContextMenuTypes, fileUpdated } from "../fileManagerSlice.ts";
import {
closeImageEditor,
@ -691,3 +691,54 @@ export function findSubtitleOptions(): AppThunk<FileResponse[]> {
return options ?? [];
};
}
const BROKEN_IMG_URI =
"data:image/svg+xml;charset=utf-8," +
encodeURIComponent(/* xml */ `
<svg id="imgLoadError" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="red" stroke-width="4" stroke-dasharray="4" />
<text x="50" y="55" text-anchor="middle" font-size="20" fill="red"></text>
</svg>
`);
export function markdownImagePreviewHandler(imageSource: string, mdFileUri: string): AppThunk<Promise<string>> {
return async (dispatch, getState) => {
// For URl, return the image source
if (imageSource.startsWith("http://") || imageSource.startsWith("https://")) {
return imageSource;
}
let uri = new CrUri(mdFileUri)?.parent();
if (imageSource.startsWith(CrUriPrefix)) {
uri = new CrUri(imageSource);
} else if (uri) {
uri = uri.join_raw(imageSource);
} else {
return imageSource;
}
try {
const file = await dispatch(getFileInfo({ uri: uri.toString() }));
const fileUrl = await dispatch(getFileEntityUrl({ uris: [getFileLinkedUri(file)], entity: file.primary_entity }));
return fileUrl.urls[0].url;
} catch (e) {
return BROKEN_IMG_URI;
}
};
}
export function markdownImageAutocompleteSuggestions(): AppThunk<string[] | null> {
return (_dispatch, getState) => {
const files = getState().fileManager[FileManagerIndex.main]?.list?.files;
if (!files) {
return null;
}
const suggestions = files.filter((f) => {
const ext = fileExtension(f.name);
return ViewersByID[builtInViewers.image]?.exts.indexOf(ext ?? "") !== -1;
});
return suggestions.map((f) => f.name);
};
}

View File

@ -240,6 +240,17 @@ export default class CrUri {
return this;
}
public join_raw(rawPath: string): this {
if (rawPath.startsWith("/")) {
// Absolute path - replace the entire pathname
this.url.pathname = rawPath;
} else {
// Relative path - resolve it against the current path
this.url.pathname = path.resolve(this.url.pathname, rawPath);
}
return this;
}
public elements(): string[] {
const res = this.path_trimmed()
.split("/")