-
{language}
+
+ {/* Header with language label and copy button */}
+
+ {language || "text"}
-
-
+
+ {/* Code content */}
+
+
);
diff --git a/web/src/components/MemoContent/MermaidBlock.tsx b/web/src/components/MemoContent/MermaidBlock.tsx
index 48ec20bb1..d7511e65e 100644
--- a/web/src/components/MemoContent/MermaidBlock.tsx
+++ b/web/src/components/MemoContent/MermaidBlock.tsx
@@ -86,7 +86,7 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
return (
);
diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx
new file mode 100644
index 000000000..45d0cee93
--- /dev/null
+++ b/web/src/components/MemoContent/Table.tsx
@@ -0,0 +1,83 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./markdown/types";
+
+interface TableProps extends React.HTMLAttributes
, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
+ return (
+
+ );
+};
+
+interface TableHeadProps extends React.HTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TableBodyProps extends React.HTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TableRowProps extends React.HTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TableHeaderCellProps extends React.ThHTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
+ return (
+
+ {children}
+ |
+ );
+};
+
+interface TableCellProps extends React.TdHTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
+ return (
+
+ {children}
+ |
+ );
+};
diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx
index 5fa87d753..966a90418 100644
--- a/web/src/components/MemoContent/Tag.tsx
+++ b/web/src/components/MemoContent/Tag.tsx
@@ -48,7 +48,7 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa
return (
{
- node?: Element; // AST node from react-markdown
+interface TaskListItemProps extends React.InputHTMLAttributes, ReactMarkdownProps {
checked?: boolean;
}
-export const TaskListItem: React.FC = ({ checked, ...props }) => {
+export const TaskListItem: React.FC = ({ checked, node: _node, ...props }) => {
const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const checkboxRef = useRef(null);
@@ -35,14 +35,19 @@ export const TaskListItem: React.FC = ({ checked, ...props })
if (taskIndexStr !== null) {
taskIndex = parseInt(taskIndexStr);
} else {
- // Fallback: Calculate index by counting ALL task list items in the memo
- // Find the markdown-content container by traversing up from the list item
- const container = listItem.closest(".markdown-content");
- if (!container) {
- return;
+ // Fallback: Calculate index by counting task list items
+ // Walk up to find the parent element with all task items
+ let searchRoot = listItem.parentElement;
+ while (searchRoot && !searchRoot.classList.contains(TASK_LIST_CLASS)) {
+ searchRoot = searchRoot.parentElement;
}
- const allTaskItems = container.querySelectorAll("li.task-list-item");
+ // If not found, search from the document root
+ if (!searchRoot) {
+ searchRoot = document.body;
+ }
+
+ const allTaskItems = searchRoot.querySelectorAll(`li.${TASK_LIST_ITEM_CLASS}`);
for (let i = 0; i < allTaskItems.length; i++) {
if (allTaskItems[i] === listItem) {
taskIndex = i;
diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts
index 2a7c8293d..237b1e6b8 100644
--- a/web/src/components/MemoContent/constants.ts
+++ b/web/src/components/MemoContent/constants.ts
@@ -1,6 +1,16 @@
import { defaultSchema } from "rehype-sanitize";
-export const MAX_DISPLAY_HEIGHT = 256;
+// Class names added by remark-gfm for task lists
+export const TASK_LIST_CLASS = "contains-task-list";
+export const TASK_LIST_ITEM_CLASS = "task-list-item";
+
+// Compact mode display settings
+export const COMPACT_MODE_CONFIG = {
+ maxHeightVh: 60, // 60% of viewport height
+ gradientHeight: "h-24", // Tailwind class for gradient overlay
+} as const;
+
+export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
diff --git a/web/src/components/MemoContent/hooks.ts b/web/src/components/MemoContent/hooks.ts
index 2f7c82ca1..bc8187472 100644
--- a/web/src/components/MemoContent/hooks.ts
+++ b/web/src/components/MemoContent/hooks.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { COMPACT_STATES, MAX_DISPLAY_HEIGHT } from "./constants";
+import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
import type { ContentCompactView } from "./types";
export const useCompactMode = (enabled: boolean) => {
@@ -8,7 +8,8 @@ export const useCompactMode = (enabled: boolean) => {
useEffect(() => {
if (!enabled || !containerRef.current) return;
- if (containerRef.current.getBoundingClientRect().height > MAX_DISPLAY_HEIGHT) {
+ const maxHeight = getMaxDisplayHeight();
+ if (containerRef.current.getBoundingClientRect().height > maxHeight) {
setMode("ALL");
}
}, [enabled]);
diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx
index f0114909b..b60ffffe6 100644
--- a/web/src/components/MemoContent/index.tsx
+++ b/web/src/components/MemoContent/index.tsx
@@ -1,4 +1,5 @@
import type { Element } from "hast";
+import { ChevronDown, ChevronUp } from "lucide-react";
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
@@ -14,8 +15,10 @@ import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
-import { SANITIZE_SCHEMA } from "./constants";
+import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
+import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
+import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types";
@@ -36,16 +39,18 @@ const MemoContent = (props: MemoContentProps) => {
*:last-child]:mb-0",
+ showCompactMode === "ALL" && "overflow-hidden",
contentClassName,
)}
+ style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
& { node?: Element }) => {
@@ -61,28 +66,61 @@ const MemoContent = (props: MemoContentProps) => {
}
return ;
}) as React.ComponentType>,
- pre: CodeBlock,
- a: ({ href, children, ...aProps }) => (
-
+ // Headings
+ h1: ({ children }) => {children},
+ h2: ({ children }) => {children},
+ h3: ({ children }) => {children},
+ h4: ({ children }) => {children},
+ h5: ({ children }) => {children},
+ h6: ({ children }) => {children},
+ // Block elements
+ p: ({ children }) => {children},
+ blockquote: ({ children }) => {children}
,
+ hr: () => ,
+ // Lists
+ ul: ({ children, ...props }) => {children}
,
+ ol: ({ children, ...props }) => (
+
{children}
-
+
),
+ li: ({ children, ...props }) => {children},
+ // Inline elements
+ a: ({ children, ...props }) => {children},
+ code: ({ children }) => {children},
+ img: ({ ...props }) => ,
+ // Code blocks
+ pre: CodeBlock,
+ // Tables
+ table: ({ children }) => ,
+ thead: ({ children }) => {children},
+ tbody: ({ children }) => {children},
+ tr: ({ children }) => {children},
+ th: ({ children, ...props }) => {children},
+ td: ({ children, ...props }) => {children},
}}
>
{content}
+ {showCompactMode === "ALL" && (
+
+ )}
- {showCompactMode === "ALL" && (
-
- )}
{showCompactMode !== undefined && (
-
+
)}
diff --git a/web/src/components/MemoContent/markdown/Blockquote.tsx b/web/src/components/MemoContent/markdown/Blockquote.tsx
new file mode 100644
index 000000000..c8ed5fc31
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/Blockquote.tsx
@@ -0,0 +1,17 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface BlockquoteProps extends React.BlockquoteHTMLAttributes
, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+/**
+ * Blockquote component with left border accent
+ */
+export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/src/components/MemoContent/markdown/Heading.tsx b/web/src/components/MemoContent/markdown/Heading.tsx
new file mode 100644
index 000000000..000589abc
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/Heading.tsx
@@ -0,0 +1,30 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface HeadingProps extends React.HTMLAttributes, ReactMarkdownProps {
+ level: 1 | 2 | 3 | 4 | 5 | 6;
+ children: React.ReactNode;
+}
+
+/**
+ * Heading component for h1-h6 elements
+ * Renders semantic heading levels with consistent styling
+ */
+export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
+ const Component = `h${level}` as const;
+
+ const levelClasses = {
+ 1: "text-3xl font-bold border-b border-border pb-2",
+ 2: "text-2xl font-semibold border-b border-border pb-1.5",
+ 3: "text-xl font-semibold",
+ 4: "text-lg font-semibold",
+ 5: "text-base font-semibold",
+ 6: "text-base font-medium text-muted-foreground",
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/src/components/MemoContent/markdown/HorizontalRule.tsx b/web/src/components/MemoContent/markdown/HorizontalRule.tsx
new file mode 100644
index 000000000..dc798b778
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/HorizontalRule.tsx
@@ -0,0 +1,11 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface HorizontalRuleProps extends React.HTMLAttributes, ReactMarkdownProps {}
+
+/**
+ * Horizontal rule separator
+ */
+export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
+ return
;
+};
diff --git a/web/src/components/MemoContent/markdown/Image.tsx b/web/src/components/MemoContent/markdown/Image.tsx
new file mode 100644
index 000000000..05def40f7
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/Image.tsx
@@ -0,0 +1,12 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface ImageProps extends React.ImgHTMLAttributes, ReactMarkdownProps {}
+
+/**
+ * Image component for markdown images
+ * Responsive with rounded corners
+ */
+export const Image = ({ className, alt, node: _node, ...props }: ImageProps) => {
+ return
;
+};
diff --git a/web/src/components/MemoContent/markdown/InlineCode.tsx b/web/src/components/MemoContent/markdown/InlineCode.tsx
new file mode 100644
index 000000000..945dc1c03
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/InlineCode.tsx
@@ -0,0 +1,17 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface InlineCodeProps extends React.HTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+/**
+ * Inline code component with background and monospace font
+ */
+export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/src/components/MemoContent/markdown/Link.tsx b/web/src/components/MemoContent/markdown/Link.tsx
new file mode 100644
index 000000000..d305fdaf7
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/Link.tsx
@@ -0,0 +1,27 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface LinkProps extends React.AnchorHTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+/**
+ * Link component for external links
+ * Opens in new tab with security attributes
+ */
+export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/src/components/MemoContent/markdown/List.tsx b/web/src/components/MemoContent/markdown/List.tsx
new file mode 100644
index 000000000..b9969bfcf
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/List.tsx
@@ -0,0 +1,67 @@
+import { cn } from "@/lib/utils";
+import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
+import type { ReactMarkdownProps } from "./types";
+
+interface ListProps extends React.HTMLAttributes, ReactMarkdownProps {
+ ordered?: boolean;
+ children: React.ReactNode;
+}
+
+/**
+ * List component for both regular and task lists (GFM)
+ * Detects task lists via the "contains-task-list" class added by remark-gfm
+ */
+export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
+ const Component = ordered ? "ol" : "ul";
+ const isTaskList = className?.includes(TASK_LIST_CLASS);
+
+ return (
+
+ {children}
+
+ );
+};
+
+interface ListItemProps extends React.LiHTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+/**
+ * List item component for both regular and task list items
+ * Detects task items via the "task-list-item" class added by remark-gfm
+ * Applies specialized styling for task checkboxes
+ */
+export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
+ const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
+
+ if (isTaskListItem) {
+ return (
+ button]:mr-2 [&>button]:align-middle",
+ "[&>p]:inline [&>p]:m-0",
+ `[&>.${TASK_LIST_CLASS}]:pl-6`,
+ className,
+ )}
+ {...domProps}
+ >
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/src/components/MemoContent/markdown/Paragraph.tsx b/web/src/components/MemoContent/markdown/Paragraph.tsx
new file mode 100644
index 000000000..ecf5e67e6
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/Paragraph.tsx
@@ -0,0 +1,17 @@
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface ParagraphProps extends React.HTMLAttributes, ReactMarkdownProps {
+ children: React.ReactNode;
+}
+
+/**
+ * Paragraph component with compact spacing
+ */
+export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/src/components/MemoContent/markdown/README.md b/web/src/components/MemoContent/markdown/README.md
new file mode 100644
index 000000000..a6ba08cc7
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/README.md
@@ -0,0 +1,97 @@
+# Markdown Components
+
+Modern, type-safe React components for rendering markdown content via react-markdown.
+
+## Architecture
+
+### Component-Based Rendering
+Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides:
+
+- **Type Safety**: Full TypeScript support with proper prop types
+- **Maintainability**: Components are easier to test, modify, and understand
+- **Performance**: No CSS specificity conflicts, cleaner DOM
+- **Modularity**: Each element is independently styled and documented
+
+### Type System
+
+All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to:
+1. Filter it from DOM props (avoids `node="[object Object]"` in HTML)
+2. Keep it available for advanced use cases (e.g., detecting task lists)
+3. Maintain type safety without `as any` casts
+
+### GFM Task Lists
+
+Task lists (from remark-gfm) are handled by:
+- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
+- **Styling**: Tailwind utilities with arbitrary variants for nested elements
+- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
+- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
+
+### Component Patterns
+
+Each component follows this structure:
+```tsx
+import { cn } from "@/lib/utils";
+import type { ReactMarkdownProps } from "./types";
+
+interface ComponentProps extends React.HTMLAttributes, ReactMarkdownProps {
+ children?: React.ReactNode;
+ // component-specific props
+}
+
+/**
+ * JSDoc description
+ */
+export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
+ return (
+
+ {children}
+
+ );
+};
+```
+
+## Components
+
+| Component | Element | Purpose |
+|-----------|---------|---------|
+| `Heading` | h1-h6 | Semantic headings with level-based styling |
+| `Paragraph` | p | Compact paragraphs with consistent spacing |
+| `Link` | a | External links with security attributes |
+| `List` | ul/ol | Regular and GFM task lists |
+| `ListItem` | li | List items with task checkbox support |
+| `Blockquote` | blockquote | Quotes with left border accent |
+| `InlineCode` | code | Inline code with background |
+| `Image` | img | Responsive images with rounded corners |
+| `HorizontalRule` | hr | Section separators |
+
+## Styling Approach
+
+- **Tailwind CSS**: All styling uses Tailwind utilities
+- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
+- **Responsive**: Max-width constraints, responsive images
+- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
+
+## Integration
+
+Components are mapped to HTML elements in `MemoContent/index.tsx`:
+
+```tsx
+ {children},
+ p: ({ children, ...props }) => {children},
+ // ... more mappings
+ }}
+>
+ {content}
+
+```
+
+## Future Enhancements
+
+- [ ] Syntax highlighting themes for code blocks
+- [ ] Table sorting/filtering interactions
+- [ ] Image lightbox/zoom functionality
+- [ ] Collapsible sections for long content
+- [ ] Copy button for code blocks
diff --git a/web/src/components/MemoContent/markdown/index.ts b/web/src/components/MemoContent/markdown/index.ts
new file mode 100644
index 000000000..e395d51eb
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/index.ts
@@ -0,0 +1,8 @@
+export { Blockquote } from "./Blockquote";
+export { Heading } from "./Heading";
+export { HorizontalRule } from "./HorizontalRule";
+export { Image } from "./Image";
+export { InlineCode } from "./InlineCode";
+export { Link } from "./Link";
+export { List, ListItem } from "./List";
+export { Paragraph } from "./Paragraph";
diff --git a/web/src/components/MemoContent/markdown/types.ts b/web/src/components/MemoContent/markdown/types.ts
new file mode 100644
index 000000000..b50d4fcc3
--- /dev/null
+++ b/web/src/components/MemoContent/markdown/types.ts
@@ -0,0 +1,9 @@
+import type { Element } from "hast";
+
+/**
+ * Props passed by react-markdown to custom components
+ * Includes the AST node for advanced use cases
+ */
+export interface ReactMarkdownProps {
+ node?: Element;
+}
diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
index f236d0e84..4a3822afa 100644
--- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
+++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
@@ -1,7 +1,10 @@
import { create } from "@bufbuild/protobuf";
-import { timestampDate } from "@bufbuild/protobuf/wkt";
+import { timestampFromDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react";
+import toast from "react-hot-toast";
+import EditableTimestamp from "@/components/EditableTimestamp";
+import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils";
import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
@@ -15,87 +18,96 @@ interface Props {
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
+ const { mutate: updateMemo } = useUpdateMemo();
const property = create(Memo_PropertySchema, memo.property || {});
- const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode || property.hasIncompleteTasks;
- const shouldShowRelationGraph = memo.relations.filter((r) => r.type === MemoRelation_Type.REFERENCE).length > 0;
+ const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode;
+ const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE);
+
+ const handleUpdateTimestamp = (field: "createTime" | "updateTime", date: Date) => {
+ const currentTimestamp = memo[field];
+ const newTimestamp = timestampFromDate(date);
+ if (isEqual(currentTimestamp, newTimestamp)) {
+ return;
+ }
+ updateMemo(
+ {
+ update: { name: memo.name, [field]: newTimestamp },
+ updateMask: [field === "createTime" ? "create_time" : "update_time"],
+ },
+ {
+ onSuccess: () => toast.success("Updated successfully"),
+ onError: (error) => toast.error(error.message),
+ },
+ );
+ };
return (