This commit is contained in:
Arjun Patel
2026-06-11 12:03:58 -07:00
parent 5b199434bf
commit e821cd02ef
9 changed files with 85 additions and 126 deletions
+10 -42
View File
@@ -95,49 +95,17 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
/** Shown when metadata couldn't be fetched — the link itself still works. */ /** Shown when metadata couldn't be fetched — the link itself still works. */
export function LinkPreviewCardFallback({ url }: { url: string }) { 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 ( return (
<div <LinkPreviewCard
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md" metadata={{
onClick={handleOpen} url,
> domain: domainFromUrl(url),
<div className="flex flex-col gap-1 p-3"> title: url,
<div className="flex items-center gap-1.5 text-xs text-white/50"> description: null,
<Globe className="size-4" /> image: null,
<span className="truncate">{domainFromUrl(url)}</span> favicon: null,
</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>
); );
} }
@@ -115,34 +115,19 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
); );
} }
if (!entry.metadata) { // Metadata fetch can fail; fall back to the bare URL so the link stays usable.
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; const { metadata } = entry;
const domain = metadata?.domain ?? domainFromUrl(entry.url);
const title = metadata?.title ?? entry.url;
return ( return (
<button <button
type="button" type="button"
onClick={() => platform.link.openExternal(metadata.url)} onClick={() => platform.link.openExternal(metadata?.url ?? 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" 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"> <div className="flex items-center gap-1 text-[10px] text-white/40">
{metadata.favicon ? ( {metadata?.favicon ? (
<img <img
src={metadata.favicon} src={metadata.favicon}
alt="" alt=""
@@ -154,11 +139,11 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
) : ( ) : (
<Globe className="size-3" /> <Globe className="size-3" />
)} )}
<span className="truncate">{metadata.domain}</span> <span className="truncate">{domain}</span>
</div> </div>
{metadata.title && ( {title && (
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80"> <p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
{metadata.title} {title}
</p> </p>
)} )}
</button> </button>
@@ -144,28 +144,11 @@
overflow-y: auto; 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 /* 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 * .milkdown — can use the full card width/height before clipping. The shared
* gutters also fit the block drag handle (66px wide, offset 16px left of the * layout variables (globals.css) keep the editor's content column identical
* block), which would otherwise clip against the card edge. */ * to the posted card's, and the gutter fits the block drag handle. */
.llink-crepe--fill .milkdown .ProseMirror { .llink-crepe--fill .milkdown .ProseMirror {
min-height: 100%; min-height: 100%;
padding: 1.5rem 5.5rem; padding: var(--message-card-padding) var(--message-editor-gutter);
} }
@@ -65,11 +65,14 @@ export function MarkdownEditor({
featureConfigs: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, [Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
// Images come from URLs only (e.g. pasted markdown) — there is no // 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]: { [Crepe.Feature.ImageBlock]: {
blockUploadPlaceholderText: 'Paste an image link…', blockUploadPlaceholderText: 'Paste an image link…',
inlineUploadPlaceholderText: 'paste an image link', inlineUploadPlaceholderText: 'paste an image link',
maxHeight: 420, maxHeight: 420,
onUpload: () => Promise.reject(new Error('Image uploads disabled')),
}, },
}, },
}); });
@@ -117,7 +120,11 @@ export function MarkdownEditor({
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)} className={cn(
'llink-crepe',
!readOnly && 'llink-crepe--fill scrollbar-card',
className,
)}
/> />
); );
} }
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { Paperclip } from 'lucide-react'; import { Paperclip } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getImmersiveTextStyle } from '@/lib/immersive-text';
import { hasMarkdownFormatting } from '@/lib/markdown'; import { hasMarkdownFormatting } from '@/lib/markdown';
import { metaKey } from '@/lib/platform'; import { metaKey } from '@/lib/platform';
import { useAllLinkMetadata } from '@/hooks/use-link-metadata'; import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
@@ -36,12 +37,6 @@ interface TextEditorProps {
const IMMERSIVE_CHAR_LIMIT = 120; 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({ export function TextEditor({
textContent, textContent,
onTextChange, onTextChange,
@@ -65,10 +60,11 @@ export function TextEditor({
const attachmentCount = attachmentProps?.attachments.length ?? 0; const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0; const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const shouldCard = const shouldCard =
textContent.length >= IMMERSIVE_CHAR_LIMIT || !carded &&
hasEnrichments || (textContent.length >= IMMERSIVE_CHAR_LIMIT ||
hasMarkdownFormatting(textContent); hasEnrichments ||
if (shouldCard && !carded) setCarded(true); hasMarkdownFormatting(textContent));
if (shouldCard) setCarded(true);
const immersive = !carded; const immersive = !carded;
// Keep the immersive textarea focused with the caret at the end on mount. // Keep the immersive textarea focused with the caret at the end on mount.
@@ -199,10 +195,7 @@ export function TextEditor({
{...dropZoneProps} {...dropZoneProps}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
> >
{/* max-w-[50rem] minus the editor's 5.5rem gutters yields the same 624px <div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200">
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, {/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */} menu's clipping bounds the full card rather than the inset box. */}
@@ -8,6 +8,7 @@ import {
type LinkPreviewEntry, type LinkPreviewEntry,
} from '@/hooks/use-link-metadata'; } from '@/hooks/use-link-metadata';
import { extractUrls } from '@/lib/link-metadata'; import { extractUrls } from '@/lib/link-metadata';
import { getImmersiveTextStyle } from '@/lib/immersive-text';
import { hasMarkdownFormatting } from '@/lib/markdown'; import { hasMarkdownFormatting } from '@/lib/markdown';
import { import {
LinkPreviewCard, LinkPreviewCard,
@@ -53,12 +54,6 @@ function computeReadDuration(
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S); 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[] }) { function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return ( return (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -207,17 +202,7 @@ export function TextParticleView({
// Mode 3: card layout // Mode 3: card layout
return ( return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]"> <div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div <div className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md">
className={cn(
'flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md',
'[&::-webkit-scrollbar]:w-2',
'[&::-webkit-scrollbar]:p-2',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:bg-white/30',
'[&::-webkit-scrollbar-thumb]:hover:bg-white/50',
)}
>
<MarkdownEditor <MarkdownEditor
key={content} key={content}
value={content} value={content}
+10 -7
View File
@@ -49,14 +49,17 @@ export function useFileInput({
if (!enabled) return; if (!enabled) return;
// Capture phase so file pastes always become attachments — ProseMirror // Capture phase so file pastes always become attachments — ProseMirror
// would otherwise inline pasted images as ephemeral blob: URLs. // would otherwise inline pasted images as ephemeral blob: URLs. Mixed
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
// must still paste as text, so only file-only pastes are intercepted.
const handlePaste = (e: ClipboardEvent) => { const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []); const data = e.clipboardData;
if (files.length > 0) { if (!data) return;
e.preventDefault(); const files = Array.from(data.files);
e.stopPropagation(); if (files.length === 0 || data.types.includes('text/plain')) return;
onFilesRef.current(files); e.preventDefault();
} e.stopPropagation();
onFilesRef.current(files);
}; };
window.addEventListener('paste', handlePaste, true); window.addEventListener('paste', handlePaste, true);
+7
View File
@@ -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' };
}
+28
View File
@@ -151,6 +151,34 @@
background: oklch(1 0 0 / 35%); 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 */ /* Frameless window drag regions */
.drag-region { .drag-region {
-webkit-app-region: drag; -webkit-app-region: drag;