cleanup
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user