make fixes

This commit is contained in:
Arjun Patel
2026-06-11 11:25:52 -07:00
parent 66d53497c5
commit 5b199434bf
10 changed files with 226 additions and 25 deletions
+1
View File
@@ -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",
@@ -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 (
<div
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
onClick={handleOpen}
>
<div className="flex flex-col gap-1 p-3">
<div className="flex items-center gap-1.5 text-xs text-white/50">
<Globe className="size-4" />
<span className="truncate">{domainFromUrl(url)}</span>
</div>
<p className="truncate text-sm font-medium text-white/80">{url}</p>
<div className="mt-1.5 flex gap-2">
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleOpen}
>
<ExternalLink data-icon="inline-start" />
Open
</Button>
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleCopy}
>
<Copy data-icon="inline-start" />
Copy
</Button>
</div>
</div>
</div>
);
}
export function LinkPreviewCardSkeleton() {
return (
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
@@ -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 (
<button
type="button"
onClick={() => platform.link.openExternal(entry.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-1 text-[10px] text-white/40">
<Globe className="size-3" />
<span className="truncate">{domainFromUrl(entry.url)}</span>
</div>
<p className="truncate text-[11px] leading-tight text-white/60">
{entry.url}
</p>
</button>
);
}
const { metadata } = entry;
@@ -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;
}
@@ -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<HTMLElement>('.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();
});
}
});
+18 -10
View File
@@ -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<HTMLTextAreaElement>(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 && (
<KeyHint
keys={`${metaKey}+M`}
onClick={() => 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}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl">
{/* 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. */}
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[50rem] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200">
{/* 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. */}
@@ -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 (
<div className="flex flex-wrap gap-3">
@@ -72,7 +68,9 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
<LinkPreviewCardSkeleton />
) : entry.metadata ? (
<LinkPreviewCard metadata={entry.metadata} />
) : null}
) : (
<LinkPreviewCardFallback url={entry.url} />
)}
</div>
))}
</div>
+5 -2
View File
@@ -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
+8
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
export function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*\S[^*]*\*|\b_\S[^_]*_\b|^>/m.test(
content,
);
}