mirror of
https://github.com/cloudreve/frontend.git
synced 2025-12-25 19:52:48 +00:00
feat(markdown): add images from relative path
This commit is contained in:
parent
5a1665a96a
commit
0feed521f0
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
"uploadImage": {
|
||||
"dialogTitle": "上传图片",
|
||||
"uploadInstructions": "从您的设备中上传图片:",
|
||||
"addViaUrlInstructions": "或从网址新增图片:",
|
||||
"autoCompletePlaceholder": "选择或粘贴图片",
|
||||
"addViaUrlInstructions": "或填写图片 URL / 相对路径 (相对于当前文件):",
|
||||
"autoCompletePlaceholder": "选择或粘贴图片 URL",
|
||||
"addViaUrlInstructionsNoUpload": "图片网址:",
|
||||
"alt": "替代文本:",
|
||||
"title": "标题:"
|
||||
|
|
@ -110,4 +110,4 @@
|
|||
"contentArea": {
|
||||
"editableMarkdown": "可编辑的 Markdown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
"uploadImage": {
|
||||
"dialogTitle": "上傳圖片",
|
||||
"uploadInstructions": "從您的裝置上傳圖片:",
|
||||
"addViaUrlInstructions": "或從網址新增圖片:",
|
||||
"autoCompletePlaceholder": "選擇或貼上圖片網址",
|
||||
"addViaUrlInstructions": "或填寫圖片 URL / 相對路徑 (相對於當前文件):",
|
||||
"autoCompletePlaceholder": "選擇或貼上圖片 URL",
|
||||
"addViaUrlInstructionsNoUpload": "圖片網址:",
|
||||
"alt": "替代文字:",
|
||||
"title": "標題:"
|
||||
|
|
@ -111,4 +111,4 @@
|
|||
"contentArea": {
|
||||
"editableMarkdown": "可編輯的 Markdown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
Loading…
Reference in New Issue