diff --git a/js/desktop/package.json b/js/desktop/package.json index 9cdc2be..aeda838 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -1,8 +1,8 @@ { "name": "Flowy.llink", "productName": "Flowy.llink", - "version": "1.4.1", - "description": "Flowy.llink is a team communication app for teams", + "version": "1.4.0", + "description": "Flowy.llink is a video messaging app for teams", "main": ".vite/build/main.js", "private": true, "scripts": { @@ -58,6 +58,7 @@ "dependencies": { "@livekit/components-react": "^2.9.20", "@livekit/components-styles": "^1.2.0", + "@milkdown/crepe": "^7.21.1", "@sentry/electron": "^7.11.0", "@sentry/react": "^10.54.0", "@tanstack/react-query": "^5.90.21", @@ -65,7 +66,6 @@ "clsx": "^2.1.1", "electron-squirrel-startup": "^1.0.1", "firebase": "^12.10.0", - "highlight.js": "^11.11.1", "livekit-client": "^2.18.0", "lucide-react": "^0.575.0", "next-themes": "^0.4.6", @@ -73,11 +73,8 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.1", - "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", "react-use": "^17.6.0", - "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1", "shadcn": "^3.8.5", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css new file mode 100644 index 0000000..f33e6b7 --- /dev/null +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -0,0 +1,77 @@ +/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces. + * Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom + * properties; we override those to white-on-transparent with a blue accent so + * the editor (and the read-only display, which uses the same engine) sits on + * top of the existing card instead of painting its own opaque background. + * + * The floating menus (slash menu, selection toolbar, link tooltip) are portaled + * to , OUTSIDE .milkdown — so the variables must be declared on those + * selectors too, otherwise they fall back to transparent and the menu is + * unreadable over the content behind it. */ +.llink-crepe .milkdown, +.milkdown-slash-menu, +.milkdown-toolbar, +.milkdown-link-edit, +.milkdown-link-preview, +.milkdown-block-handle { + --crepe-color-background: transparent; + --crepe-color-on-background: rgb(255 255 255 / 0.92); + /* Surface backs code blocks, tables, and the floating menus — keep it + * (near-)opaque so menus read clearly over content. */ + --crepe-color-surface: rgb(24 24 28 / 0.96); + --crepe-color-surface-low: rgb(42 42 50 / 0.96); + --crepe-color-on-surface: #ffffff; + --crepe-color-on-surface-variant: rgb(255 255 255 / 0.65); + --crepe-color-outline: rgb(255 255 255 / 0.2); + --crepe-color-primary: #60a5fa; + --crepe-color-secondary: rgb(96 165 250 / 0.25); + --crepe-color-on-secondary: #ffffff; + --crepe-color-inverse: #ffffff; + --crepe-color-on-inverse: #0b0b0b; + --crepe-color-inline-code: #fca5a5; + --crepe-color-inline-area: rgb(255 255 255 / 0.1); + --crepe-color-error: #f87171; + --crepe-color-hover: rgb(255 255 255 / 0.08); + --crepe-color-selected: rgb(96 165 250 / 0.25); + /* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */ + --crepe-font-default: inherit; + --crepe-font-title: inherit; + --crepe-font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; +} + +/* Glassy floating menus over content. */ +.milkdown-slash-menu, +.milkdown-toolbar, +.milkdown-link-edit, +.milkdown-link-preview { + backdrop-filter: blur(12px); +} + +.llink-crepe .milkdown { + background: transparent; + box-shadow: none; +} + +.llink-crepe .milkdown .ProseMirror { + padding: 0; + outline: none; +} + +/* Editing context: fill the compose card and scroll internally so a long + * message stays inside the card rather than growing the whole overlay. */ +.llink-crepe--fill, +.llink-crepe--fill .milkdown { + height: 100%; +} + +.llink-crepe--fill .milkdown { + overflow-y: auto; +} + +/* Pad the content (not the card) so the slash menu — which Crepe appends to + * .milkdown — can use the full card width/height before clipping. */ +.llink-crepe--fill .milkdown .ProseMirror { + min-height: 100%; + padding: 1.25rem; +} diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx new file mode 100644 index 0000000..350ca60 --- /dev/null +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from "react"; +import { Crepe } from "@milkdown/crepe"; +import "@milkdown/crepe/theme/common/style.css"; +import "@milkdown/crepe/theme/frame-dark.css"; +import "./markdown-editor.css"; +import { cn } from "@/lib/utils"; + +interface MarkdownEditorProps { + /** Initial markdown. The editor owns its content after mount; edits flow out + * through `onChange`, so this is only read when the editor is (re)created. */ + value: string; + onChange?: (markdown: string) => void; + placeholder?: string; + /** Render the same engine read-only, for displaying a message. */ + readOnly?: boolean; + autoFocus?: boolean; + className?: string; +} + +/** + * Single markdown engine used for both composing and displaying messages + * (Milkdown Crepe). Editing is inline/WYSIWYG (Obsidian/Notion feel) with full + * GFM — headings, lists, task lists, tables, code blocks, quotes, links — and + * read-only mode renders the exact same way, so write and read never drift. + */ +export function MarkdownEditor({ + value, + onChange, + placeholder, + readOnly = false, + autoFocus = true, + className, +}: MarkdownEditorProps) { + const rootRef = useRef(null); + // Keep the latest onChange without forcing the editor to be recreated. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + let cancelled = false; + let created: Crepe | null = null; + + const crepe = new Crepe({ + root, + defaultValue: value, + features: { + [Crepe.Feature.CodeMirror]: true, + [Crepe.Feature.ListItem]: true, + [Crepe.Feature.Table]: true, + [Crepe.Feature.LinkTooltip]: !readOnly, + [Crepe.Feature.Cursor]: !readOnly, + [Crepe.Feature.BlockEdit]: !readOnly, + [Crepe.Feature.Toolbar]: !readOnly, + [Crepe.Feature.Placeholder]: !readOnly, + [Crepe.Feature.ImageBlock]: false, + [Crepe.Feature.Latex]: false, + [Crepe.Feature.TopBar]: false, + [Crepe.Feature.AI]: false, + }, + featureConfigs: { + [Crepe.Feature.Placeholder]: { text: placeholder ?? "" }, + }, + }); + + crepe.setReadonly(readOnly); + + if (!readOnly) { + crepe.on((listener) => { + listener.markdownUpdated((_ctx, markdown) => { + onChangeRef.current?.(markdown); + }); + }); + } + + crepe.create().then(() => { + if (cancelled) { + crepe.destroy(); + return; + } + created = crepe; + if (autoFocus && !readOnly) { + root.querySelector(".ProseMirror")?.focus(); + } + }); + + return () => { + cancelled = true; + created?.destroy(); + }; + // `value` is the initial content only; recreate when the mode flips. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [readOnly]); + + return ( +
+ ); +} diff --git a/js/desktop/src/features/compose/text-editor.tsx b/js/desktop/src/features/compose/text-editor.tsx index 900cae1..47febad 100644 --- a/js/desktop/src/features/compose/text-editor.tsx +++ b/js/desktop/src/features/compose/text-editor.tsx @@ -6,10 +6,7 @@ import { useAllLinkMetadata } from "@/hooks/use-link-metadata"; import { AttachmentStrip } from "@/features/compose/attachment-strip"; import type { PendingAttachment } from "@/features/compose/attachment-strip"; import { Button } from "@/components/ui/button"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import rehypeHighlight from "rehype-highlight"; -import "highlight.js/styles/github-dark.css"; +import { MarkdownEditor } from "@/features/compose/markdown-editor"; export interface TextEditorAttachmentProps { attachments: PendingAttachment[]; @@ -43,66 +40,6 @@ function getImmersiveTextStyle(length: number) { return { size: "text-2xl", weight: "font-normal" }; } -const markdownComponents: React.ComponentProps["components"] = { - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - h4: ({ children }) =>

{children}

, - h5: ({ children }) =>
{children}
, - h6: ({ children }) =>
{children}
, - p: ({ children }) =>

{children}

, - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - a: ({ href, children }) => ( - - {children} - - ), - code: ({ className, children, ...props }) => { - const isBlock = className?.startsWith("language-"); - if (isBlock) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
-      {children}
-    
- ), - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , -}; - -function MarkdownPreview({ content }: { content: string }) { - return ( -
    - - {content} - -
    - ); -} - export function TextEditor({ textContent, onTextChange, @@ -112,7 +49,6 @@ export function TextEditor({ attachmentProps, }: TextEditorProps) { const textareaRef = useRef(null); - const [previewMode, setPreviewMode] = useState(false); const [forceCardMode, setForceCardMode] = useState(false); const [debouncedText, setDebouncedText] = useState(textContent); @@ -127,33 +63,35 @@ export function TextEditor({ const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode; + // Keep the immersive textarea focused with the caret at the end when we + // (re)enter it. The card-mode editor manages its own focus. useEffect(() => { - if (!previewMode) { - const t = setTimeout(() => { - const el = textareaRef.current; - if (el) { - el.focus(); - el.selectionStart = el.selectionEnd = el.value.length; - } - }, 0); - return () => clearTimeout(t); - } - }, [previewMode, forceCardMode, immersive]); - - useEffect(() => { - textareaRef.current?.focus(); - }, []); + if (!immersive) return; + const t = setTimeout(() => { + const el = textareaRef.current; + if (el) { + el.focus(); + el.selectionStart = el.selectionEnd = el.value.length; + } + }, 0); + return () => clearTimeout(t); + }, [immersive]); + // Attached in the capture phase so our shortcuts win before the card-mode + // editor's own key handling (e.g. ⌘+Enter must submit, not insert a break). const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); + e.stopPropagation(); onCancel(); } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); + e.stopPropagation(); if (textContent.trim()) onSubmit(); } else if (e.key === "m" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); + e.stopPropagation(); setForceCardMode(true); } }, @@ -250,51 +188,22 @@ export function TextEditor({ isDragging && "ring-2 ring-inset ring-white/30", )} {...dropZoneProps} - onKeyDown={handleKeyDown} + onKeyDownCapture={handleKeyDown} > -
    -
    - - +
    + {/* No padding here: the editor's own scroll box hosts the slash menu, + so we pad inside the editor (ProseMirror) instead. That keeps the + menu's clipping bounds the full card rather than the inset box. */} +
    +
    - {previewMode ? ( - - ) : ( -