feat(mobile): distinguish attachment file types
This commit is contained in:
1 parent
b1f240d6db
commit
7f5328e232
6 files changed
+1165
-514
No files matched your search
+926
-505
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user