make fixes
This commit is contained in:
@@ -64,6 +64,7 @@
|
|||||||
"@livekit/components-react": "^2.9.20",
|
"@livekit/components-react": "^2.9.20",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@milkdown/crepe": "^7.21.1",
|
"@milkdown/crepe": "^7.21.1",
|
||||||
|
"@milkdown/kit": "7.21.1",
|
||||||
"@sentry/electron": "^7.11.0",
|
"@sentry/electron": "^7.11.0",
|
||||||
"@sentry/react": "^10.54.0",
|
"@sentry/react": "^10.54.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Copy, ExternalLink, Globe } from 'lucide-react';
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { platform } from '@/lib/platform';
|
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() {
|
export function LinkPreviewCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
|
<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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
|
import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
|
||||||
|
import { domainFromUrl } from '@/lib/link-metadata';
|
||||||
import {
|
import {
|
||||||
AttachmentLightbox,
|
AttachmentLightbox,
|
||||||
getAttachmentHandler,
|
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;
|
const { metadata } = entry;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,80 @@
|
|||||||
.llink-crepe .milkdown .ProseMirror {
|
.llink-crepe .milkdown .ProseMirror {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
outline: none;
|
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
|
/* Editing context: fill the compose card and scroll internally so a long
|
||||||
@@ -70,9 +144,28 @@
|
|||||||
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. */
|
* .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 {
|
.llink-crepe--fill .milkdown .ProseMirror {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 1.25rem;
|
padding: 1.5rem 5.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Crepe } from '@milkdown/crepe';
|
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/common/style.css';
|
||||||
import '@milkdown/crepe/theme/frame-dark.css';
|
import '@milkdown/crepe/theme/frame-dark.css';
|
||||||
import './markdown-editor.css';
|
import './markdown-editor.css';
|
||||||
@@ -55,13 +57,20 @@ export function MarkdownEditor({
|
|||||||
[Crepe.Feature.BlockEdit]: !readOnly,
|
[Crepe.Feature.BlockEdit]: !readOnly,
|
||||||
[Crepe.Feature.Toolbar]: !readOnly,
|
[Crepe.Feature.Toolbar]: !readOnly,
|
||||||
[Crepe.Feature.Placeholder]: !readOnly,
|
[Crepe.Feature.Placeholder]: !readOnly,
|
||||||
[Crepe.Feature.ImageBlock]: false,
|
[Crepe.Feature.ImageBlock]: true,
|
||||||
[Crepe.Feature.Latex]: false,
|
[Crepe.Feature.Latex]: false,
|
||||||
[Crepe.Feature.TopBar]: false,
|
[Crepe.Feature.TopBar]: false,
|
||||||
[Crepe.Feature.AI]: false,
|
[Crepe.Feature.AI]: false,
|
||||||
},
|
},
|
||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
|
[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;
|
created = crepe;
|
||||||
if (autoFocus && !readOnly) {
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
@@ -50,7 +51,9 @@ export function TextEditor({
|
|||||||
attachmentProps,
|
attachmentProps,
|
||||||
}: TextEditorProps) {
|
}: TextEditorProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
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);
|
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,13 +64,15 @@ 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 immersive =
|
const shouldCard =
|
||||||
textContent.length < IMMERSIVE_CHAR_LIMIT &&
|
textContent.length >= IMMERSIVE_CHAR_LIMIT ||
|
||||||
!hasEnrichments &&
|
hasEnrichments ||
|
||||||
!forceCardMode;
|
hasMarkdownFormatting(textContent);
|
||||||
|
if (shouldCard && !carded) setCarded(true);
|
||||||
|
const immersive = !carded;
|
||||||
|
|
||||||
// Keep the immersive textarea focused with the caret at the end when we
|
// Keep the immersive textarea focused with the caret at the end on mount.
|
||||||
// (re)enter it. The card-mode editor manages its own focus.
|
// The card-mode editor manages its own focus.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!immersive) return;
|
if (!immersive) return;
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
@@ -95,7 +100,7 @@ export function TextEditor({
|
|||||||
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setForceCardMode(true);
|
setCarded(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onCancel, onSubmit, textContent],
|
[onCancel, onSubmit, textContent],
|
||||||
@@ -127,7 +132,7 @@ export function TextEditor({
|
|||||||
{immersive && (
|
{immersive && (
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={`${metaKey}+M`}
|
keys={`${metaKey}+M`}
|
||||||
onClick={() => setForceCardMode(true)}
|
onClick={() => setCarded(true)}
|
||||||
title={`Switch to markdown editor (or press ${metaKey}+M)`}
|
title={`Switch to markdown editor (or press ${metaKey}+M)`}
|
||||||
>
|
>
|
||||||
markdown
|
markdown
|
||||||
@@ -194,7 +199,10 @@ export function TextEditor({
|
|||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
onKeyDownCapture={handleKeyDown}
|
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,
|
{/* 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,8 +8,10 @@ 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 { hasMarkdownFormatting } from '@/lib/markdown';
|
||||||
import {
|
import {
|
||||||
LinkPreviewCard,
|
LinkPreviewCard,
|
||||||
|
LinkPreviewCardFallback,
|
||||||
LinkPreviewCardSkeleton,
|
LinkPreviewCardSkeleton,
|
||||||
} from '@/components/link-preview-card';
|
} from '@/components/link-preview-card';
|
||||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||||
@@ -57,12 +59,6 @@ function getImmersiveTextStyle(length: number) {
|
|||||||
return { size: 'text-2xl', weight: 'font-normal' };
|
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[] }) {
|
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -72,7 +68,9 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
|||||||
<LinkPreviewCardSkeleton />
|
<LinkPreviewCardSkeleton />
|
||||||
) : entry.metadata ? (
|
) : entry.metadata ? (
|
||||||
<LinkPreviewCard metadata={entry.metadata} />
|
<LinkPreviewCard metadata={entry.metadata} />
|
||||||
) : null}
|
) : (
|
||||||
|
<LinkPreviewCardFallback url={entry.url} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,16 +48,19 @@ export function useFileInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
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 handlePaste = (e: ClipboardEvent) => {
|
||||||
const files = Array.from(e.clipboardData?.files ?? []);
|
const files = Array.from(e.clipboardData?.files ?? []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onFilesRef.current(files);
|
onFilesRef.current(files);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('paste', handlePaste);
|
window.addEventListener('paste', handlePaste, true);
|
||||||
return () => window.removeEventListener('paste', handlePaste);
|
return () => window.removeEventListener('paste', handlePaste, true);
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
|
|||||||
@@ -14,3 +14,11 @@ export function extractUrls(text: string): string[] {
|
|||||||
// should only yield a single preview card.
|
// should only yield a single preview card.
|
||||||
return Array.from(new Set(Array.from(text.matchAll(URL_REGEX), (m) => m[0])));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function hasMarkdownFormatting(content: string): boolean {
|
||||||
|
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*\S[^*]*\*|\b_\S[^_]*_\b|^>/m.test(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user