Skip to content

feat: paste to insert image or files into text content #4698

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const EmbeddedMemo = observer(({ resourceId: uid, params: paramsStr }: Props) =>
nodes={memo.nodes}
embeddedMemos={context.embeddedMemos}
/>
<MemoResourceListView resources={memo.resources} />
<MemoResourceListView memo={memo} resources={memo.resources} noThumbnailForEmbedded />
</>
);
if (inlineMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ interface Props {

const getAdditionalClassNameWithParams = (params: URLSearchParams) => {
const additionalClassNames = [];
if (params.has("inline")) {
additionalClassNames.push("inline-block");
}
if (params.has("align")) {
const align = params.get("align");
if (align === "center") {
Expand All @@ -38,7 +41,8 @@ const getAdditionalClassNameWithParams = (params: URLSearchParams) => {

const EmbeddedResource = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const loadingState = useLoading();
const resource = resourceStore.getResourceByName(uid);
const resourceStore = useResourceStore();
const resource = resourceStore.getResourceByName(`resources/${uid}`);
const params = new URLSearchParams(paramsStr);

useEffect(() => {
Expand All @@ -54,7 +58,7 @@ const EmbeddedResource = observer(({ resourceId: uid, params: paramsStr }: Props

return (
<div className={cn("max-w-full", getAdditionalClassNameWithParams(params))}>
<MemoResourceListView resources={[resource]} />
<MemoResourceListView resources={[resource]} allowFullWidth={params.has("inline")} />
</div>
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import EmbeddedResource from "../EmbeddedContent/EmbeddedResource";

interface Props {
resourceId: string;
params: string;
}

const ReferencedResource = ({ resourceId: uid, params }: Props) => {
return <EmbeddedResource resourceId={uid} params={`inline&${params}`} />;
};

export default ReferencedResource;
4 changes: 4 additions & 0 deletions web/src/components/MemoContent/ReferencedContent/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Error from "./Error";
import ReferencedMemo from "./ReferencedMemo";
import ReferencedResource from "./ReferencedResource";

interface Props {
resourceName: string;
Expand All @@ -16,6 +17,9 @@ const ReferencedContent = ({ resourceName, params }: Props) => {
if (resourceType === "memos") {
return <ReferencedMemo resourceId={resourceId} params={params} />;
}
if (resourceType === "resources") {
return <ReferencedResource resourceId={resourceId} params={params} />;
}
return <Error message={`Unknown resource: ${resourceName}`} />;
};

Expand Down
43 changes: 41 additions & 2 deletions web/src/components/MemoEditor/ResourceListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,30 @@ import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSens
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { XIcon } from "lucide-react";
import { Resource } from "@/types/proto/api/v1/resource_service";
import { useTranslate } from "@/utils/i18n";
import { getResourceUrl } from "@/utils/resource";
import ResourceIcon from "../ResourceIcon";
import SortableItem from "./SortableItem";

interface Props {
resourceList: Resource[];
setResourceList: (resourceList: Resource[]) => void;
checkIfSafeToDeleteResource?: (resource: Resource) => boolean;
}

const ResourceListView = (props: Props) => {
const { resourceList, setResourceList } = props;
const { resourceList, setResourceList, checkIfSafeToDeleteResource } = props;
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const t = useTranslate();

const handleDeleteResource = async (name: string) => {
if (
typeof checkIfSafeToDeleteResource === "function" &&
!checkIfSafeToDeleteResource(resourceList.find((resource) => resource.name === name)!)
) {
const confirmationText = t("resource.delete-confirm-referenced");
if (!window.confirm(confirmationText)) return;
}
setResourceList(resourceList.filter((resource) => resource.name !== name));
};

Expand Down Expand Up @@ -42,7 +53,14 @@ const ResourceListView = (props: Props) => {
>
<SortableItem id={resource.name} className="flex flex-row justify-start items-center gap-x-1">
<ResourceIcon resource={resource} className="!w-4 !h-4 !opacity-100" />
<span className="text-sm max-w-[8rem] truncate">{resource.filename}</span>
<a
className="text-sm max-w-[8rem] truncate"
href={getResourceUrl(resource)}
target="_blank"
onPointerDown={preventLinkOpen}
>
{resource.filename}
</a>
</SortableItem>
<button className="shrink-0" onClick={() => handleDeleteResource(resource.name)}>
<XIcon className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-100" />
Expand All @@ -57,4 +75,25 @@ const ResourceListView = (props: Props) => {
);
};

const preventLinkOpen: React.PointerEventHandler = (e) => {
if (e.pointerType === "mouse" && (e.button !== 0 || e.metaKey || e.ctrlKey)) return;

const pointerId = e.pointerId;
const target = e.currentTarget;
const href = target.getAttribute("href");
if (!href) return;

function reset(ev: PointerEvent) {
if (ev.pointerId !== pointerId) return;

ev.preventDefault();
setTimeout(() => target.setAttribute("href", href!), 100);
window.removeEventListener("pointerup", reset, true);
window.removeEventListener("pointercancel", reset, true);
}
target.removeAttribute("href");
window.addEventListener("pointerup", reset, true);
window.addEventListener("pointercancel", reset, true);
};

export default ResourceListView;
43 changes: 43 additions & 0 deletions web/src/components/MemoEditor/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Resource } from "@/types/proto/api/v1/resource_service";
import { EditorRefActions } from "./Editor";

export const handleEditorKeydownWithMarkdownShortcuts = (event: React.KeyboardEvent, editorRef: EditorRefActions) => {
Expand Down Expand Up @@ -50,3 +51,45 @@ const styleHighlightedText = (editor: EditorRefActions, delimiter: string) => {
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
}
};

export function insertResourceText(editor: EditorRefActions, resources: Resource[], placeholder: string) {
if (!placeholder) return;

let text = editor.getContent();
const pos = text.indexOf(placeholder);
if (pos === -1) return;

const insertingParts: string[] = [];
for (const res of resources) {
insertingParts.push(`[[${res.name}?name=${encodeURIComponent(res.filename)}]]`);

// -----
// or create a normal Markdown?

// const isImage = String(res.type).startsWith("image/");
// const title = res.filename;
// const url = getResourceUrl(res);

// let part = `[${title}](${url})`;
// if (isImage) part = `!${part}`;

// insertingParts.push(part);
}
const inserting = insertingParts.join(" ");

// compute new cursorPos
let cursorPos = editor.getCursorPosition();
let selectionLength = 0;

if (cursorPos > pos + placeholder.length) {
cursorPos += inserting.length - placeholder.length;
} else if (cursorPos >= pos) {
cursorPos = pos;
selectionLength = inserting.length;
}

text = text.slice(0, pos) + inserting + text.slice(pos + placeholder.length);

editor.setContent(text);
editor.setCursorPosition(cursorPos, cursorPos + selectionLength);
}
35 changes: 31 additions & 4 deletions web/src/components/MemoEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Resource } from "@/types/proto/api/v1/resource_service";
import { UserSetting } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import { isResourceEmbeddedInContent } from "@/utils/resource";
import VisibilityIcon from "../VisibilityIcon";
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover";
import LocationSelector from "./ActionButton/LocationSelector";
Expand All @@ -28,7 +29,7 @@ import UploadResourceButton from "./ActionButton/UploadResourceButton";
import Editor, { EditorRefActions } from "./Editor";
import RelationListView from "./RelationListView";
import ResourceListView from "./ResourceListView";
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText, insertResourceText } from "./handlers";
import { MemoEditorContext } from "./types";
import "react-datepicker/dist/react-datepicker.css";

Expand Down Expand Up @@ -186,6 +187,11 @@ const MemoEditor = observer((props: Props) => {
}));
};

const checkIfSafeToDeleteResource = (resource: Resource): boolean => {
const content = editorRef.current?.getContent();
return !isResourceEmbeddedInContent(content, resource);
};

const handleSetRelationList = (relationList: MemoRelation[]) => {
setState((prevState) => ({
...prevState,
Expand Down Expand Up @@ -232,7 +238,7 @@ const MemoEditor = observer((props: Props) => {
}
};

const uploadMultiFiles = async (files: FileList) => {
const uploadMultiFiles = async (files: FileList): Promise<Resource[]> => {
const uploadedResourceList: Resource[] = [];
for (const file of files) {
const resource = await handleUploadResource(file);
Expand All @@ -255,6 +261,7 @@ const MemoEditor = observer((props: Props) => {
resourceList: [...prevState.resourceList, ...uploadedResourceList],
}));
}
return uploadedResourceList;
};

const handleDropEvent = async (event: React.DragEvent) => {
Expand Down Expand Up @@ -293,7 +300,23 @@ const MemoEditor = observer((props: Props) => {
const handlePasteEvent = async (event: React.ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files.length > 0) {
event.preventDefault();
await uploadMultiFiles(event.clipboardData.files);

const editor = editorRef.current;
let placeholder = "";

if (editor) {
// create a placeholder for uploaded files, and select it.
placeholder = `[[resources/${Date.now()}?uploading]]`;
const position = editor.getCursorPosition();
editor.insertText(placeholder);
editor.setCursorPosition(position, position + placeholder.length);
}

const resources = await uploadMultiFiles(event.clipboardData.files);

if (editor) {
insertResourceText(editor, resources, placeholder);
}
} else if (
editorRef.current != null &&
editorRef.current.getSelectedContent().length != 0 &&
Expand Down Expand Up @@ -498,7 +521,11 @@ const MemoEditor = observer((props: Props) => {
/>
)}
<Editor ref={editorRef} {...editorConfig} />
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
<ResourceListView
resourceList={state.resourceList}
setResourceList={handleSetResourceList}
checkIfSafeToDeleteResource={checkIfSafeToDeleteResource}
/>
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
<div className="relative w-full flex flex-row justify-between items-center pt-2" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center opacity-80 dark:opacity-60 space-x-2">
Expand Down
8 changes: 2 additions & 6 deletions web/src/components/MemoResource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,16 @@ const MemoResource: React.FC<Props> = (props: Props) => {
const { className, resource } = props;
const resourceUrl = getResourceUrl(resource);

const handlePreviewBtnClick = () => {
window.open(resourceUrl);
};

return (
<div className={`w-auto flex flex-row justify-start items-center text-gray-500 dark:text-gray-400 hover:opacity-80 ${className}`}>
{resource.type.startsWith("audio") ? (
<audio src={resourceUrl} controls></audio>
) : (
<>
<ResourceIcon className="!w-4 !h-4 mr-1" resource={resource} />
<span className="text-sm max-w-[256px] truncate cursor-pointer" onClick={handlePreviewBtnClick}>
<a className="text-sm max-w-[256px] truncate cursor-pointer" target="_blank" href={resourceUrl}>
{resource.filename}
</span>
</a>
</>
)}
</div>
Expand Down
30 changes: 25 additions & 5 deletions web/src/components/MemoResourceListView.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
import { memo } from "react";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { Resource } from "@/types/proto/api/v1/resource_service";
import { cn } from "@/utils";
import { getResourceType, getResourceUrl } from "@/utils/resource";
import { getResourceType, getResourceUrl, isResourceEmbeddedInContent } from "@/utils/resource";
import MemoResource from "./MemoResource";
import showPreviewImageDialog from "./PreviewImageDialog";

const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) => {
const MemoResourceListView = ({
memo,
resources = [],
noThumbnailForEmbedded,
allowFullWidth,
}: {
memo?: Memo;
resources: Resource[];
noThumbnailForEmbedded?: boolean;
allowFullWidth?: boolean;
}) => {
const mediaResources: Resource[] = [];
const otherResources: Resource[] = [];

resources.forEach((resource) => {
const type = getResourceType(resource);
if (type === "image/*" || type === "video/*") {
mediaResources.push(resource);
return;
let useThumbnail = true;
if (memo && noThumbnailForEmbedded) useThumbnail = !isResourceEmbeddedInContent(memo.content, resource);

if (useThumbnail) {
mediaResources.push(resource);
return;
}
}

otherResources.push(resource);
Expand All @@ -37,6 +53,7 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) =>
className={cn("cursor-pointer h-full w-auto rounded-lg border dark:border-zinc-800 object-contain hover:opacity-80", className)}
src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"}
onClick={() => handleImageClick(resourceUrl)}
data-is-resource-media
decoding="async"
loading="lazy"
/>
Expand All @@ -61,7 +78,10 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) =>

const MediaList = ({ resources = [] }: { resources: Resource[] }) => {
const cards = resources.map((resource) => (
<div key={resource.name} className="max-w-[70%] grow flex flex-col justify-start items-start shrink-0">
<div
key={resource.name}
className={cn(allowFullWidth ? "max-w-full" : "max-w-[70%]", "grow flex flex-col justify-start items-start shrink-0")}
>
<MediaCard className="max-h-64 grow" resource={resource} />
</div>
));
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/MemoView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;

if (targetEl.tagName === "IMG") {
if (targetEl.tagName === "IMG" && !targetEl.hasAttribute("data-is-resource-media")) {
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) {
showPreviewImageDialog([imgUrl], 0);
Expand Down Expand Up @@ -231,7 +231,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
parentPage={parentPage}
/>
{memo.location && <MemoLocationView location={memo.location} />}
<MemoResourceListView resources={memo.resources} />
<MemoResourceListView memo={memo} resources={memo.resources} noThumbnailForEmbedded />
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
</div>
Expand Down
1 change: 1 addition & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"title": "Create Resource",
"upload-method": "Upload method"
},
"delete-confirm-referenced": "Resource is referenced in memo content, delete anyway?",
"delete-resource": "Delete Resource",
"delete-selected-resources": "Delete Selected Resources",
"fetching-data": "Fetching data…",
Expand Down
1 change: 1 addition & 0 deletions web/src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"title": "创建资源",
"upload-method": "上传方式"
},
"delete-confirm-referenced": "资源在正文已中被引用,确定要删除吗?",
"delete-resource": "删除资源",
"delete-selected-resources": "删除选中资源",
"fetching-data": "正在获取数据…",
Expand Down
Loading