diff --git a/public/locales/en-US/markdown_editor.json b/public/locales/en-US/markdown_editor.json index 4dbafc6..a2f21c3 100644 --- a/public/locales/en-US/markdown_editor.json +++ b/public/locales/en-US/markdown_editor.json @@ -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" } -} \ No newline at end of file +} diff --git a/public/locales/ja-JP/markdown_editor.json b/public/locales/ja-JP/markdown_editor.json index aea8f30..f9afb4f 100644 --- a/public/locales/ja-JP/markdown_editor.json +++ b/public/locales/ja-JP/markdown_editor.json @@ -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" } -} \ No newline at end of file +} diff --git a/public/locales/zh-CN/markdown_editor.json b/public/locales/zh-CN/markdown_editor.json index d7b0c6d..6afcbe0 100644 --- a/public/locales/zh-CN/markdown_editor.json +++ b/public/locales/zh-CN/markdown_editor.json @@ -12,8 +12,8 @@ "uploadImage": { "dialogTitle": "上传图片", "uploadInstructions": "从您的设备中上传图片:", - "addViaUrlInstructions": "或从网址新增图片:", - "autoCompletePlaceholder": "选择或粘贴图片", + "addViaUrlInstructions": "或填写图片 URL / 相对路径 (相对于当前文件):", + "autoCompletePlaceholder": "选择或粘贴图片 URL", "addViaUrlInstructionsNoUpload": "图片网址:", "alt": "替代文本:", "title": "标题:" @@ -110,4 +110,4 @@ "contentArea": { "editableMarkdown": "可编辑的 Markdown" } -} \ No newline at end of file +} diff --git a/public/locales/zh-TW/markdown_editor.json b/public/locales/zh-TW/markdown_editor.json index 12cf77b..33ae1d1 100644 --- a/public/locales/zh-TW/markdown_editor.json +++ b/public/locales/zh-TW/markdown_editor.json @@ -12,8 +12,8 @@ "uploadImage": { "dialogTitle": "上傳圖片", "uploadInstructions": "從您的裝置上傳圖片:", - "addViaUrlInstructions": "或從網址新增圖片:", - "autoCompletePlaceholder": "選擇或貼上圖片網址", + "addViaUrlInstructions": "或填寫圖片 URL / 相對路徑 (相對於當前文件):", + "autoCompletePlaceholder": "選擇或貼上圖片 URL", "addViaUrlInstructionsNoUpload": "圖片網址:", "alt": "替代文字:", "title": "標題:" @@ -111,4 +111,4 @@ "contentArea": { "editableMarkdown": "可編輯的 Markdown" } -} \ No newline at end of file +} diff --git a/src/component/Viewers/MarkdownEditor/Editor.tsx b/src/component/Viewers/MarkdownEditor/Editor.tsx index d08b47e..4f2041f 100644 --- a/src/component/Viewers/MarkdownEditor/Editor.tsx +++ b/src/component/Viewers/MarkdownEditor/Editor.tsx @@ -54,6 +54,8 @@ export interface MarkdownEditorProps { readOnly?: boolean; displayOnly?: boolean; onSaveShortcut?: () => void; + imageAutocompleteSuggestions?: string[] | null; + imagePreviewHandler?: (imageSource: string) => Promise; } 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(), diff --git a/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx b/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx index 670a475..b8a1768 100644 --- a/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx +++ b/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx @@ -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 ( { initialValue={value} onChange={(v) => onChange(v as string)} onSaveShortcut={onSaveShortcut} + imagePreviewHandler={imagePreviewHandler} + imageAutocompleteSuggestions={imageAutocompleteSuggestions} /> )} diff --git a/src/redux/thunks/viewer.ts b/src/redux/thunks/viewer.ts index 07288a3..e5a1e79 100644 --- a/src/redux/thunks/viewer.ts +++ b/src/redux/thunks/viewer.ts @@ -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 { return options ?? []; }; } + +const BROKEN_IMG_URI = + "data:image/svg+xml;charset=utf-8," + + encodeURIComponent(/* xml */ ` + + + ⚠️ + +`); + +export function markdownImagePreviewHandler(imageSource: string, mdFileUri: string): AppThunk> { + 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 { + 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); + }; +} diff --git a/src/util/uri.ts b/src/util/uri.ts index aed208e..f7c6f17 100644 --- a/src/util/uri.ts +++ b/src/util/uri.ts @@ -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("/")