feat: add shared resource actions for note media

This commit is contained in:
tianma-if committed 2026-08-04 11:59:16 +08:00
1 parent 543076c12a
commit 2e3678121b
3 files changed
+330 -149

No files matched your search

+300 -141
View File
@@ -167,17 +167,37 @@ type NoteLinkHintPosition = {
};
type AttachmentMenuTarget = {
href: string;
kind: "attachment";
url: string;
filename: string;
resourceId: string | null;
position: NoteLinkHintPosition;
};
type AttachmentDialogState = {
action: "rename" | "delete";
target: AttachmentMenuTarget;
type ImageMenuTarget = {
kind: "image";
url: string;
filename: string;
resourceId: string | null;
position: NoteLinkHintPosition;
updateAttributes: (attributes: Record<string, unknown>) => void;
deleteNode: () => void;
};
type ResourceMenuTarget = AttachmentMenuTarget | ImageMenuTarget;
type ResourceDialogState = {
action: "rename" | "delete";
target: ResourceMenuTarget;
};
type ImageMenuRequestDetail = Omit<ImageMenuTarget, "kind" | "position"> & {
element: HTMLElement;
};
const IMAGE_MENU_SHOW_EVENT = "edgeever:image-menu-show";
const IMAGE_MENU_HIDE_EVENT = "edgeever:image-menu-hide";
const getAttachmentLinkFromEventTarget = (target: EventTarget | null) =>
target instanceof Element
? target.closest<HTMLAnchorElement>(
@@ -222,27 +242,31 @@ const NoteLinkInteractionHint = ({
document.body
);
const AttachmentActionMenu = ({
const ResourceActionMenu = ({
target,
canMutate,
canRename,
canDelete,
labels,
onDownload,
onSaveAs,
onRename,
onDelete,
onMouseEnter,
onMouseLeave,
}: {
target: AttachmentMenuTarget;
canMutate: boolean;
labels: { download: string; rename: string; delete: string; unavailable: string };
target: ResourceMenuTarget;
canRename: boolean;
canDelete: boolean;
labels: { download: string; saveAs: string; rename: string; delete: string; unavailable: string };
onDownload: () => void;
onSaveAs: () => void;
onRename: () => void;
onDelete: () => void;
onMouseEnter: () => void;
onMouseLeave: (event: ReactMouseEvent<HTMLDivElement>) => void;
onMouseLeave: () => void;
}) => createPortal(
<div
data-edgeever-attachment-menu
data-edgeever-resource-menu
role="toolbar"
aria-label={labels.download}
className="fixed z-[110] flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-1 shadow-lg"
@@ -258,12 +282,16 @@ const AttachmentActionMenu = ({
<FileDown className="h-3.5 w-3.5" />
{labels.download}
</Button>
<Button type="button" size="sm" variant="ghost" title={labels.saveAs} onClick={onSaveAs}>
<Save className="h-3.5 w-3.5" />
{labels.saveAs}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
title={canMutate ? labels.rename : labels.unavailable}
disabled={!canMutate}
title={canRename ? labels.rename : labels.unavailable}
disabled={!canRename}
onClick={onRename}
>
<Pencil className="h-3.5 w-3.5" />
@@ -274,8 +302,8 @@ const AttachmentActionMenu = ({
size="sm"
variant="ghost"
className="text-slate-600 hover:bg-rose-50 hover:text-rose-600"
title={canMutate ? labels.delete : labels.unavailable}
disabled={!canMutate}
title={canDelete ? labels.delete : labels.unavailable}
disabled={!canDelete}
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -461,7 +489,7 @@ const getResourceFilesFromDataTransfer = (dataTransfer: DataTransfer | null) =>
return files.filter((file) => file.size > 0);
};
const ResizableImageNodeView = ({ editor, node, selected, updateAttributes }: NodeViewProps) => {
const ResizableImageNodeView = ({ editor, node, selected, updateAttributes, deleteNode }: NodeViewProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [previewWidth, setPreviewWidth] = useState<number | null>(null);
@@ -472,6 +500,25 @@ const ResizableImageNodeView = ({ editor, node, selected, updateAttributes }: No
const title = typeof node.attrs.title === "string" ? node.attrs.title : "";
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
const requestImageMenu = useCallback(() => {
const element = wrapperRef.current;
if (!element || !src) return;
window.dispatchEvent(new CustomEvent<ImageMenuRequestDetail>(IMAGE_MENU_SHOW_EVENT, {
detail: {
element,
url: src,
filename: title || alt || getAttachmentResourceId(src) || "image",
resourceId: getAttachmentResourceId(src),
updateAttributes,
deleteNode,
},
}));
}, [alt, deleteNode, src, title, updateAttributes]);
const hideImageMenu = useCallback(() => {
window.dispatchEvent(new CustomEvent(IMAGE_MENU_HIDE_EVENT));
}, []);
const updateWidth = useCallback(
(nextWidth: number) => {
updateAttributes({ width: clampImageWidth(nextWidth) });
@@ -534,6 +581,12 @@ const ResizableImageNodeView = ({ editor, node, selected, updateAttributes }: No
className={cn("edgeever-image-node", selected && "is-selected")}
style={{ width: `${width}%` }}
data-width={width}
onMouseEnter={requestImageMenu}
onMouseLeave={hideImageMenu}
onContextMenu={(event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
requestImageMenu();
}}
>
<img
src={src}
@@ -820,11 +873,11 @@ const RichEditorPane = ({
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false);
const [noteLinkQuery, setNoteLinkQuery] = useState("");
const [noteLinkHintPosition, setNoteLinkHintPosition] = useState<NoteLinkHintPosition | null>(null);
const [attachmentMenuTarget, setAttachmentMenuTarget] = useState<AttachmentMenuTarget | null>(null);
const [attachmentDialog, setAttachmentDialog] = useState<AttachmentDialogState | null>(null);
const [attachmentFilename, setAttachmentFilename] = useState("");
const [attachmentActionPending, setAttachmentActionPending] = useState(false);
const [attachmentActionError, setAttachmentActionError] = useState<string | null>(null);
const [resourceMenuTarget, setResourceMenuTarget] = useState<ResourceMenuTarget | null>(null);
const [resourceDialog, setResourceDialog] = useState<ResourceDialogState | null>(null);
const [resourceFilename, setResourceFilename] = useState("");
const [resourceActionPending, setResourceActionPending] = useState(false);
const [resourceActionError, setResourceActionError] = useState<string | null>(null);
const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window === "undefined" ? false : window.matchMedia(MOBILE_EDITOR_QUERY).matches
);
@@ -903,7 +956,7 @@ const RichEditorPane = ({
const hasUnsavedChangesRef = useRef(false);
const editingMemoIdRef = useRef<string | null>(memo?.id ?? null);
const imageCompressionEnabledRef = useRef(imageCompressionEnabled);
const attachmentMenuHideTimerRef = useRef<number | null>(null);
const resourceMenuHideTimerRef = useRef<number | null>(null);
useEffect(() => {
const handleMemoIdRemapped = (event: Event) => {
@@ -1232,20 +1285,47 @@ const RichEditorPane = ({
setNoteLinkQuery("");
}, [editor, effectiveReadOnly, memo?.id, t]);
const cancelAttachmentMenuHide = useCallback(() => {
if (attachmentMenuHideTimerRef.current !== null) {
window.clearTimeout(attachmentMenuHideTimerRef.current);
attachmentMenuHideTimerRef.current = null;
const cancelResourceMenuHide = useCallback(() => {
if (resourceMenuHideTimerRef.current !== null) {
window.clearTimeout(resourceMenuHideTimerRef.current);
resourceMenuHideTimerRef.current = null;
}
}, []);
const scheduleAttachmentMenuHide = useCallback(() => {
cancelAttachmentMenuHide();
attachmentMenuHideTimerRef.current = window.setTimeout(() => {
attachmentMenuHideTimerRef.current = null;
setAttachmentMenuTarget(null);
}, 120);
}, [cancelAttachmentMenuHide]);
const scheduleResourceMenuHide = useCallback(() => {
cancelResourceMenuHide();
resourceMenuHideTimerRef.current = window.setTimeout(() => {
resourceMenuHideTimerRef.current = null;
setResourceMenuTarget(null);
}, 160);
}, [cancelResourceMenuHide]);
useEffect(() => {
const showImageMenu = (event: Event) => {
if (isMobileViewport) return;
const detail = (event as CustomEvent<ImageMenuRequestDetail>).detail;
if (!detail?.element) return;
cancelResourceMenuHide();
const rect = detail.element.getBoundingClientRect();
const placement = rect.top < 56 ? "below" : "above";
setResourceMenuTarget({
...detail,
kind: "image",
position: {
left: Math.min(Math.max(rect.left + rect.width / 2, 12), window.innerWidth - 12),
top: placement === "above" ? rect.top - 8 : rect.bottom + 8,
placement,
},
});
};
const hideImageMenu = () => scheduleResourceMenuHide();
window.addEventListener(IMAGE_MENU_SHOW_EVENT, showImageMenu);
window.addEventListener(IMAGE_MENU_HIDE_EVENT, hideImageMenu);
return () => {
window.removeEventListener(IMAGE_MENU_SHOW_EVENT, showImageMenu);
window.removeEventListener(IMAGE_MENU_HIDE_EVENT, hideImageMenu);
};
}, [cancelResourceMenuHide, isMobileViewport, scheduleResourceMenuHide]);
const showAttachmentMenu = useCallback((target: EventTarget | null) => {
if (isMobileViewport) return false;
@@ -1253,16 +1333,17 @@ const RichEditorPane = ({
if (!link) return false;
const href = link.getAttribute("href") || "";
cancelAttachmentMenuHide();
cancelResourceMenuHide();
setNoteLinkHintPosition(null);
setAttachmentMenuTarget({
href,
setResourceMenuTarget({
kind: "attachment",
url: href,
filename: getAttachmentFilenameFromLabel(link.textContent || "") || getAttachmentResourceId(href) || "attachment",
resourceId: getAttachmentResourceId(href),
position: getNoteLinkHintPosition(link),
});
return true;
}, [cancelAttachmentMenuHide, isMobileViewport]);
}, [cancelResourceMenuHide, isMobileViewport]);
const showNoteLinkHint = useCallback((target: EventTarget | null) => {
if (!editor?.isEditable || isMobileViewport) {
@@ -1287,11 +1368,11 @@ const RichEditorPane = ({
if (
relatedTarget instanceof Node &&
(attachmentLink.contains(relatedTarget) ||
(relatedTarget instanceof Element && relatedTarget.closest("[data-edgeever-attachment-menu]")))
(relatedTarget instanceof Element && relatedTarget.closest("[data-edgeever-resource-menu]")))
) {
return;
}
scheduleAttachmentMenuHide();
scheduleResourceMenuHide();
return;
}
@@ -1300,7 +1381,7 @@ const RichEditorPane = ({
return;
}
setNoteLinkHintPosition(null);
}, [scheduleAttachmentMenuHide]);
}, [scheduleResourceMenuHide]);
const handleEditorClickCapture = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button === 0 && !event.ctrlKey && !event.metaKey) {
@@ -1334,25 +1415,25 @@ const RichEditorPane = ({
}, [noteLinkHintPosition]);
useEffect(() => {
if (!attachmentMenuTarget) return;
const hideMenu = () => setAttachmentMenuTarget(null);
if (!resourceMenuTarget) return;
const hideMenu = () => setResourceMenuTarget(null);
window.addEventListener("resize", hideMenu);
window.addEventListener("scroll", hideMenu, true);
return () => {
window.removeEventListener("resize", hideMenu);
window.removeEventListener("scroll", hideMenu, true);
};
}, [attachmentMenuTarget]);
}, [resourceMenuTarget]);
useEffect(() => {
setNoteLinkHintPosition(null);
setAttachmentMenuTarget(null);
setAttachmentDialog(null);
setResourceMenuTarget(null);
setResourceDialog(null);
}, [memo?.id, isMarkdownMode]);
useEffect(() => () => {
if (attachmentMenuHideTimerRef.current !== null) {
window.clearTimeout(attachmentMenuHideTimerRef.current);
if (resourceMenuHideTimerRef.current !== null) {
window.clearTimeout(resourceMenuHideTimerRef.current);
}
}, []);
@@ -2214,7 +2295,7 @@ const RichEditorPane = ({
const replaceAttachmentLabel = useCallback((target: AttachmentMenuTarget, filename: string) => {
const activeEditor = editorRef.current;
if (!isEditorReady(activeEditor)) return;
const range = findAttachmentLinkRange(activeEditor, target.href);
const range = findAttachmentLinkRange(activeEditor, target.url);
if (!range) return;
activeEditor.view.dispatch(
activeEditor.state.tr.replaceWith(
@@ -2228,7 +2309,7 @@ const RichEditorPane = ({
const removeAttachmentLink = useCallback((target: AttachmentMenuTarget) => {
const activeEditor = editorRef.current;
if (!isEditorReady(activeEditor)) return;
const range = findAttachmentLinkRange(activeEditor, target.href);
const range = findAttachmentLinkRange(activeEditor, target.url);
if (!range) return;
const resolved = activeEditor.state.doc.resolve(range.from);
@@ -2248,65 +2329,123 @@ const RichEditorPane = ({
activeEditor.view.dispatch(activeEditor.state.tr.delete(deleteFrom, deleteTo));
}, []);
const handleAttachmentDownload = useCallback(async (target: AttachmentMenuTarget) => {
setAttachmentMenuTarget(null);
setAttachmentActionError(null);
try {
const blob = await api.getResourceBlob(target.href);
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = target.filename || target.resourceId || "attachment";
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
} catch (error) {
setAttachmentActionError(error instanceof Error ? error.message : t("editor.attachmentActions.failed"));
}
}, [t]);
const getResourceActionFailure = useCallback((target: ResourceMenuTarget) =>
target.kind === "image" ? t("editor.imageActions.failed") : t("editor.attachmentActions.failed"), [t]);
const openAttachmentDialog = useCallback((action: AttachmentDialogState["action"], target: AttachmentMenuTarget) => {
setAttachmentMenuTarget(null);
setAttachmentActionError(null);
setAttachmentFilename(target.filename);
setAttachmentDialog({ action, target });
const fetchResourceBlob = useCallback(async (target: ResourceMenuTarget) => {
try {
return await api.getResourceBlob(target.url);
} catch (error) {
if (target.resourceId) throw error;
const response = await fetch(target.url);
if (!response.ok) throw new Error(response.statusText || getResourceActionFailure(target));
return response.blob();
}
}, [getResourceActionFailure]);
const downloadBlob = useCallback((blob: Blob, filename: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}, []);
const handleAttachmentRename = useCallback(async () => {
const target = attachmentDialog?.action === "rename" ? attachmentDialog.target : null;
const filename = attachmentFilename.trim();
if (!target?.resourceId || !filename || attachmentActionPending) return;
const handleResourceDownload = useCallback(async (target: ResourceMenuTarget) => {
setResourceMenuTarget(null);
setResourceActionError(null);
try {
downloadBlob(await fetchResourceBlob(target), target.filename);
} catch (error) {
setResourceActionError(error instanceof Error ? error.message : getResourceActionFailure(target));
}
}, [downloadBlob, fetchResourceBlob, getResourceActionFailure]);
setAttachmentActionPending(true);
setAttachmentActionError(null);
const handleResourceSaveAs = useCallback(async (target: ResourceMenuTarget) => {
setResourceMenuTarget(null);
setResourceActionError(null);
try {
const savePicker = (window as Window & {
showSaveFilePicker?: (options: { suggestedName: string }) => Promise<{
createWritable: () => Promise<{ write: (data: Blob) => Promise<void>; close: () => Promise<void> }>;
}>;
}).showSaveFilePicker;
if (!savePicker) {
downloadBlob(await fetchResourceBlob(target), target.filename);
return;
}
const handle = await savePicker.call(window, { suggestedName: target.filename });
const blob = await fetchResourceBlob(target);
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
setResourceActionError(error instanceof Error ? error.message : getResourceActionFailure(target));
}
}, [downloadBlob, fetchResourceBlob, getResourceActionFailure]);
const openResourceDialog = useCallback((action: ResourceDialogState["action"], target: ResourceMenuTarget) => {
setResourceMenuTarget(null);
setResourceActionError(null);
setResourceFilename(target.filename);
setResourceDialog({ action, target });
}, []);
const handleResourceRename = useCallback(async () => {
const target = resourceDialog?.action === "rename" ? resourceDialog.target : null;
const filename = resourceFilename.trim();
if (!target?.resourceId || !filename || resourceActionPending) return;
setResourceActionPending(true);
setResourceActionError(null);
try {
const result = await repository.renameResource(target.resourceId, filename);
replaceAttachmentLabel(target, result.resource.filename || filename);
const nextFilename = result.resource.filename || filename;
if (target.kind === "image") {
target.updateAttributes({ alt: nextFilename, title: nextFilename });
} else {
replaceAttachmentLabel(target, nextFilename);
}
await queryClient.invalidateQueries({ queryKey: ["resources"] });
setAttachmentDialog(null);
setResourceDialog(null);
} catch (error) {
setAttachmentActionError(error instanceof Error ? error.message : t("editor.attachmentActions.failed"));
setResourceActionError(error instanceof Error ? error.message : getResourceActionFailure(target));
} finally {
setAttachmentActionPending(false);
setResourceActionPending(false);
}
}, [attachmentActionPending, attachmentDialog, attachmentFilename, queryClient, replaceAttachmentLabel, repository, t]);
}, [getResourceActionFailure, queryClient, replaceAttachmentLabel, repository, resourceActionPending, resourceDialog, resourceFilename]);
const handleAttachmentDelete = useCallback(async () => {
const target = attachmentDialog?.action === "delete" ? attachmentDialog.target : null;
if (!target?.resourceId || attachmentActionPending) return;
const handleResourceDelete = useCallback(async () => {
const target = resourceDialog?.action === "delete" ? resourceDialog.target : null;
if (!target || resourceActionPending) return;
setAttachmentActionPending(true);
setAttachmentActionError(null);
setResourceActionPending(true);
setResourceActionError(null);
try {
await repository.deleteResource(target.resourceId);
removeAttachmentLink(target);
await queryClient.invalidateQueries({ queryKey: ["resources"] });
setAttachmentDialog(null);
if (target.kind === "image" && target.url.startsWith("edgeever-staged://") && window.edgeeverDesktop) {
const stagedUrl = new URL(target.url);
const stagedId = decodeURIComponent(stagedUrl.hostname || stagedUrl.pathname.replace(/^\//, ""));
if (stagedId) await window.edgeeverDesktop.removeStagedResource(stagedId);
} else if (target.resourceId && !target.resourceId.startsWith("local_resource_")) {
await repository.deleteResource(target.resourceId);
await queryClient.invalidateQueries({ queryKey: ["resources"] });
}
if (target.kind === "image") {
target.deleteNode();
} else {
removeAttachmentLink(target);
}
setResourceDialog(null);
} catch (error) {
setAttachmentActionError(error instanceof Error ? error.message : t("editor.attachmentActions.failed"));
setResourceActionError(error instanceof Error ? error.message : getResourceActionFailure(target));
} finally {
setAttachmentActionPending(false);
setResourceActionPending(false);
}
}, [attachmentActionPending, attachmentDialog, queryClient, removeAttachmentLink, repository, t]);
}, [getResourceActionFailure, queryClient, removeAttachmentLink, repository, resourceActionPending, resourceDialog]);
const clearMobileEditorTimers = useCallback(() => {
if (mobileDraftTimerRef.current !== null) {
@@ -2658,6 +2797,29 @@ const RichEditorPane = ({
});
};
const resourceMenuLabels = {
download: t("editor.resourceActions.download"),
saveAs: t("editor.resourceActions.saveAs"),
rename: t("editor.resourceActions.rename"),
delete: t("editor.resourceActions.delete"),
unavailable: t("editor.resourceActions.unavailable"),
};
const resourceDialogLabels = resourceDialog?.target.kind === "image"
? {
renameTitle: t("editor.imageActions.renameTitle"),
renameDescription: t("editor.imageActions.renameDescription"),
filenameLabel: t("editor.imageActions.filenameLabel"),
deleteTitle: t("editor.imageActions.deleteTitle"),
deleteDescription: t("editor.imageActions.deleteDescription"),
}
: {
renameTitle: t("editor.attachmentActions.renameTitle"),
renameDescription: t("editor.attachmentActions.renameDescription"),
filenameLabel: t("editor.attachmentActions.filenameLabel"),
deleteTitle: t("editor.attachmentActions.deleteTitle"),
deleteDescription: t("editor.attachmentActions.deleteDescription"),
};
return (
<div className="relative flex h-full min-w-0 flex-col bg-white">
{selectionActionBar}
@@ -3361,39 +3523,36 @@ const RichEditorPane = ({
/>
)}
{attachmentMenuTarget && (
<AttachmentActionMenu
target={attachmentMenuTarget}
canMutate={Boolean(
attachmentMenuTarget.resourceId &&
!attachmentMenuTarget.resourceId.startsWith("local_resource_") &&
{resourceMenuTarget && (
<ResourceActionMenu
target={resourceMenuTarget}
canRename={Boolean(
resourceMenuTarget.resourceId &&
!resourceMenuTarget.resourceId.startsWith("local_resource_") &&
editor?.isEditable &&
!effectiveReadOnly
)}
labels={{
download: t("editor.attachmentActions.download"),
rename: t("editor.attachmentActions.rename"),
delete: t("editor.attachmentActions.delete"),
unavailable: t("editor.attachmentActions.unavailableUntilSynced"),
}}
onDownload={() => void handleAttachmentDownload(attachmentMenuTarget)}
onRename={() => openAttachmentDialog("rename", attachmentMenuTarget)}
onDelete={() => openAttachmentDialog("delete", attachmentMenuTarget)}
onMouseEnter={cancelAttachmentMenuHide}
onMouseLeave={(event) => {
if (getAttachmentLinkFromEventTarget(event.relatedTarget)) {
cancelAttachmentMenuHide();
return;
}
scheduleAttachmentMenuHide();
}}
canDelete={Boolean(
editor?.isEditable &&
!effectiveReadOnly &&
(resourceMenuTarget.kind === "image" || (
resourceMenuTarget.resourceId && !resourceMenuTarget.resourceId.startsWith("local_resource_")
))
)}
labels={resourceMenuLabels}
onDownload={() => void handleResourceDownload(resourceMenuTarget)}
onSaveAs={() => void handleResourceSaveAs(resourceMenuTarget)}
onRename={() => openResourceDialog("rename", resourceMenuTarget)}
onDelete={() => openResourceDialog("delete", resourceMenuTarget)}
onMouseEnter={cancelResourceMenuHide}
onMouseLeave={scheduleResourceMenuHide}
/>
)}
<Dialog
open={attachmentDialog?.action === "rename"}
open={resourceDialog?.action === "rename"}
onOpenChange={(open) => {
if (!open && !attachmentActionPending) setAttachmentDialog(null);
if (!open && !resourceActionPending) setResourceDialog(null);
}}
>
<DialogContent>
@@ -3401,32 +3560,32 @@ const RichEditorPane = ({
className="contents"
onSubmit={(event) => {
event.preventDefault();
void handleAttachmentRename();
void handleResourceRename();
}}
>
<DialogHeader>
<DialogTitle>{t("editor.attachmentActions.renameTitle")}</DialogTitle>
<DialogDescription>{t("editor.attachmentActions.renameDescription")}</DialogDescription>
<DialogTitle>{resourceDialogLabels.renameTitle}</DialogTitle>
<DialogDescription>{resourceDialogLabels.renameDescription}</DialogDescription>
</DialogHeader>
<label className="grid gap-2 text-sm font-medium text-slate-700">
{t("editor.attachmentActions.filenameLabel")}
{resourceDialogLabels.filenameLabel}
<Input
autoFocus
value={attachmentFilename}
value={resourceFilename}
maxLength={160}
disabled={attachmentActionPending}
onChange={(event) => setAttachmentFilename(event.target.value)}
disabled={resourceActionPending}
onChange={(event) => setResourceFilename(event.target.value)}
/>
</label>
{attachmentActionError && (
<p className="text-sm text-rose-600" role="alert">{attachmentActionError}</p>
{resourceActionError && (
<p className="text-sm text-rose-600" role="alert">{resourceActionError}</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" disabled={attachmentActionPending} onClick={() => setAttachmentDialog(null)}>
<Button type="button" variant="ghost" disabled={resourceActionPending} onClick={() => setResourceDialog(null)}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="solid" disabled={attachmentActionPending || !attachmentFilename.trim()}>
{attachmentActionPending ? t("common.saving") : t("common.save")}
<Button type="submit" variant="solid" disabled={resourceActionPending || !resourceFilename.trim()}>
{resourceActionPending ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</form>
@@ -3434,36 +3593,36 @@ const RichEditorPane = ({
</Dialog>
<Dialog
open={attachmentDialog?.action === "delete"}
open={resourceDialog?.action === "delete"}
onOpenChange={(open) => {
if (!open && !attachmentActionPending) setAttachmentDialog(null);
if (!open && !resourceActionPending) setResourceDialog(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("editor.attachmentActions.deleteTitle")}</DialogTitle>
<DialogDescription>{t("editor.attachmentActions.deleteDescription")}</DialogDescription>
<DialogTitle>{resourceDialogLabels.deleteTitle}</DialogTitle>
<DialogDescription>{resourceDialogLabels.deleteDescription}</DialogDescription>
</DialogHeader>
<p className="truncate rounded-md bg-slate-50 px-3 py-2 text-sm font-medium text-slate-700">
{attachmentDialog?.target.filename}
{resourceDialog?.target.filename}
</p>
{attachmentActionError && (
<p className="text-sm text-rose-600" role="alert">{attachmentActionError}</p>
{resourceActionError && (
<p className="text-sm text-rose-600" role="alert">{resourceActionError}</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" disabled={attachmentActionPending} onClick={() => setAttachmentDialog(null)}>
<Button type="button" variant="ghost" disabled={resourceActionPending} onClick={() => setResourceDialog(null)}>
{t("common.cancel")}
</Button>
<Button type="button" variant="danger" disabled={attachmentActionPending} onClick={() => void handleAttachmentDelete()}>
{attachmentActionPending ? t("common.processing") : t("common.delete")}
<Button type="button" variant="danger" disabled={resourceActionPending} onClick={() => void handleResourceDelete()}>
{resourceActionPending ? t("common.processing") : t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{attachmentActionError && !attachmentDialog && (
{resourceActionError && !resourceDialog && (
<div className="fixed bottom-5 left-1/2 z-[120] -translate-x-1/2 rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white shadow-lg" role="alert">
{attachmentActionError}
{resourceActionError}
</div>
)}
+15 -4
View File
@@ -581,6 +581,21 @@ export const enUS = {
imageSizeLarge: "Large",
imageSizeFull: "Full",
resizeImage: "Drag to resize image width",
resourceActions: {
download: "Download",
saveAs: "Save as",
rename: "Rename",
delete: "Delete",
unavailable: "This action is available after the resource has synced and the note is editable",
},
imageActions: {
renameTitle: "Rename image",
renameDescription: "The new filename will be synced to the image resource and the current note.",
filenameLabel: "Filename",
deleteTitle: "Delete image",
deleteDescription: "The image will be permanently removed from storage and this note. This cannot be undone.",
failed: "The image action failed. Try again.",
},
currentNotebook: "Current notebook",
close: "Close",
searchNotebook: "Search notebooks",
@@ -590,15 +605,11 @@ export const enUS = {
attachmentLabel: "Attachment: {{filename}}",
attachmentInsertText: "Attachment: {{filename}} {{url}}",
attachmentActions: {
download: "Download",
rename: "Rename",
delete: "Delete",
renameTitle: "Rename attachment",
renameDescription: "The new filename will be synced to the attachment and the current note.",
filenameLabel: "Filename",
deleteTitle: "Delete attachment",
deleteDescription: "The attachment will be permanently removed from storage and this note. This cannot be undone.",
unavailableUntilSynced: "This action is available after the attachment has synced",
failed: "The attachment action failed. Try again.",
},
placeholder: "Start writing...",
+15 -4
View File
@@ -581,6 +581,21 @@ export const zhCN = {
imageSizeLarge: "较大",
imageSizeFull: "铺满",
resizeImage: "拖拽调整图片宽度",
resourceActions: {
download: "下载",
saveAs: "另存为",
rename: "重命名",
delete: "删除",
unavailable: "资源同步完成且当前笔记可编辑后才能执行此操作",
},
imageActions: {
renameTitle: "重命名图片",
renameDescription: "新的文件名会同步到图片资源和当前笔记。",
filenameLabel: "文件名",
deleteTitle: "删除图片",
deleteDescription: "图片会从存储空间和当前笔记中永久删除,此操作无法撤销。",
failed: "图片操作失败,请重试。",
},
currentNotebook: "所在笔记本",
close: "关闭",
searchNotebook: "搜索笔记本",
@@ -590,15 +605,11 @@ export const zhCN = {
attachmentLabel: "附件:{{filename}}",
attachmentInsertText: "附件:{{filename}} {{url}}",
attachmentActions: {
download: "下载",
rename: "重命名",
delete: "删除",
renameTitle: "重命名附件",
renameDescription: "新的文件名会同步到附件和当前笔记。",
filenameLabel: "文件名",
deleteTitle: "删除附件",
deleteDescription: "附件会从存储空间和当前笔记中永久删除,此操作无法撤销。",
unavailableUntilSynced: "附件同步完成后才能执行此操作",
failed: "附件操作失败,请重试。",
},
placeholder: "开始记录...",