feat(mobile): distinguish attachment file types

This commit is contained in:
tianma-if committed 2026-08-28 00:26:12 +08:00
1 parent b1f240d6db
commit 7f5328e232
6 files changed
+1165 -514

No files matched your search

File diff suppressed because it is too large. Load diff
+24
View File
@@ -12,6 +12,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import mermaid from "mermaid";
import { toCanvas } from "html-to-image";
import { resolveAttachmentKind } from "@edgeever/shared";
import {
type NoteImageTheme,
type NoteImageFontStyle,
@@ -144,6 +145,27 @@ function buildAttachmentTargetJson(href: string, label: string): string | null {
});
}
const ATTACHMENT_KIND_CLASS_PREFIX = "edgeever-attachment-kind-";
function normalizeAttachmentFilename(label: string): string {
return label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim();
}
function decorateAttachmentLinks(root: ParentNode): void {
root.querySelectorAll<HTMLAnchorElement>(
'a.edgeever-attachment-link, a[href*="/api/v1/resources/"]'
).forEach((link) => {
Array.from(link.classList).forEach((className) => {
if (className.startsWith(ATTACHMENT_KIND_CLASS_PREFIX)) link.classList.remove(className);
});
const filename = normalizeAttachmentFilename(link.textContent || "");
link.classList.add(
"edgeever-attachment-link",
`${ATTACHMENT_KIND_CLASS_PREFIX}${resolveAttachmentKind(null, filename)}`,
);
});
}
type ConfigureOptions = {
mode?: "viewer" | "editor";
locale?: string;
@@ -705,6 +727,7 @@ const editor = new Editor({
content: { type: "doc", content: [{ type: "paragraph" }] },
onUpdate: ({ editor: ed }) => {
refreshToolbarState();
requestAnimationFrame(() => decorateAttachmentLinks(editorEl));
if (suppressChange || mode !== "editor") return;
emitChange(ed);
},
@@ -1111,6 +1134,7 @@ async function afterContentSet(theme: "light" | "dark" = "light") {
editorEl.querySelectorAll<HTMLElement>("[data-placeholder]").forEach((element) => {
element.dataset.placeholder = currentPlaceholder;
});
decorateAttachmentLinks(editorEl);
await hydrateProtectedImages(editorEl);
if (mode === "viewer") {
await renderMermaidBlocks(editorEl, theme);
+100
View File
@@ -493,9 +493,109 @@ html[data-theme="dark"] .ProseMirror a:active {
.ProseMirror a.edgeever-attachment-link,
.edgeever-prose a[href*="/api/v1/resources/"],
.ProseMirror a[href*="/api/v1/resources/"] {
display: flex;
min-width: 0;
max-width: 100%;
min-height: 48px;
align-items: center;
gap: 8px;
margin: 6px 0;
overflow: hidden;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 7px 10px;
background: #f8fafc;
color: #1e293b;
font-size: 0.93rem;
font-weight: 700;
line-height: 1.3;
text-decoration: none;
}
.edgeever-prose a.edgeever-attachment-link::before,
.ProseMirror a.edgeever-attachment-link::before,
.edgeever-prose a[href*="/api/v1/resources/"]::before,
.ProseMirror a[href*="/api/v1/resources/"]::before {
display: inline-flex;
width: 30px;
height: 30px;
flex: 0 0 30px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
content: "FILE";
font-size: 8px;
font-weight: 800;
letter-spacing: -0.2px;
}
.edgeever-prose a.edgeever-attachment-link::after,
.ProseMirror a.edgeever-attachment-link::after,
.edgeever-prose a[href*="/api/v1/resources/"]::after,
.ProseMirror a[href*="/api/v1/resources/"]::after {
margin-left: auto;
flex: 0 0 auto;
color: #64748b;
content: "⋯";
font-size: 18px;
font-weight: 700;
}
.edgeever-prose a.edgeever-attachment-kind-image::before,
.ProseMirror a.edgeever-attachment-kind-image::before { background: #ecfdf5; color: #10b981; content: "▧"; font-size: 20px; }
.edgeever-prose a.edgeever-attachment-kind-audio::before,
.ProseMirror a.edgeever-attachment-kind-audio::before { background: #f0f9ff; color: #0ea5e9; content: "♪"; font-size: 21px; }
.edgeever-prose a.edgeever-attachment-kind-video::before,
.ProseMirror a.edgeever-attachment-kind-video::before { background: #fff1f2; color: #f43f5e; content: "▶"; font-size: 15px; }
.edgeever-prose a.edgeever-attachment-kind-pdf::before,
.ProseMirror a.edgeever-attachment-kind-pdf::before { background: #fff1f2; color: #e11d48; content: "PDF"; }
.edgeever-prose a.edgeever-attachment-kind-spreadsheet::before,
.ProseMirror a.edgeever-attachment-kind-spreadsheet::before { background: #f0fdf4; color: #16a34a; content: "XLS"; }
.edgeever-prose a.edgeever-attachment-kind-document::before,
.ProseMirror a.edgeever-attachment-kind-document::before { background: #eff6ff; color: #2563eb; content: "DOC"; }
.edgeever-prose a.edgeever-attachment-kind-presentation::before,
.ProseMirror a.edgeever-attachment-kind-presentation::before { background: #fff7ed; color: #f97316; content: "PPT"; }
.edgeever-prose a.edgeever-attachment-kind-archive::before,
.ProseMirror a.edgeever-attachment-kind-archive::before { background: #fffbeb; color: #d97706; content: "ZIP"; }
.edgeever-prose a.edgeever-attachment-kind-code::before,
.ProseMirror a.edgeever-attachment-kind-code::before { background: #faf5ff; color: #8b5cf6; content: "</>"; }
.edgeever-prose a.edgeever-attachment-kind-text::before,
.ProseMirror a.edgeever-attachment-kind-text::before { background: #f1f5f9; color: #64748b; content: "TXT"; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-link,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-link,
html[data-theme="dark"] .edgeever-prose a[href*="/api/v1/resources/"],
html[data-theme="dark"] .ProseMirror a[href*="/api/v1/resources/"] {
border-color: #334155;
background: #172033;
color: #f1f5f9;
}
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-link::after,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-link::after { color: #94a3b8; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-image::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-image::before { background: #064e3b; color: #6ee7b7; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-audio::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-audio::before { background: #0c4a6e; color: #7dd3fc; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-video::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-video::before,
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-pdf::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-pdf::before { background: #881337; color: #fda4af; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-spreadsheet::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-spreadsheet::before { background: #14532d; color: #86efac; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-document::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-document::before { background: #1e3a8a; color: #93c5fd; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-presentation::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-presentation::before { background: #7c2d12; color: #fdba74; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-archive::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-archive::before { background: #713f12; color: #fde68a; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-code::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-code::before { background: #581c87; color: #d8b4fe; }
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-text::before,
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-text::before { background: #334155; color: #cbd5e1; }
.ProseMirror hr {
margin: 1.5rem 0;
border: 0;
@@ -74,7 +74,10 @@ import {
isMobileImageUploadPlaceholderSource,
stripMobileImageUploadPlaceholders,
} from "../lib/mobile-image-upload-placeholder";
import { resolveMobileAttachmentContent } from "../lib/mobile-attachment-content";
import {
getMobileAttachmentLinkClass,
resolveMobileAttachmentContent,
} from "../lib/mobile-attachment-content";
import {
getMobileAiSourceRange,
resolveMobileAiSelectionTriggerPosition,
@@ -860,7 +863,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
attrs: {
href: resolveUrl(attachmentUrlValue, props.baseUrl),
target: "_blank",
class: "edgeever-attachment-link",
class: getMobileAttachmentLinkClass(filenameValue),
},
}],
}],
@@ -885,7 +888,10 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
}
const range = findMobileAttachmentRange(editor, target.resourceId);
if (!range) return;
const linkMark = editor.schema.marks.link?.create(range.linkAttrs);
const linkMark = editor.schema.marks.link?.create({
...range.linkAttrs,
class: getMobileAttachmentLinkClass(filenameValue, null, range.linkAttrs.class),
});
if (!linkMark) return;
editor.view.dispatch(editor.state.tr.replaceWith(
range.from,
@@ -2986,9 +2992,22 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean }
justify-content: center;
border-radius: 8px;
background: ${theme === "dark" ? "#134e4a" : "#ecfdf5"};
content: "📎";
font-size: 15px;
color: ${theme === "dark" ? "#cbd5e1" : "#64748b"};
content: "FILE";
font-size: 8px;
font-weight: 800;
letter-spacing: -0.2px;
}
.edgeever-editor-content a.edgeever-attachment-kind-image::before { background: ${theme === "dark" ? "#064e3b" : "#ecfdf5"}; color: ${theme === "dark" ? "#6ee7b7" : "#10b981"}; content: "▧"; font-size: 20px; }
.edgeever-editor-content a.edgeever-attachment-kind-audio::before { background: ${theme === "dark" ? "#0c4a6e" : "#f0f9ff"}; color: ${theme === "dark" ? "#7dd3fc" : "#0ea5e9"}; content: "♪"; font-size: 21px; }
.edgeever-editor-content a.edgeever-attachment-kind-video::before { background: ${theme === "dark" ? "#881337" : "#fff1f2"}; color: ${theme === "dark" ? "#fda4af" : "#f43f5e"}; content: "▶"; font-size: 15px; }
.edgeever-editor-content a.edgeever-attachment-kind-pdf::before { background: ${theme === "dark" ? "#881337" : "#fff1f2"}; color: ${theme === "dark" ? "#fda4af" : "#e11d48"}; content: "PDF"; }
.edgeever-editor-content a.edgeever-attachment-kind-spreadsheet::before { background: ${theme === "dark" ? "#14532d" : "#f0fdf4"}; color: ${theme === "dark" ? "#86efac" : "#16a34a"}; content: "XLS"; }
.edgeever-editor-content a.edgeever-attachment-kind-document::before { background: ${theme === "dark" ? "#1e3a8a" : "#eff6ff"}; color: ${theme === "dark" ? "#93c5fd" : "#2563eb"}; content: "DOC"; }
.edgeever-editor-content a.edgeever-attachment-kind-presentation::before { background: ${theme === "dark" ? "#7c2d12" : "#fff7ed"}; color: ${theme === "dark" ? "#fdba74" : "#f97316"}; content: "PPT"; }
.edgeever-editor-content a.edgeever-attachment-kind-archive::before { background: ${theme === "dark" ? "#713f12" : "#fffbeb"}; color: ${theme === "dark" ? "#fde68a" : "#d97706"}; content: "ZIP"; }
.edgeever-editor-content a.edgeever-attachment-kind-code::before { background: ${theme === "dark" ? "#581c87" : "#faf5ff"}; color: ${theme === "dark" ? "#d8b4fe" : "#8b5cf6"}; content: "</>"; }
.edgeever-editor-content a.edgeever-attachment-kind-text::before { background: ${theme === "dark" ? "#334155" : "#f1f5f9"}; color: ${theme === "dark" ? "#cbd5e1" : "#64748b"}; content: "TXT"; }
.edgeever-editor-content a.edgeever-attachment-link::after, .edgeever-editor-content a[href*="/api/v1/resources/"]::after {
margin-left: auto;
flex: 0 0 auto;
@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test";
import { resolveMobileAttachmentContent } from "./mobile-attachment-content";
import {
getMobileAttachmentLinkClass,
resolveMobileAttachmentContent,
} from "./mobile-attachment-content";
describe("resolveMobileAttachmentContent", () => {
test("converts PDF and file attachment nodes into mobile-safe links", () => {
@@ -43,7 +46,7 @@ describe("resolveMobileAttachmentContent", () => {
attrs: {
href: "/api/v1/resources/res_pdf/blob",
target: "_blank",
class: "edgeever-attachment-link",
class: "edgeever-attachment-link edgeever-attachment-kind-pdf",
},
}],
}],
@@ -58,7 +61,7 @@ describe("resolveMobileAttachmentContent", () => {
attrs: {
href: "/api/v1/resources/res_doc/blob",
target: "_blank",
class: "edgeever-attachment-link",
class: "edgeever-attachment-link edgeever-attachment-kind-document",
},
}],
}],
@@ -67,6 +70,35 @@ describe("resolveMobileAttachmentContent", () => {
});
});
test("adds type classes to existing resource links and preserves custom classes", () => {
const result = resolveMobileAttachmentContent({
type: "doc",
content: [{
type: "paragraph",
content: [{
type: "text",
text: "附件:数据.xlsx",
marks: [{
type: "link",
attrs: {
href: "/api/v1/resources/res_sheet/blob",
class: "custom edgeever-attachment-link edgeever-attachment-kind-file",
},
}],
}],
}],
});
expect(result.content[0]?.content?.[0]).toMatchObject({
marks: [{
attrs: {
class: "custom edgeever-attachment-link edgeever-attachment-kind-spreadsheet",
},
}],
});
expect(getMobileAttachmentLinkClass("演示.pptx")).toContain("edgeever-attachment-kind-presentation");
});
test("preserves surrounding rich content and falls back for incomplete nodes", () => {
const originalParagraph = {
type: "paragraph",
@@ -1,7 +1,9 @@
import {
FILE_ATTACHMENT_NODE_TYPE,
PDF_ATTACHMENT_NODE_TYPE,
resolveAttachmentKind,
type TiptapDoc,
type TiptapMark,
type TiptapNode,
type TiptapTextNode,
} from "@edgeever/shared";
@@ -16,9 +18,58 @@ const getStringAttr = (node: TiptapNode, key: string) => {
return typeof value === "string" ? value : "";
};
const ATTACHMENT_KIND_CLASS_PREFIX = "edgeever-attachment-kind-";
const normalizeAttachmentFilename = (label: string) =>
label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim();
export const getMobileAttachmentLinkClass = (
filename: string,
mimeType?: string | null,
existingClass?: unknown,
) => {
const preservedClasses = typeof existingClass === "string"
? existingClass.split(/\s+/).filter((className) =>
className &&
className !== "edgeever-attachment-link" &&
!className.startsWith(ATTACHMENT_KIND_CLASS_PREFIX)
)
: [];
const kind = resolveAttachmentKind(mimeType, normalizeAttachmentFilename(filename));
return [
...preservedClasses,
"edgeever-attachment-link",
`${ATTACHMENT_KIND_CLASS_PREFIX}${kind}`,
].join(" ");
};
const isAttachmentMark = (mark: TiptapMark) => {
if (mark.type !== "link") return false;
const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : "";
const className = typeof mark.attrs?.class === "string" ? mark.attrs.class : "";
return className.split(/\s+/).includes("edgeever-attachment-link") || href.includes("/api/v1/resources/");
};
const withAttachmentKindClass = (node: TiptapTextNode): TiptapTextNode => {
if (!node.marks?.some(isAttachmentMark)) return node;
return {
...node,
marks: node.marks.map((mark) => isAttachmentMark(mark)
? {
...mark,
attrs: {
...mark.attrs,
class: getMobileAttachmentLinkClass(node.text, null, mark.attrs?.class),
},
}
: mark),
};
};
const toLegacyAttachmentLink = (node: TiptapNode): TiptapTextNode => {
const url = getStringAttr(node, "url");
const label = getStringAttr(node, "label") || getStringAttr(node, "filename") || "Attachment";
const mimeType = getStringAttr(node, "mimeType");
return {
type: "text",
@@ -30,7 +81,7 @@ const toLegacyAttachmentLink = (node: TiptapNode): TiptapTextNode => {
attrs: {
href: url,
target: "_blank",
class: "edgeever-attachment-link",
class: getMobileAttachmentLinkClass(label, mimeType),
},
}],
}
@@ -53,6 +104,10 @@ export const resolveMobileAttachmentContent = (doc: TiptapDoc): TiptapDoc => {
return { ...node, content: node.content.map(visit) };
}
if (node.type === "text") {
return withAttachmentKindClass(node as TiptapTextNode);
}
return node;
};