From 5b199434bfc0d26413e27ad1837cc14e659ca054 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 11:25:52 -0700 Subject: [PATCH 1/2] make fixes --- js/desktop/package.json | 1 + .../src/components/link-preview-card.tsx | 50 +++++++++- .../src/features/compose/attachment-strip.tsx | 19 +++- .../src/features/compose/markdown-editor.css | 97 ++++++++++++++++++- .../src/features/compose/markdown-editor.tsx | 24 ++++- .../src/features/compose/text-editor.tsx | 28 ++++-- .../features/particles/text-particle-view.tsx | 12 +-- js/desktop/src/hooks/use-file-input.ts | 7 +- js/desktop/src/lib/link-metadata.ts | 8 ++ js/desktop/src/lib/markdown.ts | 5 + 10 files changed, 226 insertions(+), 25 deletions(-) create mode 100644 js/desktop/src/lib/markdown.ts diff --git a/js/desktop/package.json b/js/desktop/package.json index 065e486..3f9e0d6 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -64,6 +64,7 @@ "@livekit/components-react": "^2.9.20", "@livekit/components-styles": "^1.2.0", "@milkdown/crepe": "^7.21.1", + "@milkdown/kit": "7.21.1", "@sentry/electron": "^7.11.0", "@sentry/react": "^10.54.0", "@tanstack/react-query": "^5.90.21", diff --git a/js/desktop/src/components/link-preview-card.tsx b/js/desktop/src/components/link-preview-card.tsx index 3e3c368..f5f1d98 100644 --- a/js/desktop/src/components/link-preview-card.tsx +++ b/js/desktop/src/components/link-preview-card.tsx @@ -1,5 +1,5 @@ import { Copy, ExternalLink, Globe } from 'lucide-react'; -import type { LinkMetadata } from '@/lib/link-metadata'; +import { domainFromUrl, type LinkMetadata } from '@/lib/link-metadata'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { platform } from '@/lib/platform'; @@ -93,6 +93,54 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) { ); } +/** Shown when metadata couldn't be fetched — the link itself still works. */ +export function LinkPreviewCardFallback({ url }: { url: string }) { + const handleOpen = (e: React.MouseEvent) => { + e.stopPropagation(); + platform.link.openExternal(url); + }; + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(url); + }; + + return ( +
+
+
+ + {domainFromUrl(url)} +
+

{url}

+
+ + +
+
+
+ ); +} + export function LinkPreviewCardSkeleton() { return (
diff --git a/js/desktop/src/features/compose/attachment-strip.tsx b/js/desktop/src/features/compose/attachment-strip.tsx index 84776b5..128d03d 100644 --- a/js/desktop/src/features/compose/attachment-strip.tsx +++ b/js/desktop/src/features/compose/attachment-strip.tsx @@ -4,6 +4,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import type { LinkPreviewEntry } from '@/hooks/use-link-metadata'; +import { domainFromUrl } from '@/lib/link-metadata'; import { AttachmentLightbox, getAttachmentHandler, @@ -114,7 +115,23 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) { ); } - if (!entry.metadata) return null; + if (!entry.metadata) { + return ( + + ); + } const { metadata } = entry; diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css index 2f56fef..60de521 100644 --- a/js/desktop/src/features/compose/markdown-editor.css +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -57,6 +57,80 @@ .llink-crepe .milkdown .ProseMirror { padding: 0; outline: none; + caret-color: white; +} + +/* Crepe's default heading scale (42px h1) is sized for a document editor; + * tighten it to message-card proportions. Applies to compose and read-only + * alike so the editing view matches the posted card. */ +.llink-crepe .milkdown .ProseMirror h1 { + font-size: 1.875rem; + line-height: 2.375rem; + font-weight: 600; + margin-top: 24px; +} + +.llink-crepe .milkdown .ProseMirror h2 { + font-size: 1.5rem; + line-height: 2rem; + font-weight: 600; + margin-top: 20px; +} + +.llink-crepe .milkdown .ProseMirror h3 { + font-size: 1.25rem; + line-height: 1.75rem; + font-weight: 600; + margin-top: 16px; +} + +.llink-crepe .milkdown .ProseMirror h4 { + font-size: 1.125rem; + line-height: 1.625rem; + font-weight: 600; + margin-top: 12px; +} + +.llink-crepe .milkdown .ProseMirror h5 { + font-size: 1rem; + line-height: 1.5rem; + font-weight: 600; + margin-top: 12px; +} + +.llink-crepe .milkdown .ProseMirror h6 { + font-size: 0.875rem; + line-height: 1.375rem; + font-weight: 700; + margin-top: 12px; + color: rgb(255 255 255 / 0.7); +} + +.llink-crepe .milkdown .ProseMirror > :first-child { + margin-top: 0; +} + +/* Images come from URLs only (no stable public upload URL), so hide the + * file uploader — the placeholder then just prompts for a link. */ +.llink-crepe + .milkdown + :is(.milkdown-image-block, .milkdown-image-inline) + .placeholder + .uploader { + display: none; +} + +/* Read-only renders the image node view with inert editing chrome — hide it. */ +.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation, +.llink-crepe:not(.llink-crepe--fill) + .milkdown + .milkdown-image-block + .image-resize-handle { + display: none; +} + +.llink-crepe .milkdown .milkdown-image-block img { + border-radius: 8px; } /* Editing context: fill the compose card and scroll internally so a long @@ -70,9 +144,28 @@ overflow-y: auto; } +.llink-crepe--fill .milkdown::-webkit-scrollbar { + width: 8px; +} + +.llink-crepe--fill .milkdown::-webkit-scrollbar-track { + background: transparent; +} + +.llink-crepe--fill .milkdown::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: rgb(255 255 255 / 0.3); +} + +.llink-crepe--fill .milkdown::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 0.5); +} + /* Pad the content (not the card) so the slash menu — which Crepe appends to - * .milkdown — can use the full card width/height before clipping. */ + * .milkdown — can use the full card width/height before clipping. The 5.5rem + * gutters also fit the block drag handle (66px wide, offset 16px left of the + * block), which would otherwise clip against the card edge. */ .llink-crepe--fill .milkdown .ProseMirror { min-height: 100%; - padding: 1.25rem; + padding: 1.5rem 5.5rem; } diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx index 7b2ce0f..85931c7 100644 --- a/js/desktop/src/features/compose/markdown-editor.tsx +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react'; import { Crepe } from '@milkdown/crepe'; +import { editorViewCtx } from '@milkdown/kit/core'; +import { Selection } from '@milkdown/kit/prose/state'; import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/frame-dark.css'; import './markdown-editor.css'; @@ -55,13 +57,20 @@ export function MarkdownEditor({ [Crepe.Feature.BlockEdit]: !readOnly, [Crepe.Feature.Toolbar]: !readOnly, [Crepe.Feature.Placeholder]: !readOnly, - [Crepe.Feature.ImageBlock]: false, + [Crepe.Feature.ImageBlock]: true, [Crepe.Feature.Latex]: false, [Crepe.Feature.TopBar]: false, [Crepe.Feature.AI]: false, }, featureConfigs: { [Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, + // Images come from URLs only (e.g. pasted markdown) — there is no + // stable public upload URL, so the file uploader is hidden in CSS. + [Crepe.Feature.ImageBlock]: { + blockUploadPlaceholderText: 'Paste an image link…', + inlineUploadPlaceholderText: 'paste an image link', + maxHeight: 420, + }, }, }); @@ -82,7 +91,18 @@ export function MarkdownEditor({ } created = crepe; if (autoFocus && !readOnly) { - root.querySelector('.ProseMirror')?.focus(); + // Place the caret at the end of the document — the editor often mounts + // mid-typing (immersive → card flip), where start-of-doc would strand + // the user. Selection-only transactions don't echo markdownUpdated. + crepe.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + view.dispatch( + view.state.tr + .setSelection(Selection.atEnd(view.state.doc)) + .scrollIntoView(), + ); + view.focus(); + }); } }); diff --git a/js/desktop/src/features/compose/text-editor.tsx b/js/desktop/src/features/compose/text-editor.tsx index 947c0fd..fd919ca 100644 --- a/js/desktop/src/features/compose/text-editor.tsx +++ b/js/desktop/src/features/compose/text-editor.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Paperclip } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { hasMarkdownFormatting } from '@/lib/markdown'; import { metaKey } from '@/lib/platform'; import { useAllLinkMetadata } from '@/hooks/use-link-metadata'; import { AttachmentStrip } from '@/features/compose/attachment-strip'; @@ -50,7 +51,9 @@ export function TextEditor({ attachmentProps, }: TextEditorProps) { const textareaRef = useRef(null); - const [forceCardMode, setForceCardMode] = useState(false); + // Card mode latches: once the message needs the full editor, snapping back + // to immersive mid-edit would be jarring, so it stays for the session. + const [carded, setCarded] = useState(false); const [debouncedText, setDebouncedText] = useState(textContent); useEffect(() => { @@ -61,13 +64,15 @@ export function TextEditor({ const attachmentCount = attachmentProps?.attachments.length ?? 0; const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0; - const immersive = - textContent.length < IMMERSIVE_CHAR_LIMIT && - !hasEnrichments && - !forceCardMode; + const shouldCard = + textContent.length >= IMMERSIVE_CHAR_LIMIT || + hasEnrichments || + hasMarkdownFormatting(textContent); + if (shouldCard && !carded) setCarded(true); + const immersive = !carded; - // Keep the immersive textarea focused with the caret at the end when we - // (re)enter it. The card-mode editor manages its own focus. + // Keep the immersive textarea focused with the caret at the end on mount. + // The card-mode editor manages its own focus. useEffect(() => { if (!immersive) return; const t = setTimeout(() => { @@ -95,7 +100,7 @@ export function TextEditor({ } else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); - setForceCardMode(true); + setCarded(true); } }, [onCancel, onSubmit, textContent], @@ -127,7 +132,7 @@ export function TextEditor({ {immersive && ( setForceCardMode(true)} + onClick={() => setCarded(true)} title={`Switch to markdown editor (or press ${metaKey}+M)`} > markdown @@ -194,7 +199,10 @@ export function TextEditor({ {...dropZoneProps} onKeyDownCapture={handleKeyDown} > -
+ {/* max-w-[50rem] minus the editor's 5.5rem gutters yields the same 624px + content column as the posted card (max-w-2xl minus p-6), so line + wrapping while editing matches the final render. */} +
{/* 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. */} diff --git a/js/desktop/src/features/particles/text-particle-view.tsx b/js/desktop/src/features/particles/text-particle-view.tsx index 47b807a..59dad06 100644 --- a/js/desktop/src/features/particles/text-particle-view.tsx +++ b/js/desktop/src/features/particles/text-particle-view.tsx @@ -8,8 +8,10 @@ import { type LinkPreviewEntry, } from '@/hooks/use-link-metadata'; import { extractUrls } from '@/lib/link-metadata'; +import { hasMarkdownFormatting } from '@/lib/markdown'; import { LinkPreviewCard, + LinkPreviewCardFallback, LinkPreviewCardSkeleton, } from '@/components/link-preview-card'; import { useParticleAttachments } from '@/hooks/use-particle-attachments'; @@ -57,12 +59,6 @@ function getImmersiveTextStyle(length: number) { return { size: 'text-2xl', weight: 'font-normal' }; } -function hasMarkdownFormatting(content: string): boolean { - return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test( - content, - ); -} - function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { return (
@@ -72,7 +68,9 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { ) : entry.metadata ? ( - ) : null} + ) : ( + + )}
))}
diff --git a/js/desktop/src/hooks/use-file-input.ts b/js/desktop/src/hooks/use-file-input.ts index 625c5c4..302d6a7 100644 --- a/js/desktop/src/hooks/use-file-input.ts +++ b/js/desktop/src/hooks/use-file-input.ts @@ -48,16 +48,19 @@ export function useFileInput({ useEffect(() => { if (!enabled) return; + // Capture phase so file pastes always become attachments — ProseMirror + // would otherwise inline pasted images as ephemeral blob: URLs. const handlePaste = (e: ClipboardEvent) => { const files = Array.from(e.clipboardData?.files ?? []); if (files.length > 0) { e.preventDefault(); + e.stopPropagation(); onFilesRef.current(files); } }; - window.addEventListener('paste', handlePaste); - return () => window.removeEventListener('paste', handlePaste); + window.addEventListener('paste', handlePaste, true); + return () => window.removeEventListener('paste', handlePaste, true); }, [enabled]); // Drag and drop handlers diff --git a/js/desktop/src/lib/link-metadata.ts b/js/desktop/src/lib/link-metadata.ts index 0bdbc9d..3447ec0 100644 --- a/js/desktop/src/lib/link-metadata.ts +++ b/js/desktop/src/lib/link-metadata.ts @@ -14,3 +14,11 @@ export function extractUrls(text: string): string[] { // should only yield a single preview card. return Array.from(new Set(Array.from(text.matchAll(URL_REGEX), (m) => m[0]))); } + +export function domainFromUrl(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} diff --git a/js/desktop/src/lib/markdown.ts b/js/desktop/src/lib/markdown.ts new file mode 100644 index 0000000..eb188a3 --- /dev/null +++ b/js/desktop/src/lib/markdown.ts @@ -0,0 +1,5 @@ +export function hasMarkdownFormatting(content: string): boolean { + return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*\S[^*]*\*|\b_\S[^_]*_\b|^>/m.test( + content, + ); +} -- 2.54.0 From e821cd02ef994f38079134f28b80d2324c0ac6d7 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 12:03:58 -0700 Subject: [PATCH 2/2] cleanup --- .../src/components/link-preview-card.tsx | 52 ++++--------------- .../src/features/compose/attachment-strip.tsx | 31 +++-------- .../src/features/compose/markdown-editor.css | 25 ++------- .../src/features/compose/markdown-editor.tsx | 11 +++- .../src/features/compose/text-editor.tsx | 21 +++----- .../features/particles/text-particle-view.tsx | 19 +------ js/desktop/src/hooks/use-file-input.ts | 17 +++--- js/desktop/src/lib/immersive-text.ts | 7 +++ js/desktop/src/styles/globals.css | 28 ++++++++++ 9 files changed, 85 insertions(+), 126 deletions(-) create mode 100644 js/desktop/src/lib/immersive-text.ts diff --git a/js/desktop/src/components/link-preview-card.tsx b/js/desktop/src/components/link-preview-card.tsx index f5f1d98..5090f97 100644 --- a/js/desktop/src/components/link-preview-card.tsx +++ b/js/desktop/src/components/link-preview-card.tsx @@ -95,49 +95,17 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) { /** Shown when metadata couldn't be fetched — the link itself still works. */ export function LinkPreviewCardFallback({ url }: { url: string }) { - const handleOpen = (e: React.MouseEvent) => { - e.stopPropagation(); - platform.link.openExternal(url); - }; - - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(url); - }; - return ( -
-
-
- - {domainFromUrl(url)} -
-

{url}

-
- - -
-
-
+ ); } diff --git a/js/desktop/src/features/compose/attachment-strip.tsx b/js/desktop/src/features/compose/attachment-strip.tsx index 128d03d..4c8090b 100644 --- a/js/desktop/src/features/compose/attachment-strip.tsx +++ b/js/desktop/src/features/compose/attachment-strip.tsx @@ -115,34 +115,19 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) { ); } - if (!entry.metadata) { - return ( - - ); - } - + // Metadata fetch can fail; fall back to the bare URL so the link stays usable. const { metadata } = entry; + const domain = metadata?.domain ?? domainFromUrl(entry.url); + const title = metadata?.title ?? entry.url; return ( diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css index 60de521..7227482 100644 --- a/js/desktop/src/features/compose/markdown-editor.css +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -144,28 +144,11 @@ overflow-y: auto; } -.llink-crepe--fill .milkdown::-webkit-scrollbar { - width: 8px; -} - -.llink-crepe--fill .milkdown::-webkit-scrollbar-track { - background: transparent; -} - -.llink-crepe--fill .milkdown::-webkit-scrollbar-thumb { - border-radius: 9999px; - background: rgb(255 255 255 / 0.3); -} - -.llink-crepe--fill .milkdown::-webkit-scrollbar-thumb:hover { - background: rgb(255 255 255 / 0.5); -} - /* Pad the content (not the card) so the slash menu — which Crepe appends to - * .milkdown — can use the full card width/height before clipping. The 5.5rem - * gutters also fit the block drag handle (66px wide, offset 16px left of the - * block), which would otherwise clip against the card edge. */ + * .milkdown — can use the full card width/height before clipping. The shared + * layout variables (globals.css) keep the editor's content column identical + * to the posted card's, and the gutter fits the block drag handle. */ .llink-crepe--fill .milkdown .ProseMirror { min-height: 100%; - padding: 1.5rem 5.5rem; + padding: var(--message-card-padding) var(--message-editor-gutter); } diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx index 85931c7..43b3e00 100644 --- a/js/desktop/src/features/compose/markdown-editor.tsx +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -65,11 +65,14 @@ export function MarkdownEditor({ featureConfigs: { [Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, // Images come from URLs only (e.g. pasted markdown) — there is no - // stable public upload URL, so the file uploader is hidden in CSS. + // stable public upload URL, so the file uploader is hidden in CSS and + // onUpload rejects in case a file ever reaches it anyway (the default + // would serialize an ephemeral blob: URL into the message). [Crepe.Feature.ImageBlock]: { blockUploadPlaceholderText: 'Paste an image link…', inlineUploadPlaceholderText: 'paste an image link', maxHeight: 420, + onUpload: () => Promise.reject(new Error('Image uploads disabled')), }, }, }); @@ -117,7 +120,11 @@ export function MarkdownEditor({ return (
); } diff --git a/js/desktop/src/features/compose/text-editor.tsx b/js/desktop/src/features/compose/text-editor.tsx index fd919ca..2d06f80 100644 --- a/js/desktop/src/features/compose/text-editor.tsx +++ b/js/desktop/src/features/compose/text-editor.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Paperclip } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { getImmersiveTextStyle } from '@/lib/immersive-text'; import { hasMarkdownFormatting } from '@/lib/markdown'; import { metaKey } from '@/lib/platform'; import { useAllLinkMetadata } from '@/hooks/use-link-metadata'; @@ -36,12 +37,6 @@ interface TextEditorProps { const IMMERSIVE_CHAR_LIMIT = 120; -function getImmersiveTextStyle(length: number) { - if (length < 70) return { size: 'text-5xl', weight: 'font-semibold' }; - if (length < 130) return { size: 'text-3xl', weight: 'font-semibold' }; - return { size: 'text-2xl', weight: 'font-normal' }; -} - export function TextEditor({ textContent, onTextChange, @@ -65,10 +60,11 @@ export function TextEditor({ const attachmentCount = attachmentProps?.attachments.length ?? 0; const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0; const shouldCard = - textContent.length >= IMMERSIVE_CHAR_LIMIT || - hasEnrichments || - hasMarkdownFormatting(textContent); - if (shouldCard && !carded) setCarded(true); + !carded && + (textContent.length >= IMMERSIVE_CHAR_LIMIT || + hasEnrichments || + hasMarkdownFormatting(textContent)); + if (shouldCard) setCarded(true); const immersive = !carded; // Keep the immersive textarea focused with the caret at the end on mount. @@ -199,10 +195,7 @@ export function TextEditor({ {...dropZoneProps} onKeyDownCapture={handleKeyDown} > - {/* max-w-[50rem] minus the editor's 5.5rem gutters yields the same 624px - content column as the posted card (max-w-2xl minus p-6), so line - wrapping while editing matches the final render. */} -
+
{/* 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. */} diff --git a/js/desktop/src/features/particles/text-particle-view.tsx b/js/desktop/src/features/particles/text-particle-view.tsx index 59dad06..95607fb 100644 --- a/js/desktop/src/features/particles/text-particle-view.tsx +++ b/js/desktop/src/features/particles/text-particle-view.tsx @@ -8,6 +8,7 @@ import { type LinkPreviewEntry, } from '@/hooks/use-link-metadata'; import { extractUrls } from '@/lib/link-metadata'; +import { getImmersiveTextStyle } from '@/lib/immersive-text'; import { hasMarkdownFormatting } from '@/lib/markdown'; import { LinkPreviewCard, @@ -53,12 +54,6 @@ function computeReadDuration( return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S); } -function getImmersiveTextStyle(length: number) { - if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' }; - if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' }; - return { size: 'text-2xl', weight: 'font-normal' }; -} - function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { return (
@@ -207,17 +202,7 @@ export function TextParticleView({ // Mode 3: card layout return (
-
+
{ - const files = Array.from(e.clipboardData?.files ?? []); - if (files.length > 0) { - e.preventDefault(); - e.stopPropagation(); - onFilesRef.current(files); - } + const data = e.clipboardData; + if (!data) return; + const files = Array.from(data.files); + if (files.length === 0 || data.types.includes('text/plain')) return; + e.preventDefault(); + e.stopPropagation(); + onFilesRef.current(files); }; window.addEventListener('paste', handlePaste, true); diff --git a/js/desktop/src/lib/immersive-text.ts b/js/desktop/src/lib/immersive-text.ts new file mode 100644 index 0000000..529ba1e --- /dev/null +++ b/js/desktop/src/lib/immersive-text.ts @@ -0,0 +1,7 @@ +/** Font scale for short messages shown as large centered text. Shared by the + * composer and the posted view so editing matches the final render. */ +export function getImmersiveTextStyle(length: number) { + if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' }; + if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' }; + return { size: 'text-2xl', weight: 'font-normal' }; +} diff --git a/js/desktop/src/styles/globals.css b/js/desktop/src/styles/globals.css index 9773c25..b63b917 100644 --- a/js/desktop/src/styles/globals.css +++ b/js/desktop/src/styles/globals.css @@ -151,6 +151,34 @@ background: oklch(1 0 0 / 35%); } +/* Message cards: more prominent scrollbar than the subtle global default. + The descendant form reaches scroll containers we don't own (Crepe's .milkdown). */ +.scrollbar-card::-webkit-scrollbar, +.scrollbar-card ::-webkit-scrollbar { + width: 8px; +} + +.scrollbar-card::-webkit-scrollbar-thumb, +.scrollbar-card ::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 30%); + border-radius: 9999px; +} + +.scrollbar-card::-webkit-scrollbar-thumb:hover, +.scrollbar-card ::-webkit-scrollbar-thumb:hover { + background: oklch(1 0 0 / 50%); +} + +/* Message layout: the compose editor and the posted card derive their widths + from one shared content column, so line wrapping while editing matches the + final render. The editor gutter additionally fits Crepe's block drag handle + (66px wide, offset 16px left of the block). */ +:root { + --message-content-width: 39rem; + --message-card-padding: 1.5rem; + --message-editor-gutter: 5.5rem; +} + /* Frameless window drag regions */ .drag-region { -webkit-app-region: drag; -- 2.54.0