perf(web): reduce demo bandwidth usage
- Cache fingerprinted frontend assets for one month. - Load media, rich renderers, dialogs, and editors only when needed. - Simplify seeded demo content and cover deferred-loading behavior.
This commit is contained in:
@@ -23,7 +23,7 @@ var embeddedFiles embed.FS
|
||||
const (
|
||||
frontendHTMLCacheControl = "no-cache, no-store, must-revalidate"
|
||||
frontendStaticAssetCacheControl = "public, max-age=3600"
|
||||
frontendHashedAssetCacheControl = "public, max-age=3600, immutable"
|
||||
frontendHashedAssetCacheControl = "public, max-age=2592000, immutable"
|
||||
)
|
||||
|
||||
type FrontendService struct {
|
||||
|
||||
@@ -26,7 +26,7 @@ INSERT INTO user (id,username,role,nickname,description,password_hash) VALUES(4,
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Welcome (steven, pinned) — orientation + SSO call-to-action + protected teaser
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(1,'welcome0000001',1,strftime('%s','now','-2 days'),strftime('%s','now','-2 days'),replace('# Welcome to Memos 👋\n\nHi, I''m Steven — I build Memos. You''ve found the public demo: a real, living instance where you can poke at every feature before self-hosting your own.\n\nMemos is an open-source, self-hosted home for short-form thoughts. Capture quickly, organize lightly, own everything.\n\n> Most apps treat notes like documents. Memos treats them like thoughts — small, timestamped, searchable.\n\n## Make yourself at home\n\n- [ ] Scroll this Explore feed — everything here was written with Memos\n- [ ] React to a memo you like (🎉 works great on this one)\n- [ ] **Sign in** (top right) — SSO takes ten seconds and gives you your own space\n- [ ] Write your first memo. One sentence is plenty. That''s the point.\n- [ ] Like it? Star [the repo](https://github.com/usememos/memos) ⭐\n\n🔒 *Psst — a few memos on this instance are hidden until you sign in. Members only.*\n\n## What Memos can do\n\n| Feature | Where to see it |\n|---------|-----------------|\n| **Markdown** | Everywhere — tables, `code`, task lists, quotes |\n| **Math & diagrams** | KaTeX and Mermaid — see my rendering test memo |\n| **Tags** | `#dev/git` — nested tags grow a tree in the sidebar |\n| **Comments & reactions** | Right under this memo 👇 |\n| **Memo references** | Alice''s weekly review links back to her week |\n| **Attachments** | Ben''s golden-hour art from the coastal trail |\n| **Locations** | Look for the 📍 pins |\n| **Visibility** | Public, protected, or private — chosen per memo |\n\n## Take it home\n\n```bash\ndocker run -d -p 5230:5230 -v ~/.memos:/var/opt/memos neosmemo/memos:stable\n```\n\nYour data lives in one SQLite file, on your machine. #welcome #getting-started','\n',char(10)),'PUBLIC',1,'{"tags":["welcome","getting-started"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(1,'welcome0000001',1,strftime('%s','now','-2 days'),strftime('%s','now','-2 days'),replace('# Welcome to Memos 👋\n\nHi, I''m Steven — I build Memos. You''ve found the public demo: a real, living instance where you can poke at every feature before self-hosting your own.\n\nMemos is an open-source, self-hosted home for short-form thoughts. Capture quickly, organize lightly, own everything.\n\n> Most apps treat notes like documents. Memos treats them like thoughts — small, timestamped, searchable.\n\n## Make yourself at home\n\n- [ ] Scroll this Explore feed — everything here was written with Memos\n- [ ] React to a memo you like (🎉 works great on this one)\n- [ ] **Sign in** (top right) — SSO takes ten seconds and gives you your own space\n- [ ] Write your first memo. One sentence is plenty. That''s the point.\n- [ ] Like it? Star [the repo](https://github.com/usememos/memos) ⭐\n\n🔒 *Psst — a few memos on this instance are hidden until you sign in. Members only.*\n\n## What Memos can do\n\n| Feature | Where to see it |\n|---------|-----------------|\n| **Markdown** | Everywhere — tables, `code`, task lists, quotes |\n| **Tags** | `#dev/git` — nested tags grow a tree in the sidebar |\n| **Comments & reactions** | Right under this memo 👇 |\n| **Memo references** | Alice''s weekly review links back to her week |\n| **Attachments** | Ben''s golden-hour art from the coastal trail |\n| **Locations** | Look for the 📍 pins |\n| **Visibility** | Public, protected, or private — chosen per memo |\n\n## Take it home\n\n```bash\ndocker run -d -p 5230:5230 -v ~/.memos:/var/opt/memos neosmemo/memos:stable\n```\n\nYour data lives in one SQLite file, on your machine. #welcome #getting-started','\n',char(10)),'PUBLIC',1,'{"tags":["welcome","getting-started"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
|
||||
|
||||
-- 2. Sponsors (steven, pinned) — sits below Welcome (older created_ts)
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(2,'sponsors000001',1,strftime('%s','now','-6 days'),strftime('%s','now','-6 days'),replace('Memos is free and open source, kept independent by its sponsors — no ads, no telemetry, no paywalls. 🙏 If you build with Memos, please consider supporting them in return.\n\n## ⭐ Sponsors\n\n<a href="https://coderabbit.link/usememos" target="_blank" rel="noopener"><img src="https://victorious-bubble-f69a016683.media.strapiapp.com/Orange_Typemark_43bf516c9d.svg" alt="CodeRabbit" height="44" /></a>\n\nCut code review time & bugs in half, instantly. [coderabbit.link/usememos →](https://coderabbit.link/usememos)\n\n---\n\n**SSD Nodes** — Affordable VPS hosting for self-hosters. [ssdnodes.com →](https://ssdnodes.com/?utm_source=memos&utm_medium=sponsor)\n\n---\n\nWant to see your company here? Reach out via [GitHub Sponsors](https://github.com/sponsors/usememos).\n\n#sponsors','\n',char(10)),'PUBLIC',1,'{"tags":["sponsors"],"property":{"hasLink":true}}');
|
||||
@@ -36,7 +36,10 @@ INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pin
|
||||
-- and thinning out toward -22 days so the feed reads as a living instance
|
||||
-- ============================================================================
|
||||
|
||||
-- 3. Ben: photo memo with an image attachment + location (newest in the feed)
|
||||
-- 10. Steven: official Chrome extension launch (featured first in Explore)
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(10,'webclipper0001',1,strftime('%s','now','-30 minutes'),strftime('%s','now','-30 minutes'),replace('# ✂️ Memos Web Clipper for Chrome\n\nThe ***official*** Memos Web Clipper turns the page you''re reading into a source-linked memo in under five seconds.\n\n[Add Memos Web Clipper to Chrome →](https://chromewebstore.google.com/detail/memos-web-clipper/nebaoebnljalfegiidibihhkebeiklbl)\n\n| Capture from Chrome | What lands in Memos |\n|---------------------|---------------------|\n| Current page | Title, summary, and source link |\n| Text selection | Quoted excerpt with its source |\n| Selected image | A real attachment on your instance |\n\nEdit the generated memo, choose its visibility, or customize the template and default tags before saving.\n\n### Private by design\n\nThe clipper sends captured content only to the self-hosted Memos instance you connect — and only when you save. No analytics, third-party servers, or tracking. #memos/extensions','\n',char(10)),'PUBLIC','{"tags":["memos/extensions"],"property":{"hasLink":true}}');
|
||||
|
||||
-- 3. Ben: photo memo with an image attachment + location
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(3,'goldenhour0001',3,strftime('%s','now','-3 hours'),strftime('%s','now','-3 hours'),'Golden hour on the coastal trail tonight. The camera couldn''t hold the colors, so here''s my minimal-art rendition instead 🎨 #travel/hikes','PUBLIC','{"tags":["travel/hikes"],"property":{"hasLink":false},"location":{"placeholder":"Point Reyes, California, United States","latitude":37.9954,"longitude":-123.0204}}');
|
||||
|
||||
-- 27. Steven: build-in-public changelog note (real v0.30 highlights)
|
||||
@@ -63,12 +66,8 @@ INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pay
|
||||
-- 9. Zoe: recipe with a task list
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(9,'zoeramen000001',4,strftime('%s','now','-4 days'),strftime('%s','now','-4 days'),replace('## 🍜 Midnight miso ramen (15 minutes)\n\nFor nights when the book was too good to stop reading.\n\n- [x] Dashi or veg stock, 500ml\n- [x] Miso paste, 2 big spoons\n- [x] Noodles, one nest\n- [x] Soft egg — 6½ minutes, exactly\n- [ ] Scallions (always forget the scallions)\n\nSteep the miso, don''t boil it. That''s the whole secret. #cooking','\n',char(10)),'PUBLIC','{"tags":["cooking"],"property":{"hasLink":false,"hasTaskList":true,"hasIncompleteTasks":true}}');
|
||||
|
||||
-- 10. Steven: Scratchpad promo
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(10,'scratchpad0001',1,strftime('%s','now','-8 days'),strftime('%s','now','-8 days'),replace('## 🧩 Try Memos Scratchpad\n\nA lightweight visual canvas for quick thoughts, links, images, files, and small cards before they become polished notes.\n\n[Open Scratchpad →](https://usememos.com/scratchpad)\n\n| Need | Scratchpad helps you |\n|------|----------------------|\n| Capture fast | Double-click to add notes |\n| Arrange freely | Drag cards around |\n| Add context | Attach files, links, and images |\n| Move around | Zoom and pan without setup |\n\n### Local-first\n\nYour Scratchpad cards stay in your browser. Memos does not access, sync, or store them.','\n',char(10)),'PUBLIC','{"property":{"hasLink":true}}');
|
||||
|
||||
-- 30. Steven: rendering test page — the explicit feature showcase (math, mermaid,
|
||||
-- syntax highlighting, tables, task lists, text styles) framed as maintainer dogfooding
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(30,'rendertest0001',1,strftime('%s','now','-10 days'),strftime('%s','now','-10 days'),replace('## 🧪 My rendering test memo #memos/dev\n\nI keep this memo around to eyeball the renderer after every release. If anything below looks broken, the next version isn''t shipping.\n\n**Bold**, *italic*, ~~strikethrough~~, `inline code`, and a [link](https://www.usememos.com/docs).\n\n> Blockquotes, naturally.\n\nInline math $e^{i\pi} + 1 = 0$ and block math:\n\n$$\n\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}\n$$\n\n```mermaid\ngraph LR\n A[Capture] --> B[Tag]\n B --> C[Find it later]\n C --> A\n```\n\n```go\nfunc main() {\n fmt.Println("syntax highlighting ✓")\n}\n```\n\n| Render | Status |\n|--------|--------|\n| Tables | ✅ |\n| Everything else | see above |\n\n- [x] KaTeX loads\n- [x] Mermaid draws\n- [ ] Find a bug before users do','\n',char(10)),'PUBLIC','{"tags":["memos/dev"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
|
||||
-- 30. Steven: compact Markdown rendering check framed as maintainer dogfooding
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(30,'rendertest0001',1,strftime('%s','now','-10 days'),strftime('%s','now','-10 days'),replace('## 🧪 Markdown rendering check #memos/dev\n\nA quick smoke test I open after releases:\n\n**Bold**, *italic*, ~~strikethrough~~, `inline code`, and a [docs link](https://www.usememos.com/docs).\n\n> Simple notes should render beautifully.\n\n```go\nfmt.Println("syntax highlighting ✓")\n```\n\n| Check | Status |\n|-------|--------|\n| Text styles | ✅ |\n| Code blocks | ✅ |\n| Tables | ✅ |\n\n- [x] Check desktop\n- [x] Check mobile\n- [ ] Ship the next release','\n',char(10)),'PUBLIC','{"tags":["memos/dev"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
|
||||
|
||||
-- 11. Zoe: movie watchlist with a table
|
||||
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(11,'zoemovies00001',4,strftime('%s','now','-9 days'),strftime('%s','now','-9 days'),replace('## 🎬 Movie catch-up #movies\n\nWorking through films I''ve been meaning to watch for, uh, years.\n\n| Movie | Status | Rating |\n|-------|--------|--------|\n| The Grand Budapest Hotel | ✅ Watched | ⭐⭐⭐⭐⭐ |\n| Spirited Away | ✅ Watched | ⭐⭐⭐⭐⭐ |\n| Everything Everywhere All at Once | ✅ Watched | ⭐⭐⭐⭐⭐ |\n| Dune: Part Two | 📅 This weekend | — |\n| Oppenheimer | 📋 Queued | — |\n\n**Spirited Away**: cried twice, no regrets. **Everything Everywhere**: went in cold, did not expect the bagel.\n\nNext month: full Miyazaki marathon 🎨 Taking suggestions for where to start.','\n',char(10)),'PUBLIC','{"tags":["movies"],"property":{"hasLink":false}}');
|
||||
@@ -176,7 +175,7 @@ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(20,1,'memos
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(21,4,'memos/bennighttrain1','💛');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(22,3,'memos/alicegitcheat1','🔥');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(23,4,'memos/alicegitcheat1','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(24,4,'memos/scratchpad0001','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(24,4,'memos/webclipper0001','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(25,1,'memos/tagtipmemo0001','👍');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(26,2,'memos/protectedintro','👀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(27,3,'memos/protectedintro','✅');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toBlob } from "html-to-image";
|
||||
const WINDOW_HORIZONTAL_MARGIN = 32;
|
||||
const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40;
|
||||
const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48;
|
||||
const PREVIEW_ASSET_TIMEOUT_MS = 5000;
|
||||
|
||||
export const MEMO_SHARE_IMAGE_CONFIG = {
|
||||
dialogExtraWidth: 80,
|
||||
@@ -42,13 +43,26 @@ const waitForPreviewAssets = async (node: HTMLElement) => {
|
||||
images.map(
|
||||
(image) =>
|
||||
new Promise<void>((resolve) => {
|
||||
// Share previews may sit inside a scroll container. Promote their images
|
||||
// so native lazy loading cannot leave the export waiting indefinitely.
|
||||
image.loading = "eager";
|
||||
|
||||
if (image.complete) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
image.addEventListener("load", () => resolve(), { once: true });
|
||||
image.addEventListener("error", () => resolve(), { once: true });
|
||||
let timeoutId: number;
|
||||
const finish = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
image.removeEventListener("load", finish);
|
||||
image.removeEventListener("error", finish);
|
||||
resolve();
|
||||
};
|
||||
|
||||
image.addEventListener("load", finish, { once: true });
|
||||
image.addEventListener("error", finish, { once: true });
|
||||
timeoutId = window.setTimeout(finish, PREVIEW_ASSET_TIMEOUT_MS);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LoaderCircleIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
import { type ComponentType, useCallback, useState } from "react";
|
||||
import { loadMemoEditor } from "@/components/MemoEditor/loader";
|
||||
import type { MemoEditorProps } from "@/components/MemoEditor/types";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
@@ -21,6 +22,8 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
|
||||
|
||||
const showCreateButton = currentUser && !showEditor;
|
||||
|
||||
@@ -28,6 +31,27 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
|
||||
setShowEditor(false);
|
||||
};
|
||||
|
||||
const preloadEditor = useCallback(() => {
|
||||
void loadMemoEditor().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const openEditor = useCallback(async () => {
|
||||
if (isEditorLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditorLoading(true);
|
||||
try {
|
||||
const { default: MemoEditor } = await loadMemoEditor();
|
||||
setEditorComponent(() => MemoEditor);
|
||||
setShowEditor(true);
|
||||
} catch {
|
||||
// Chunk failures are handled by loadWithReload; keep the current UI mounted.
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [isEditorLoading]);
|
||||
|
||||
return (
|
||||
<div className="pt-8 pb-16 w-full">
|
||||
<h2 id="comments" className="sr-only">
|
||||
@@ -37,9 +61,19 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
|
||||
{comments.length === 0 ? (
|
||||
showCreateButton && (
|
||||
<div className="w-full flex flex-row justify-center items-center py-6">
|
||||
<Button variant="ghost" onClick={() => setShowEditor(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPointerEnter={preloadEditor}
|
||||
onFocus={preloadEditor}
|
||||
onClick={openEditor}
|
||||
disabled={isEditorLoading}
|
||||
>
|
||||
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
|
||||
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
|
||||
{isEditorLoading ? (
|
||||
<LoaderCircleIcon className="ml-2 h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -51,15 +85,23 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
|
||||
<span className="text-muted-foreground text-sm ml-1">({comments.length})</span>
|
||||
</div>
|
||||
{showCreateButton && (
|
||||
<Button variant="ghost" className="text-muted-foreground" onClick={() => setShowEditor(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onPointerEnter={preloadEditor}
|
||||
onFocus={preloadEditor}
|
||||
onClick={openEditor}
|
||||
disabled={isEditorLoading}
|
||||
>
|
||||
{isEditorLoading && <LoaderCircleIcon className="h-4 w-4 animate-spin" />}
|
||||
{t("memo.comment.write-a-comment")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showEditor && (
|
||||
{showEditor && EditorComponent && (
|
||||
<div className="w-full mb-2">
|
||||
<MemoEditor
|
||||
<EditorComponent
|
||||
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
|
||||
placeholder={t("editor.add-your-comment-here")}
|
||||
parentMemoName={memo.name}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import hljs from "highlight.js";
|
||||
import { escape as escapeHTML } from "lodash-es";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { isValidElement, type ReactElement, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { isValidElement, type ReactElement, type ReactNode, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
|
||||
import { ensureHighlightTheme, highlightCode, isPlainTextLanguage } from "./highlight";
|
||||
import { MermaidBlock } from "./MermaidBlock";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
import { extractCodeContent, extractLanguage } from "./utils";
|
||||
@@ -15,9 +16,6 @@ interface CodeBlockProps extends ReactMarkdownProps {
|
||||
}
|
||||
|
||||
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const codeElement = isValidElement(children) ? (children as ReactElement<{ className?: string }>) : null;
|
||||
const codeClassName = codeElement?.props.className || "";
|
||||
const codeContent = extractCodeContent(children);
|
||||
@@ -34,55 +32,56 @@ export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBl
|
||||
);
|
||||
}
|
||||
|
||||
// Keying on the inputs remounts the block when they change, so highlight state
|
||||
// can never be shown against a different code content.
|
||||
return <HighlightedCodeBlock key={`${language}\u0000${codeContent}`} codeContent={codeContent} language={language} />;
|
||||
};
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
codeContent: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const HighlightedCodeBlock = ({ codeContent, language }: HighlightedCodeBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [highlightedCode, setHighlightedCode] = useState<string>();
|
||||
const renderedCode = highlightedCode ?? escapeHTML(codeContent);
|
||||
|
||||
const theme = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
const resolvedTheme = resolveTheme(theme);
|
||||
const isDarkTheme = resolvedTheme.includes("dark");
|
||||
|
||||
// Dynamically load highlight.js theme based on app theme
|
||||
useEffect(() => {
|
||||
const dynamicImportStyle = async () => {
|
||||
// Remove any existing highlight.js style
|
||||
const existingStyle = document.querySelector("style[data-hljs-theme]");
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
try {
|
||||
const cssModule = isDarkTheme
|
||||
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
|
||||
: await import("highlight.js/styles/github.css?inline");
|
||||
|
||||
// Create and inject the style
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssModule.default;
|
||||
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
|
||||
document.head.appendChild(style);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load highlight.js theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
dynamicImportStyle();
|
||||
}, [resolvedTheme, isDarkTheme]);
|
||||
|
||||
// Highlight code using highlight.js
|
||||
const highlightedCode = useMemo(() => {
|
||||
try {
|
||||
const lang = hljs.getLanguage(language);
|
||||
if (lang) {
|
||||
return hljs.highlight(codeContent, {
|
||||
language: language,
|
||||
}).value;
|
||||
}
|
||||
} catch {
|
||||
// Skip error and use default highlighted code.
|
||||
if (isPlainTextLanguage(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape any HTML entities when rendering original content.
|
||||
return Object.assign(document.createElement("span"), {
|
||||
textContent: codeContent,
|
||||
}).innerHTML;
|
||||
}, [language, codeContent]);
|
||||
void ensureHighlightTheme(isDarkTheme);
|
||||
}, [isDarkTheme, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlainTextLanguage(language)) {
|
||||
// The escaped fallback already is the final output; skip the async round-trip.
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void highlightCode(codeContent, language)
|
||||
.then((value) => {
|
||||
if (!cancelled) {
|
||||
setHighlightedCode(value);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the escaped plain-text fallback when a language chunk cannot load.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [codeContent, language]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
@@ -148,7 +147,7 @@ export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBl
|
||||
<div className="overflow-x-auto">
|
||||
<code
|
||||
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
dangerouslySetInnerHTML={{ __html: renderedCode }}
|
||||
/>
|
||||
</div>
|
||||
</pre>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import "katex/dist/katex.min.css";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkMath from "remark-math";
|
||||
import { MemoMarkdownRendererCore, type MemoMarkdownRendererProps } from "./MemoMarkdownRenderer";
|
||||
|
||||
const MathMarkdownRenderer = (props: MemoMarkdownRendererProps) => (
|
||||
<MemoMarkdownRendererCore
|
||||
{...props}
|
||||
mathRemarkPlugins={[remarkMath]}
|
||||
mathRehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }]]}
|
||||
/>
|
||||
);
|
||||
|
||||
export default MathMarkdownRenderer;
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { Element } from "hast";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { type ComponentProps, type ReactNode, Suspense } from "react";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
|
||||
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
|
||||
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
|
||||
@@ -20,12 +19,13 @@ import { SANITIZE_SCHEMA } from "./constants";
|
||||
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
|
||||
import { Mention } from "./Mention";
|
||||
import { AnchorLink, Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
|
||||
import { hasMathSyntax } from "./math";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
import { TrustedIframe } from "./TrustedIframe";
|
||||
|
||||
interface MemoMarkdownRendererProps {
|
||||
export interface MemoMarkdownRendererProps {
|
||||
content: string;
|
||||
resolvedMentionUsernames: Set<string>;
|
||||
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
|
||||
@@ -34,7 +34,18 @@ interface MemoMarkdownRendererProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||
type RemarkPlugins = NonNullable<ComponentProps<typeof ReactMarkdown>["remarkPlugins"]>;
|
||||
type RehypePlugins = NonNullable<ComponentProps<typeof ReactMarkdown>["rehypePlugins"]>;
|
||||
|
||||
interface MemoMarkdownRendererCoreProps extends MemoMarkdownRendererProps {
|
||||
/** Math plugins injected by MathMarkdownRenderer; the remark ones must run before remarkGfm. */
|
||||
mathRemarkPlugins?: RemarkPlugins;
|
||||
mathRehypePlugins?: RehypePlugins;
|
||||
}
|
||||
|
||||
const MathMarkdownRenderer = lazyWithReload(() => import("./MathMarkdownRenderer"));
|
||||
|
||||
function getMentionUsername(node: Element, children?: ReactNode): string {
|
||||
const dataMention = node.properties?.["data-mention"];
|
||||
if (typeof dataMention === "string" && dataMention !== "") {
|
||||
return dataMention;
|
||||
@@ -53,7 +64,14 @@ function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
|
||||
export const MemoMarkdownRendererCore = ({
|
||||
content,
|
||||
resolvedMentionUsernames,
|
||||
memoName,
|
||||
compact,
|
||||
mathRemarkPlugins = [],
|
||||
mathRehypePlugins = [],
|
||||
}: MemoMarkdownRendererCoreProps) => {
|
||||
const markdownComponents: Components = {
|
||||
input: ({ node, ...inputProps }) => {
|
||||
if (node && isTaskListItemElement(node)) {
|
||||
@@ -144,7 +162,7 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoNa
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkDisableSetext,
|
||||
remarkMath,
|
||||
...mathRemarkPlugins,
|
||||
remarkGfm,
|
||||
remarkSplitMixedTaskLists,
|
||||
remarkBreaks,
|
||||
@@ -152,12 +170,7 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoNa
|
||||
remarkTag,
|
||||
remarkPreserveType,
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, SANITIZE_SCHEMA],
|
||||
rehypeHeadingId,
|
||||
[rehypeKatex, { throwOnError: false, strict: false }],
|
||||
]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeHeadingId, ...mathRehypePlugins]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
@@ -165,3 +178,15 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoNa
|
||||
</MarkdownRenderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemoMarkdownRenderer = (props: MemoMarkdownRendererProps) => {
|
||||
if (!hasMathSyntax(props.content)) {
|
||||
return <MemoMarkdownRendererCore {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<MemoMarkdownRendererCore {...props} />}>
|
||||
<MathMarkdownRenderer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { HLJSApi, LanguageFn } from "highlight.js";
|
||||
import { escape as escapeHTML } from "lodash-es";
|
||||
|
||||
type LanguageModule = { default: LanguageFn };
|
||||
type LanguageLoader = () => Promise<LanguageModule>;
|
||||
|
||||
// Keep the languages most commonly used in memos cheap. Less common languages
|
||||
// fall back to the complete build, preserving existing language support without
|
||||
// making every visitor download all language definitions.
|
||||
const commonLanguageLoaders = {
|
||||
bash: () => import("highlight.js/lib/languages/bash"),
|
||||
c: () => import("highlight.js/lib/languages/c"),
|
||||
cpp: () => import("highlight.js/lib/languages/cpp"),
|
||||
csharp: () => import("highlight.js/lib/languages/csharp"),
|
||||
css: () => import("highlight.js/lib/languages/css"),
|
||||
diff: () => import("highlight.js/lib/languages/diff"),
|
||||
dockerfile: () => import("highlight.js/lib/languages/dockerfile"),
|
||||
go: () => import("highlight.js/lib/languages/go"),
|
||||
graphql: () => import("highlight.js/lib/languages/graphql"),
|
||||
java: () => import("highlight.js/lib/languages/java"),
|
||||
javascript: () => import("highlight.js/lib/languages/javascript"),
|
||||
json: () => import("highlight.js/lib/languages/json"),
|
||||
kotlin: () => import("highlight.js/lib/languages/kotlin"),
|
||||
markdown: () => import("highlight.js/lib/languages/markdown"),
|
||||
php: () => import("highlight.js/lib/languages/php"),
|
||||
python: () => import("highlight.js/lib/languages/python"),
|
||||
ruby: () => import("highlight.js/lib/languages/ruby"),
|
||||
rust: () => import("highlight.js/lib/languages/rust"),
|
||||
sql: () => import("highlight.js/lib/languages/sql"),
|
||||
swift: () => import("highlight.js/lib/languages/swift"),
|
||||
typescript: () => import("highlight.js/lib/languages/typescript"),
|
||||
xml: () => import("highlight.js/lib/languages/xml"),
|
||||
yaml: () => import("highlight.js/lib/languages/yaml"),
|
||||
} satisfies Record<string, LanguageLoader>;
|
||||
|
||||
type CommonLanguage = keyof typeof commonLanguageLoaders;
|
||||
|
||||
const languageAliases: Record<string, CommonLanguage> = {
|
||||
cs: "csharp",
|
||||
cxx: "cpp",
|
||||
golang: "go",
|
||||
htm: "xml",
|
||||
html: "xml",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
md: "markdown",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
sh: "bash",
|
||||
shell: "bash",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
vue: "xml",
|
||||
yml: "yaml",
|
||||
zsh: "bash",
|
||||
};
|
||||
|
||||
const plainTextLanguages = new Set(["", "plain", "plaintext", "text", "txt"]);
|
||||
const registeredLanguagePromises = new Map<CommonLanguage, Promise<HLJSApi>>();
|
||||
let corePromise: Promise<HLJSApi> | undefined;
|
||||
let fullBuildPromise: Promise<HLJSApi> | undefined;
|
||||
let injectedThemeKey: string | undefined;
|
||||
|
||||
export const isPlainTextLanguage = (language: string): boolean => plainTextLanguages.has(language.trim().toLowerCase());
|
||||
|
||||
// The hljs theme stylesheet is a document-global singleton, so it is managed here
|
||||
// (keyed by theme) rather than by each rendered code block.
|
||||
export const ensureHighlightTheme = async (isDarkTheme: boolean): Promise<void> => {
|
||||
const themeKey = isDarkTheme ? "dark" : "light";
|
||||
if (injectedThemeKey === themeKey) {
|
||||
return;
|
||||
}
|
||||
injectedThemeKey = themeKey;
|
||||
|
||||
try {
|
||||
const cssModule = isDarkTheme
|
||||
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
|
||||
: await import("highlight.js/styles/github.css?inline");
|
||||
if (injectedThemeKey !== themeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector("style[data-hljs-theme]")?.remove();
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssModule.default;
|
||||
style.setAttribute("data-hljs-theme", themeKey);
|
||||
document.head.appendChild(style);
|
||||
} catch (error) {
|
||||
injectedThemeKey = undefined;
|
||||
console.warn("Failed to load highlight.js theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCore = (): Promise<HLJSApi> => {
|
||||
corePromise ??= import("highlight.js/lib/core").then((module) => module.default);
|
||||
return corePromise;
|
||||
};
|
||||
|
||||
const loadCommonLanguage = (language: CommonLanguage): Promise<HLJSApi> => {
|
||||
const existingPromise = registeredLanguagePromises.get(language);
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const languagePromise = Promise.all([loadCore(), commonLanguageLoaders[language]()]).then(([hljs, languageModule]) => {
|
||||
if (!hljs.getLanguage(language)) {
|
||||
hljs.registerLanguage(language, languageModule.default);
|
||||
}
|
||||
return hljs;
|
||||
});
|
||||
registeredLanguagePromises.set(language, languagePromise);
|
||||
return languagePromise;
|
||||
};
|
||||
|
||||
const loadFullBuild = (): Promise<HLJSApi> => {
|
||||
fullBuildPromise ??= import("highlight.js").then((module) => module.default);
|
||||
return fullBuildPromise;
|
||||
};
|
||||
|
||||
export const highlightCode = async (code: string, language: string): Promise<string> => {
|
||||
const normalizedLanguage = language.trim().toLowerCase();
|
||||
if (isPlainTextLanguage(normalizedLanguage)) {
|
||||
return escapeHTML(code);
|
||||
}
|
||||
|
||||
const canonicalLanguage =
|
||||
languageAliases[normalizedLanguage] ??
|
||||
(Object.hasOwn(commonLanguageLoaders, normalizedLanguage) ? (normalizedLanguage as CommonLanguage) : undefined);
|
||||
|
||||
const hljs = canonicalLanguage ? await loadCommonLanguage(canonicalLanguage) : await loadFullBuild();
|
||||
const languageToHighlight = canonicalLanguage ?? normalizedLanguage;
|
||||
if (!hljs.getLanguage(languageToHighlight)) {
|
||||
return escapeHTML(code);
|
||||
}
|
||||
|
||||
return hljs.highlight(code, { language: languageToHighlight }).value;
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export const Image = ({ className, alt, node: _node, height, width, style, ...pr
|
||||
alt={alt}
|
||||
style={{ height: height ? `${height}px` : undefined, width: width ? `${width}px` : undefined, ...style }}
|
||||
{...props}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const stripCode = (content: string): string =>
|
||||
content
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/~~~[\s\S]*?~~~/g, "")
|
||||
.replace(/`(?:\\.|[^`\n])*`/g, "");
|
||||
|
||||
// This is only a loading heuristic; remark-math remains the source of truth for
|
||||
// parsing. Any unescaped dollar outside code triggers the math chunk: a false
|
||||
// positive merely loads it, whereas mirroring remark-math's grammar here risks
|
||||
// false negatives that would silently render real math as raw dollars.
|
||||
export const hasMathSyntax = (content: string): boolean => /(^|[^\\])\$/.test(stripCode(content));
|
||||
@@ -0,0 +1,15 @@
|
||||
import { loadWithReload } from "@/utils/lazy";
|
||||
|
||||
type MemoEditorModule = typeof import(".");
|
||||
|
||||
let memoEditorModulePromise: Promise<MemoEditorModule> | undefined;
|
||||
|
||||
// Share one in-flight request across edit and comment entry points. Callers
|
||||
// keep their current UI mounted until this promise resolves.
|
||||
export const loadMemoEditor = (): Promise<MemoEditorModule> => {
|
||||
memoEditorModulePromise ??= loadWithReload(() => import(".")).catch((error) => {
|
||||
memoEditorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return memoEditorModulePromise;
|
||||
};
|
||||
@@ -32,11 +32,11 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
|
||||
}
|
||||
|
||||
if (attachmentType === "video/*") {
|
||||
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />;
|
||||
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="none" />;
|
||||
}
|
||||
|
||||
if (attachmentType === "audio/*") {
|
||||
return <audio src={sourceUrl} className={cn("w-full rounded-lg", className)} controls preload="metadata" />;
|
||||
return <audio src={sourceUrl} className={cn("w-full rounded-lg", className)} controls preload="none" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { PreviewMediaItem } from "@/utils/media-item";
|
||||
import { formatAudioTime } from "./attachmentHelpers";
|
||||
import { formatAudioTime, toggleAudioPlayback } from "./attachmentHelpers";
|
||||
|
||||
interface AttachmentListEditorProps {
|
||||
attachments: Attachment[];
|
||||
@@ -119,16 +119,7 @@ const AttachmentItemCard: FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio.paused) {
|
||||
try {
|
||||
await audio.play();
|
||||
} catch {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
await toggleAudioPlayback(audio, sourceUrl, () => setIsPlaying(false));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -144,7 +135,7 @@ const AttachmentItemCard: FC<{
|
||||
className={cn("h-full w-full overflow-hidden", isPreviewable ? "cursor-pointer" : "cursor-default")}
|
||||
aria-label={`Preview ${filename}`}
|
||||
>
|
||||
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
|
||||
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
) : isVoiceNote ? (
|
||||
<>
|
||||
@@ -158,8 +149,7 @@ const AttachmentItemCard: FC<{
|
||||
</button>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={sourceUrl}
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
className="hidden"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import { formatAudioTime } from "./attachmentHelpers";
|
||||
import { formatAudioTime, toggleAudioPlayback } from "./attachmentHelpers";
|
||||
|
||||
const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const;
|
||||
const UNKNOWN_DURATION_LABEL = "--:--";
|
||||
@@ -91,16 +91,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title, compa
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio.paused) {
|
||||
try {
|
||||
await audio.play();
|
||||
} catch {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
await toggleAudioPlayback(audio, sourceUrl, () => setIsPlaying(false));
|
||||
};
|
||||
|
||||
const handleSeek = (nextTime: number) => {
|
||||
@@ -171,8 +162,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title, compa
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={sourceUrl}
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
className="hidden"
|
||||
onLoadedMetadata={(e) => handleDuration(e.currentTarget.duration)}
|
||||
onDurationChange={(e) => handleDuration(e.currentTarget.duration)}
|
||||
|
||||
@@ -43,6 +43,26 @@ export const getAttachmentMetadata = (attachment: Attachment): AttachmentMetadat
|
||||
fileSizeLabel: attachment.size ? formatFileSize(Number(attachment.size)) : undefined,
|
||||
});
|
||||
|
||||
// Audio elements render with preload="none" and no src so the feed fetches nothing;
|
||||
// the source is bound on first play. getAttribute is compared (not .src, which
|
||||
// reflects an absolute URL) so an already-bound source is not re-assigned.
|
||||
export const toggleAudioPlayback = async (audio: HTMLAudioElement, sourceUrl: string, onPlayFailure: () => void): Promise<void> => {
|
||||
if (!audio.paused) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio.getAttribute("src") !== sourceUrl) {
|
||||
audio.setAttribute("src", sourceUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
await audio.play();
|
||||
} catch {
|
||||
onPlayFailure();
|
||||
}
|
||||
};
|
||||
|
||||
export const formatAudioTime = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "0:00";
|
||||
|
||||
@@ -55,6 +55,7 @@ const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) =>
|
||||
alt={item.filename}
|
||||
className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{item.kind === "motion" && (
|
||||
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 py-0.5 text-[8px] font-semibold leading-none text-white">
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ComponentType, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { loadMemoEditor } from "@/components/MemoEditor/loader";
|
||||
import type { MemoEditorProps } from "@/components/MemoEditor/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
import { findTagMetadata } from "@/lib/tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog";
|
||||
import MemoEditor from "../MemoEditor";
|
||||
import PreviewImageDialog from "../PreviewImageDialog";
|
||||
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
|
||||
import { MEMO_CARD_BASE_CLASSES } from "./constants";
|
||||
import { useImagePreview } from "./hooks";
|
||||
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
|
||||
import type { MemoViewProps } from "./types";
|
||||
|
||||
const MemoShareImageDialog = lazyWithReload(() => import("../MemoActionMenu/MemoShareImageDialog"));
|
||||
const PreviewImageDialog = lazyWithReload(() => import("../PreviewImageDialog"));
|
||||
|
||||
const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
|
||||
const [cardWidth, setCardWidth] = useState(0);
|
||||
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -36,7 +40,14 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
|
||||
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
||||
|
||||
const openEditor = useCallback(() => setShowEditor(true), []);
|
||||
const openEditor = useCallback(() => {
|
||||
void loadMemoEditor()
|
||||
.then(({ default: MemoEditor }) => {
|
||||
setEditorComponent(() => MemoEditor);
|
||||
setShowEditor(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const closeEditor = useCallback(() => setShowEditor(false), []);
|
||||
|
||||
const location = useLocation();
|
||||
@@ -101,20 +112,6 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
],
|
||||
);
|
||||
|
||||
if (showEditor) {
|
||||
return (
|
||||
<MemoEditor
|
||||
autoFocus
|
||||
className="mb-2"
|
||||
cacheKey={`inline-memo-editor-${memoData.name}`}
|
||||
memo={memoData}
|
||||
parentMemoName={memoData.parent || undefined}
|
||||
onConfirm={closeEditor}
|
||||
onCancel={closeEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const article = (
|
||||
<article
|
||||
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
|
||||
@@ -125,28 +122,48 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
|
||||
<MemoBody compact={compact} />
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewState.open}
|
||||
onOpenChange={setPreviewOpen}
|
||||
items={previewState.items}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
{previewState.items.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<PreviewImageDialog
|
||||
open={previewState.open}
|
||||
onOpenChange={setPreviewOpen}
|
||||
items={previewState.items}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{props.onShareImageDialogOpenChange && (
|
||||
<MemoShareImageDialog open={Boolean(props.shareImageDialogOpen)} onOpenChange={props.onShareImageDialogOpenChange} />
|
||||
{props.onShareImageDialogOpenChange && props.shareImageDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<MemoShareImageDialog open onOpenChange={props.onShareImageDialogOpenChange} />
|
||||
</Suspense>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
|
||||
const memoDisplay = showCommentPreview ? (
|
||||
<div className="w-full mb-2">
|
||||
{article}
|
||||
<MemoCommentListView />
|
||||
</div>
|
||||
) : (
|
||||
article
|
||||
);
|
||||
|
||||
return (
|
||||
<MemoViewContext.Provider value={contextValue}>
|
||||
{showCommentPreview ? (
|
||||
<div className="w-full mb-2">
|
||||
{article}
|
||||
<MemoCommentListView />
|
||||
</div>
|
||||
{showEditor && EditorComponent ? (
|
||||
<EditorComponent
|
||||
autoFocus
|
||||
className="mb-2"
|
||||
cacheKey={`inline-memo-editor-${memoData.name}`}
|
||||
memo={memoData}
|
||||
parentMemoName={memoData.parent || undefined}
|
||||
onConfirm={closeEditor}
|
||||
onCancel={closeEditor}
|
||||
/>
|
||||
) : (
|
||||
article
|
||||
memoDisplay
|
||||
)}
|
||||
</MemoViewContext.Provider>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,9 @@ const MotionPhotoPlayer = ({
|
||||
}: MotionPhotoPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
// Latched on first activation: the source stays bound afterwards so repeated
|
||||
// hovers replay the already-buffered video instead of refetching it.
|
||||
const [hasActivated, setHasActivated] = useState(false);
|
||||
|
||||
const resetPlaybackPosition = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
@@ -83,6 +86,7 @@ const MotionPhotoPlayer = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setHasActivated(true);
|
||||
void startPlayback(loop);
|
||||
}, [active, loop, startPlayback, stopPlayback]);
|
||||
|
||||
@@ -98,7 +102,7 @@ const MotionPhotoPlayer = ({
|
||||
/>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={motionUrl}
|
||||
src={active || hasActivated ? motionUrl : undefined}
|
||||
poster={posterUrl}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200",
|
||||
@@ -108,7 +112,7 @@ const MotionPhotoPlayer = ({
|
||||
)}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={(event) => resetPlaybackPosition(event.currentTarget)}
|
||||
onEnded={() => stopPlayback()}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { type ReactElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
@@ -18,7 +17,6 @@ import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import ColumnGrid, { columnCountForWidth, GRID_GAP } from "../ColumnGrid";
|
||||
import MemoEditor from "../MemoEditor";
|
||||
import MemoFilters from "../MemoFilters";
|
||||
import Placeholder from "../Placeholder";
|
||||
import { estimateMemoCardHeight } from "./memoCardHeight";
|
||||
@@ -46,8 +44,8 @@ interface Props {
|
||||
pageSize?: number;
|
||||
showCreator?: boolean;
|
||||
enabled?: boolean;
|
||||
/** When true, render the inline MemoEditor above the list (e.g. on the Home page). */
|
||||
showMemoEditor?: boolean;
|
||||
/** Route-owned content rendered before the list and inside column one in grid mode. */
|
||||
renderLeading?: (options: { useGrid: boolean }) => ReactNode;
|
||||
}
|
||||
|
||||
function useAutoFetchWhenNotScrollable({
|
||||
@@ -132,9 +130,6 @@ const PagedMemoList = (props: Props) => {
|
||||
// pages don't each repeat the policy.
|
||||
const effectiveCompact = compactMode || useGrid;
|
||||
|
||||
const showMemoEditor = props.showMemoEditor ?? false;
|
||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos(
|
||||
{
|
||||
state: props.state || State.NORMAL,
|
||||
@@ -202,16 +197,7 @@ const PagedMemoList = (props: Props) => {
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// In the grid the leading stack owns all spacing (GRID_GAP), so the composer carries
|
||||
// its own bottom margin only in the flow layout.
|
||||
const memoEditor = showMemoEditor ? (
|
||||
<MemoEditor
|
||||
className={useGrid ? undefined : "mb-2"}
|
||||
cacheKey="home-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
/>
|
||||
) : null;
|
||||
const leadingContent = props.renderLeading?.({ useGrid });
|
||||
|
||||
// A freshly created memo is hoisted to the front; pin it to the top of column one so it
|
||||
// appears right under the composer instead of dropping into a random (shortest) column.
|
||||
@@ -232,9 +218,9 @@ const PagedMemoList = (props: Props) => {
|
||||
// grid's x-spacing exactly.
|
||||
const hasFilters = filters.length > 0;
|
||||
const gridLeading =
|
||||
memoEditor || hasFilters || emptyPlaceholder ? (
|
||||
leadingContent || hasFilters || emptyPlaceholder ? (
|
||||
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
|
||||
{memoEditor}
|
||||
{leadingContent}
|
||||
<MemoFilters />
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
@@ -277,7 +263,7 @@ const PagedMemoList = (props: Props) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{memoEditor}
|
||||
{leadingContent}
|
||||
<MemoFilters className="mb-2" />
|
||||
{sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
|
||||
{emptyPlaceholder}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VideoPosterProps {
|
||||
@@ -9,6 +9,7 @@ interface VideoPosterProps {
|
||||
}
|
||||
|
||||
const MAX_POSTER_DIMENSION = 960;
|
||||
const POSTER_LOAD_MARGIN = "200px 0px";
|
||||
|
||||
const getFrameSourceUrl = (sourceUrl: string): string => {
|
||||
if (!sourceUrl || sourceUrl.startsWith("blob:") || sourceUrl.startsWith("data:") || sourceUrl.includes("#")) {
|
||||
@@ -27,14 +28,44 @@ const getCanvasSize = (width: number, height: number) => {
|
||||
};
|
||||
|
||||
const VideoPoster = ({ sourceUrl, alt, className, posterUrl }: VideoPosterProps) => {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const usablePosterUrl = posterUrl && posterUrl !== sourceUrl ? posterUrl : undefined;
|
||||
const [capturedPosterUrl, setCapturedPosterUrl] = useState<string>();
|
||||
const [capturedPoster, setCapturedPoster] = useState<{ sourceUrl: string; url: string }>();
|
||||
const [nearViewport, setNearViewport] = useState(false);
|
||||
const frameSourceUrl = useMemo(() => getFrameSourceUrl(sourceUrl), [sourceUrl]);
|
||||
const capturedPosterUrl = capturedPoster?.sourceUrl === sourceUrl ? capturedPoster.url : undefined;
|
||||
const posterImageUrl = usablePosterUrl ?? capturedPosterUrl;
|
||||
|
||||
useEffect(() => {
|
||||
setCapturedPosterUrl(undefined);
|
||||
}, [sourceUrl, usablePosterUrl]);
|
||||
if (posterImageUrl || nearViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = placeholderRef.current;
|
||||
if (!placeholder) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNearViewport(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: POSTER_LOAD_MARGIN },
|
||||
);
|
||||
|
||||
observer.observe(placeholder);
|
||||
return () => observer.disconnect();
|
||||
}, [posterImageUrl, nearViewport]);
|
||||
|
||||
const captureFrame = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
@@ -54,23 +85,29 @@ const VideoPoster = ({ sourceUrl, alt, className, posterUrl }: VideoPosterProps)
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
setCapturedPosterUrl(canvas.toDataURL("image/jpeg", 0.86));
|
||||
setCapturedPoster({ sourceUrl, url: canvas.toDataURL("image/jpeg", 0.86) });
|
||||
} catch {
|
||||
// Cross-origin or codec-specific failures should keep the video fallback visible.
|
||||
}
|
||||
},
|
||||
[capturedPosterUrl, usablePosterUrl],
|
||||
[capturedPosterUrl, sourceUrl, usablePosterUrl],
|
||||
);
|
||||
|
||||
if (posterImageUrl) {
|
||||
return <img src={posterImageUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
|
||||
}
|
||||
|
||||
if (!nearViewport) {
|
||||
return <div ref={placeholderRef} data-testid="video-poster-placeholder" role="img" aria-label={alt} className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
data-testid="video-poster-fallback"
|
||||
src={frameSourceUrl}
|
||||
className={cn("pointer-events-none", className)}
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
import type { MapPoint } from "./types";
|
||||
|
||||
interface LazyLocationPickerProps {
|
||||
@@ -9,7 +10,7 @@ interface LazyLocationPickerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LocationPicker = lazy(() => import("./LocationPicker"));
|
||||
const LocationPicker = lazyWithReload(() => import("./LocationPicker"));
|
||||
|
||||
export const LazyLocationPicker = ({ className, ...props }: LazyLocationPickerProps) => {
|
||||
return (
|
||||
|
||||
+16
-1
@@ -1,15 +1,23 @@
|
||||
import { useMemo } from "react";
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const Home = () => {
|
||||
const user = useCurrentUser();
|
||||
const t = useTranslate();
|
||||
const { isInitialized } = useInstance();
|
||||
const { filters } = useMemoFilterContext();
|
||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
|
||||
|
||||
const memoFilter = useMemoFilters({
|
||||
creatorName: user?.name,
|
||||
@@ -33,7 +41,14 @@ const Home = () => {
|
||||
orderBy={orderBy}
|
||||
filter={memoFilter}
|
||||
enabled={isInitialized}
|
||||
showMemoEditor
|
||||
renderLeading={({ useGrid }) => (
|
||||
<MemoEditor
|
||||
className={useGrid ? undefined : "mb-2"}
|
||||
cacheKey="home-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</NewMemoProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { ExternalLinkIcon, LayoutListIcon, MapIcon } from "lucide-react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import MemoView from "@/components/MemoView";
|
||||
@@ -13,10 +13,11 @@ import { useUser } from "@/hooks/useUserQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
|
||||
type TabView = "memos" | "map";
|
||||
|
||||
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
|
||||
const UserMemoMap = lazyWithReload(() => import("@/components/UserMemoMap"));
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { lazy } from "react";
|
||||
import { createBrowserRouter, Navigate, type RouteObject } from "react-router-dom";
|
||||
import App from "@/App";
|
||||
import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import RootLayout from "@/layouts/RootLayout";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards";
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
// Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment).
|
||||
function lazyWithReload<T extends React.ComponentType>(factory: () => Promise<{ default: T }>) {
|
||||
return lazy(() =>
|
||||
factory().catch((error) => {
|
||||
const isChunkError =
|
||||
error?.message?.includes("Failed to fetch dynamically imported module") ||
|
||||
error?.message?.includes("Importing a module script failed");
|
||||
const reloadKey = "chunk-reload";
|
||||
if (isChunkError && !sessionStorage.getItem(reloadKey)) {
|
||||
sessionStorage.setItem(reloadKey, "1");
|
||||
window.location.reload();
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
|
||||
const About = lazyWithReload(() => import("@/pages/About"));
|
||||
const Archived = lazyWithReload(() => import("@/pages/Archived"));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { lazy } from "react";
|
||||
|
||||
const reloadOnChunkLoadError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const isChunkError =
|
||||
message.includes("Failed to fetch dynamically imported module") || message.includes("Importing a module script failed");
|
||||
const reloadKey = "chunk-reload";
|
||||
if (isChunkError && !sessionStorage.getItem(reloadKey)) {
|
||||
sessionStorage.setItem(reloadKey, "1");
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
export async function loadWithReload<T>(factory: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await factory();
|
||||
} catch (error) {
|
||||
reloadOnChunkLoadError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment).
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mirrors React.lazy's own constraint
|
||||
export function lazyWithReload<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>) {
|
||||
return lazy(() => loadWithReload(factory));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Image } from "@/components/MemoContent/markdown/Image";
|
||||
import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem";
|
||||
import MotionPhotoPlayer from "@/components/MotionPhotoPlayer";
|
||||
|
||||
describe("media loading", () => {
|
||||
it("marks markdown images for native lazy loading", () => {
|
||||
render(<Image src="/image.jpg" alt="memo illustration" />);
|
||||
|
||||
const image = screen.getByRole("img", { name: "memo illustration" });
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
expect(image).toHaveAttribute("decoding", "async");
|
||||
});
|
||||
|
||||
it("does not bind an audio source until playback is requested", async () => {
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
const { container } = render(
|
||||
<AudioAttachmentItem filename="recording.mp3" sourceUrl="/recording.mp3" mimeType="audio/mpeg" />,
|
||||
);
|
||||
const audio = container.querySelector("audio");
|
||||
|
||||
expect(audio).not.toHaveAttribute("src");
|
||||
expect(audio).toHaveAttribute("preload", "none");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Play recording.mp3" }));
|
||||
|
||||
expect(audio).toHaveAttribute("src", "/recording.mp3");
|
||||
expect(play).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("defers binding the motion video until first activation, then keeps it for replays", () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
|
||||
const { container, rerender } = render(
|
||||
<MotionPhotoPlayer posterUrl="/poster.jpg" motionUrl="/motion.mp4" alt="Live photo" active={false} />,
|
||||
);
|
||||
const video = container.querySelector("video");
|
||||
|
||||
expect(video).not.toHaveAttribute("src");
|
||||
expect(video).toHaveAttribute("preload", "none");
|
||||
|
||||
rerender(<MotionPhotoPlayer posterUrl="/poster.jpg" motionUrl="/motion.mp4" alt="Live photo" active />);
|
||||
expect(video).toHaveAttribute("src", "/motion.mp4");
|
||||
|
||||
// Deactivating pauses playback but keeps the buffered source bound.
|
||||
rerender(<MotionPhotoPlayer posterUrl="/poster.jpg" motionUrl="/motion.mp4" alt="Live photo" active={false} />);
|
||||
expect(pause).toHaveBeenCalled();
|
||||
expect(video).toHaveAttribute("src", "/motion.mp4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { highlightCode } from "@/components/MemoContent/highlight";
|
||||
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
|
||||
import { hasMathSyntax } from "@/components/MemoContent/math";
|
||||
|
||||
describe("memo content lazy renderers", () => {
|
||||
it("detects math in prose but ignores escaped dollars and code", () => {
|
||||
expect(hasMathSyntax("Inline $L$ formula")).toBe(true);
|
||||
expect(hasMathSyntax("Display $$x$$ formula")).toBe(true);
|
||||
expect(hasMathSyntax("Display\n$$\nx + y\n$$")).toBe(true);
|
||||
|
||||
expect(hasMathSyntax(String.raw`Price is \$5`)).toBe(false);
|
||||
expect(hasMathSyntax("Inline code: `$L$`")).toBe(false);
|
||||
expect(hasMathSyntax("```text\n$L$\n```")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads the math renderer when math syntax is present", async () => {
|
||||
const { container } = render(<MemoMarkdownRenderer content="$L$" resolvedMentionUsernames={new Set()} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".katex")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("escapes plain code and highlights common languages", async () => {
|
||||
expect(await highlightCode('<script data-test="x">&</script>', "")).toBe(
|
||||
"<script data-test="x">&</script>",
|
||||
);
|
||||
expect(await highlightCode("echo hello", "bash")).toContain("hljs-built_in");
|
||||
expect(await highlightCode("const value = 1;", "js")).toContain("hljs-keyword");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toBlob } from "html-to-image";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMemoShareImageFileName,
|
||||
createMemoShareImageBlob,
|
||||
getMemoShareDialogWidth,
|
||||
getMemoSharePreviewAvatarUrl,
|
||||
getMemoSharePreviewWidth,
|
||||
@@ -11,6 +13,10 @@ import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/mem
|
||||
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
vi.mock("html-to-image", () => ({
|
||||
toBlob: vi.fn(),
|
||||
}));
|
||||
|
||||
const buildMemo = (overrides: Partial<Memo> = {}) =>
|
||||
create(MemoSchema, {
|
||||
name: "memos/test",
|
||||
@@ -106,6 +112,10 @@ describe("memo share image preview model", () => {
|
||||
});
|
||||
|
||||
describe("memo share image utilities", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toBlob).mockResolvedValue(new Blob(["preview"], { type: "image/png" }));
|
||||
});
|
||||
|
||||
it("builds filenames from memo resource names", () => {
|
||||
expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png");
|
||||
expect(buildMemoShareImageFileName("")).toBe("memo-memo.png");
|
||||
@@ -131,4 +141,27 @@ describe("memo share image utilities", () => {
|
||||
expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
|
||||
expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("promotes lazy images and stops waiting after the asset timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const preview = document.createElement("div");
|
||||
const image = document.createElement("img");
|
||||
image.loading = "lazy";
|
||||
Object.defineProperty(image, "complete", { configurable: true, value: false });
|
||||
preview.appendChild(image);
|
||||
|
||||
const renderPromise = createMemoShareImageBlob(preview);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(image.loading).toBe("eager");
|
||||
expect(toBlob).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await expect(renderPromise).resolves.toBeInstanceOf(Blob);
|
||||
expect(toBlob).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,19 +37,15 @@ vi.mock("@/components/MemoFilters", () => ({
|
||||
default: () => <div data-testid="memo-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoEditor", () => ({
|
||||
default: () => <div data-testid="memo-editor" />,
|
||||
}));
|
||||
|
||||
const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo;
|
||||
|
||||
const renderList = (
|
||||
renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />,
|
||||
options: { showMemoEditor?: boolean } = {},
|
||||
options: { leading?: React.ReactNode } = {},
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<PagedMemoList renderer={renderer} showMemoEditor={options.showMemoEditor} />
|
||||
<PagedMemoList renderer={renderer} renderLeading={options.leading ? () => options.leading : undefined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -67,23 +63,23 @@ describe("<PagedMemoList>", () => {
|
||||
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state below the memo editor", () => {
|
||||
renderList(undefined, { showMemoEditor: true });
|
||||
it("shows the empty state below route-owned leading content", () => {
|
||||
renderList(undefined, { leading: <div data-testid="leading-content" /> });
|
||||
|
||||
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("leading-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("No data found.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places the empty state in the first grid column", () => {
|
||||
it("places leading content and the empty state in the first grid column", () => {
|
||||
view.maxColumns = 0;
|
||||
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
|
||||
try {
|
||||
renderList(undefined, { showMemoEditor: true });
|
||||
renderList(undefined, { leading: <div data-testid="leading-content" /> });
|
||||
|
||||
const leadingTile = screen.getByText("No data found.").closest(".absolute");
|
||||
expect(leadingTile).not.toBeNull();
|
||||
expect(leadingTile).toContainElement(screen.getByTestId("memo-editor"));
|
||||
expect(leadingTile).toContainElement(screen.getByTestId("leading-content"));
|
||||
} finally {
|
||||
widthSpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import VideoPoster from "@/components/VideoPoster";
|
||||
|
||||
describe("<VideoPoster>", () => {
|
||||
it("renders a mobile-friendly video fallback before a frame is captured", () => {
|
||||
let intersectionCallback: IntersectionObserverCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "200px 0px";
|
||||
readonly thresholds = [0];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = vi.fn();
|
||||
observe = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
|
||||
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const enterViewport = () => {
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
};
|
||||
|
||||
it("waits until the poster is near the viewport before loading video data", () => {
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
|
||||
|
||||
expect(screen.getByTestId("video-poster-placeholder")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("video-poster-fallback")).not.toBeInTheDocument();
|
||||
|
||||
enterViewport();
|
||||
|
||||
const video = screen.getByTestId("video-poster-fallback");
|
||||
expect(video).toHaveAttribute("src", "/file/attachments/video/video.mp4#t=0.001");
|
||||
expect(video).toHaveAttribute("preload", "auto");
|
||||
expect(video).toHaveAttribute("playsinline");
|
||||
expect((video as HTMLVideoElement).muted).toBe(true);
|
||||
@@ -32,6 +69,7 @@ describe("<VideoPoster>", () => {
|
||||
});
|
||||
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
|
||||
enterViewport();
|
||||
|
||||
const video = screen.getByTestId("video-poster-fallback") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "videoWidth", { configurable: true, value: 640 });
|
||||
@@ -44,4 +82,17 @@ describe("<VideoPoster>", () => {
|
||||
});
|
||||
expect(drawImage).toHaveBeenCalledWith(video, 0, 0, 640, 360);
|
||||
});
|
||||
|
||||
it("uses an available poster without loading the video fallback", () => {
|
||||
render(
|
||||
<VideoPoster
|
||||
sourceUrl="/file/attachments/video/video.mp4"
|
||||
posterUrl="/file/attachments/video/poster.webp"
|
||||
alt="clip.mp4"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("img", { name: "clip.mp4" })).toHaveAttribute("src", "/file/attachments/video/poster.webp");
|
||||
expect(screen.queryByTestId("video-poster-fallback")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user