mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(composer): compose spoiler, annotation, and tooltip controls (#1491)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
36ac5243ff
commit
1aa87bb26b
@@ -40,8 +40,10 @@ export default defineConfig({
|
||||
"**/agent-readiness-screenshots.spec.ts",
|
||||
"**/file-attachment.spec.ts",
|
||||
"**/image-attachment-gallery.spec.ts",
|
||||
"**/composer-image-draw.spec.ts",
|
||||
"**/video-attachment.spec.ts",
|
||||
"**/spoiler.spec.ts",
|
||||
"**/composer-tooltip-dismiss.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/relay-reconnect.spec.ts",
|
||||
"**/relay-reconnect-affordance.spec.ts",
|
||||
|
||||
@@ -129,6 +129,25 @@ pub async fn download_file(
|
||||
save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await
|
||||
}
|
||||
|
||||
/// Fetch relay media bytes for the composer image editor.
|
||||
///
|
||||
/// The editor composites the image onto a canvas and needs pixel access.
|
||||
/// Handing the webview raw bytes over IPC (which it wraps in a same-origin
|
||||
/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and
|
||||
/// therefore without any media-proxy header or origin-gate changes.
|
||||
///
|
||||
/// Same SSRF validation, size cap, and content policy as the download
|
||||
/// commands above.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_media_bytes(url: String, state: State<'_, AppState>) -> Result<Vec<u8>, String> {
|
||||
let relay_base = relay_api_base_url_with_override(&state);
|
||||
validate_download_url(&url, &relay_base)?;
|
||||
|
||||
let bytes = fetch_blob_bytes(&url, &state).await?;
|
||||
detect_and_validate_mime(&bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Fetch blob bytes from a (pre-validated) relay media URL through the app's
|
||||
/// HTTP client, enforcing the download size cap. The caller is responsible for
|
||||
/// validating the URL origin and for any content-type checks on the result.
|
||||
|
||||
@@ -507,6 +507,7 @@ pub fn run() {
|
||||
upload_media_bytes,
|
||||
download_image,
|
||||
download_file,
|
||||
fetch_media_bytes,
|
||||
list_relay_members,
|
||||
get_my_relay_membership,
|
||||
add_relay_member,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
|
||||
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
|
||||
|
||||
@@ -5,9 +7,12 @@ type ComposerMedia = Pick<
|
||||
ReturnType<typeof useMediaUpload>,
|
||||
| "isUploading"
|
||||
| "cancelUpload"
|
||||
| "originalUrlByUrl"
|
||||
| "pendingImeta"
|
||||
| "removeAttachment"
|
||||
| "revertAttachment"
|
||||
| "setUploadState"
|
||||
| "uploadEditedAttachment"
|
||||
| "uploadState"
|
||||
| "uploadingCount"
|
||||
| "uploadingPreviews"
|
||||
@@ -20,6 +25,13 @@ type ForumComposerMediaStatusProps = {
|
||||
export function ForumComposerMediaStatus({
|
||||
media,
|
||||
}: ForumComposerMediaStatusProps) {
|
||||
const handleEditSave = React.useCallback(
|
||||
async (url: string, bytes: Uint8Array) => {
|
||||
await media.uploadEditedAttachment(url, bytes);
|
||||
},
|
||||
[media.uploadEditedAttachment],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{media.uploadState.status === "error" ? (
|
||||
@@ -41,7 +53,10 @@ export function ForumComposerMediaStatus({
|
||||
attachments={media.pendingImeta}
|
||||
isUploading={media.isUploading}
|
||||
onCancelUpload={media.cancelUpload}
|
||||
onEditSave={handleEditSave}
|
||||
onRemove={media.removeAttachment}
|
||||
onRevert={media.revertAttachment}
|
||||
originalUrlByUrl={media.originalUrlByUrl}
|
||||
uploadingCount={media.uploadingCount}
|
||||
uploadingPreviews={media.uploadingPreviews}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { MediaUploadController } from "./useMediaUpload";
|
||||
|
||||
type UseAttachmentEditingArgs = {
|
||||
revertAttachment: MediaUploadController["revertAttachment"];
|
||||
/** Spoiler-set updater; membership follows the attachment across URL swaps. */
|
||||
setSpoileredAttachmentUrls: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
uploadEditedAttachment: MediaUploadController["uploadEditedAttachment"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Composer-side glue for the attachment drawing editor: uploads annotated
|
||||
* bytes as a replacement / reverts to the pre-edit original, migrating
|
||||
* spoiler membership from the replaced URL to its replacement so an edited
|
||||
* spoilered image stays spoilered.
|
||||
*/
|
||||
export function useAttachmentEditing({
|
||||
revertAttachment,
|
||||
setSpoileredAttachmentUrls,
|
||||
uploadEditedAttachment,
|
||||
}: UseAttachmentEditingArgs) {
|
||||
const migrateSpoileredUrl = React.useCallback(
|
||||
(fromUrl: string, toUrl: string) => {
|
||||
setSpoileredAttachmentUrls((current) => {
|
||||
if (!current.has(fromUrl)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(fromUrl);
|
||||
next.add(toUrl);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setSpoileredAttachmentUrls],
|
||||
);
|
||||
|
||||
const handleAttachmentEditSave = React.useCallback(
|
||||
async (url: string, bytes: Uint8Array) => {
|
||||
const descriptor = await uploadEditedAttachment(url, bytes);
|
||||
if (descriptor) migrateSpoileredUrl(url, descriptor.url);
|
||||
},
|
||||
[migrateSpoileredUrl, uploadEditedAttachment],
|
||||
);
|
||||
|
||||
const handleAttachmentRevert = React.useCallback(
|
||||
(url: string) => {
|
||||
const original = revertAttachment(url);
|
||||
if (original) migrateSpoileredUrl(url, original.url);
|
||||
},
|
||||
[migrateSpoileredUrl, revertAttachment],
|
||||
);
|
||||
|
||||
return { handleAttachmentEditSave, handleAttachmentRevert };
|
||||
}
|
||||
@@ -205,6 +205,44 @@ export function useMediaUpload() {
|
||||
const pendingImetaRef = React.useRef(pendingImeta);
|
||||
pendingImetaRef.current = pendingImeta;
|
||||
|
||||
/**
|
||||
* Pre-edit originals of annotated attachments, keyed by the annotated
|
||||
* attachment's URL. Powers "revert to original" in the composer lightbox.
|
||||
* In-memory only — cleared implicitly when the attachment leaves the
|
||||
* composer (send, remove, draft switch).
|
||||
*/
|
||||
const [originalsByUrl, setOriginalsByUrl] = React.useState<
|
||||
Map<string, BlobDescriptor>
|
||||
>(() => new Map());
|
||||
const originalsByUrlRef = React.useRef(originalsByUrl);
|
||||
originalsByUrlRef.current = originalsByUrl;
|
||||
|
||||
/** Annotated URL → original URL (derived; handy for stable list keys). */
|
||||
const originalUrlByUrl = React.useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const [url, original] of originalsByUrl) map.set(url, original.url);
|
||||
return map;
|
||||
}, [originalsByUrl]);
|
||||
|
||||
// Prune originals whose annotated attachment is no longer pending —
|
||||
// covers remove, cancel, send-clear, and draft restore in one place.
|
||||
React.useEffect(() => {
|
||||
setOriginalsByUrl((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const liveUrls = new Set(pendingImeta.map((d) => d.url));
|
||||
let changed = false;
|
||||
const next = new Map<string, BlobDescriptor>();
|
||||
for (const [url, original] of prev) {
|
||||
if (liveUrls.has(url)) {
|
||||
next.set(url, original);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [pendingImeta]);
|
||||
|
||||
/** Monotonic slot counter — ensures each batch gets unique indices even
|
||||
* before React flushes the state update. */
|
||||
const nextSlotRef = React.useRef(0);
|
||||
@@ -511,6 +549,80 @@ export function useMediaUpload() {
|
||||
[isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview],
|
||||
);
|
||||
|
||||
/**
|
||||
* Upload an annotated replacement for an existing image attachment and
|
||||
* swap it into the same slot (attachment order is preserved). The pre-edit
|
||||
* descriptor is remembered in `originalsByUrl` so the edit can be reverted;
|
||||
* chained edits keep the earliest original as the single revert point.
|
||||
*
|
||||
* Returns the new descriptor, or null if `oldUrl` is no longer pending.
|
||||
* Rejects on upload failure (after surfacing the standard error banner) so
|
||||
* callers can keep their editing UI open.
|
||||
*/
|
||||
const uploadEditedAttachment = React.useCallback(
|
||||
async (
|
||||
oldUrl: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<BlobDescriptor | null> => {
|
||||
const oldDescriptor = pendingImetaRef.current.find(
|
||||
(d) => d.url === oldUrl,
|
||||
);
|
||||
if (!oldDescriptor) return null;
|
||||
|
||||
// The annotated output is always PNG — swap the extension accordingly.
|
||||
const stem = (oldDescriptor.filename ?? "image").replace(/\.[^.]+$/, "");
|
||||
const filename = `${stem}.png`;
|
||||
|
||||
const previewId = reserveUploadingPreview();
|
||||
setUploadingCount((c) => c + 1);
|
||||
try {
|
||||
const descriptor = await uploadMediaBytes(
|
||||
[...bytes],
|
||||
filename,
|
||||
uploadProgressId(previewId),
|
||||
);
|
||||
if (isUploadCanceled(previewId)) return null;
|
||||
finishUpload(previewId);
|
||||
setImetaSlots((prev) =>
|
||||
prev.map((d) => (d?.url === oldUrl ? descriptor : d)),
|
||||
);
|
||||
setOriginalsByUrl((prev) => {
|
||||
const next = new Map(prev);
|
||||
// Re-editing an annotated image keeps the earliest original.
|
||||
const original = prev.get(oldUrl) ?? oldDescriptor;
|
||||
next.delete(oldUrl);
|
||||
next.set(descriptor.url, original);
|
||||
return next;
|
||||
});
|
||||
return descriptor;
|
||||
} catch (err) {
|
||||
onUploadError(err, previewId);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview],
|
||||
);
|
||||
|
||||
/**
|
||||
* Swap an annotated attachment back to its pre-edit original (same slot)
|
||||
* and forget the stored original. Returns the restored descriptor, or null
|
||||
* if the URL has no recorded original.
|
||||
*/
|
||||
const revertAttachment = React.useCallback(
|
||||
(url: string): BlobDescriptor | null => {
|
||||
const original = originalsByUrlRef.current.get(url);
|
||||
if (!original) return null;
|
||||
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? original : d)));
|
||||
setOriginalsByUrl((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(url);
|
||||
return next;
|
||||
});
|
||||
return original;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeAttachment = React.useCallback((url: string) => {
|
||||
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? null : d)));
|
||||
}, []);
|
||||
@@ -541,11 +653,14 @@ export function useMediaUpload() {
|
||||
handlePaste,
|
||||
isDragOver,
|
||||
isUploading,
|
||||
originalUrlByUrl,
|
||||
pendingImeta,
|
||||
pendingImetaRef,
|
||||
removeAttachment,
|
||||
revertAttachment,
|
||||
setPendingImeta,
|
||||
setUploadState,
|
||||
uploadEditedAttachment,
|
||||
uploadFile,
|
||||
uploadingCount,
|
||||
uploadingPreviews,
|
||||
@@ -561,9 +676,12 @@ export function useMediaUpload() {
|
||||
handlePaste,
|
||||
isDragOver,
|
||||
isUploading,
|
||||
originalUrlByUrl,
|
||||
pendingImeta,
|
||||
removeAttachment,
|
||||
revertAttachment,
|
||||
setPendingImeta,
|
||||
uploadEditedAttachment,
|
||||
uploadFile,
|
||||
uploadingCount,
|
||||
uploadingPreviews,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
|
||||
import { FileText, HatGlasses, Play, X } from "lucide-react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { FileText, HatGlasses, Pencil, Play, X } from "lucide-react";
|
||||
|
||||
import type { BlobDescriptor } from "@/shared/api/tauri";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
@@ -9,9 +10,12 @@ import {
|
||||
type UploadingAttachmentPreview,
|
||||
} from "@/features/messages/lib/useMediaUpload";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
||||
import { Progress } from "@/shared/ui/progress";
|
||||
import { Toggle } from "@/shared/ui/toggle";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ComposerImageEditor } from "./ComposerImageEditor";
|
||||
|
||||
/** Dashed-border overlay shown when a file is dragged over the composer form. */
|
||||
export function DropZoneOverlay({ className }: { className?: string }) {
|
||||
@@ -35,10 +39,20 @@ type ComposerAttachmentsProps = {
|
||||
onCancelUpload?: (previewId: number) => void;
|
||||
uploadingCount?: number;
|
||||
uploadingPreviews?: UploadingAttachmentPreview[];
|
||||
/** Upload annotated bytes as a replacement for the attachment at `url`. */
|
||||
onEditSave?: (url: string, bytes: Uint8Array) => Promise<void>;
|
||||
onRemove: (url: string) => void;
|
||||
/** Restore the pre-edit original for an annotated attachment. */
|
||||
onRevert?: (url: string) => void;
|
||||
/** Annotated attachment URL → original (pre-edit) URL. */
|
||||
originalUrlByUrl?: ReadonlyMap<string, string>;
|
||||
onToggleSpoiler?: (url: string) => void;
|
||||
spoileredUrls?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const LIGHTBOX_BUTTON_CLASS =
|
||||
"rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30";
|
||||
|
||||
const COMPOSER_MEDIA_HEIGHT_PX = 55;
|
||||
const COMPOSER_MEDIA_WIDTH_PX = 55;
|
||||
|
||||
@@ -49,6 +63,297 @@ function composerMediaStyle(): React.CSSProperties {
|
||||
};
|
||||
}
|
||||
|
||||
type MediaAttachmentItemProps = {
|
||||
attachment: BlobDescriptor;
|
||||
isSpoilered: boolean;
|
||||
onEditSave?: (url: string, bytes: Uint8Array) => Promise<void>;
|
||||
onRemove: (url: string) => void;
|
||||
onRevert?: (url: string) => void;
|
||||
onToggleSpoiler?: (url: string) => void;
|
||||
/** Set when this attachment is an annotated replacement of an original. */
|
||||
originalUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single image/video attachment thumbnail with its lightbox dialog.
|
||||
* Images support an in-lightbox canvas edit mode (freehand drawing) and,
|
||||
* once annotated, an in-place revert to the original. Save closes the
|
||||
* dialog; revert keeps it open (the parent keys this item by its original
|
||||
* URL so the swap doesn't remount it).
|
||||
*
|
||||
* Forwards its ref to the root motion.div — required by the parent
|
||||
* `AnimatePresence mode="popLayout"`, which measures exiting children.
|
||||
*/
|
||||
const MediaAttachmentItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
MediaAttachmentItemProps
|
||||
>(function MediaAttachmentItem(
|
||||
{
|
||||
attachment,
|
||||
isSpoilered,
|
||||
onEditSave,
|
||||
onRemove,
|
||||
onRevert,
|
||||
onToggleSpoiler,
|
||||
originalUrl,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [mode, setMode] = React.useState<"view" | "edit">("view");
|
||||
|
||||
const hash = shortHash(attachment.sha256);
|
||||
const isVideo = attachment.type.startsWith("video/");
|
||||
const thumbUrl = attachment.thumb
|
||||
? rewriteRelayUrl(attachment.thumb)
|
||||
: rewriteRelayUrl(attachment.url);
|
||||
const videoPosterUrl = attachment.image
|
||||
? rewriteRelayUrl(attachment.image)
|
||||
: attachment.thumb
|
||||
? rewriteRelayUrl(attachment.thumb)
|
||||
: undefined;
|
||||
|
||||
const canEdit = !isVideo && onEditSave !== undefined;
|
||||
const canRevert =
|
||||
!isVideo && onRevert !== undefined && originalUrl !== undefined;
|
||||
|
||||
const handleOpenChange = React.useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setMode("view");
|
||||
}, []);
|
||||
|
||||
// Read `mode` via a ref: Radix's dismissable layer (>=1.1.14) registers a
|
||||
// stable Escape listener, so the handler would otherwise see a stale mode.
|
||||
const modeRef = React.useRef(mode);
|
||||
modeRef.current = mode;
|
||||
const handleEscapeKeyDown = React.useCallback((event: KeyboardEvent) => {
|
||||
if (modeRef.current === "edit") {
|
||||
// Escape leaves canvas mode but keeps the lightbox open.
|
||||
event.preventDefault();
|
||||
setMode("view");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditorSave = React.useCallback(
|
||||
async (bytes: Uint8Array) => {
|
||||
if (!onEditSave) return;
|
||||
await onEditSave(attachment.url, bytes);
|
||||
// Close on save so rapid save/redraw cycles don't orphan a blob per iteration.
|
||||
setMode("view");
|
||||
setOpen(false);
|
||||
},
|
||||
[attachment.url, onEditSave],
|
||||
);
|
||||
|
||||
const handleEditorCancel = React.useCallback(() => setMode("view"), []);
|
||||
|
||||
const handleRevert = React.useCallback(() => {
|
||||
onRevert?.(attachment.url);
|
||||
}, [attachment.url, onRevert]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layout
|
||||
initial={false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="group relative"
|
||||
>
|
||||
<div
|
||||
className="relative h-[55px] max-w-[55px]"
|
||||
style={composerMediaStyle()}
|
||||
>
|
||||
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogPrimitive.Trigger asChild>
|
||||
<div className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70">
|
||||
{isVideo ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
|
||||
{videoPosterUrl ? (
|
||||
<img
|
||||
src={videoPosterUrl}
|
||||
alt={`Video attachment ${hash}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-muted/80" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/15" />
|
||||
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
|
||||
<Play className="h-4 w-4 fill-white text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={`Attachment ${hash}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{isSpoilered ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
|
||||
data-composer-media-spoiler=""
|
||||
>
|
||||
<HatGlasses className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
MODAL_BACKDROP_BLUR_CLASS,
|
||||
)}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-8"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">
|
||||
Attachment {hash} preview
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Full-size attachment preview. Press Escape or click outside to
|
||||
close.
|
||||
</DialogPrimitive.Description>
|
||||
{mode === "view" ? (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute inset-0 cursor-default"
|
||||
aria-label="Close lightbox"
|
||||
/>
|
||||
) : null}
|
||||
{mode === "edit" && !isVideo ? (
|
||||
<ComposerImageEditor
|
||||
alt={`Attachment ${hash}`}
|
||||
src={rewriteRelayUrl(attachment.url)}
|
||||
sourceUrl={attachment.url}
|
||||
sourceType={attachment.type}
|
||||
onCancel={handleEditorCancel}
|
||||
onSave={handleEditorSave}
|
||||
/>
|
||||
) : isVideo ? (
|
||||
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
|
||||
<video
|
||||
src={rewriteRelayUrl(attachment.url)}
|
||||
controls
|
||||
className={cn(
|
||||
"relative max-h-[90vh] max-w-[90vw] rounded-lg",
|
||||
isSpoilered && "blur-2xl brightness-75",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt={`Attachment ${hash}`}
|
||||
className={cn(
|
||||
"relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain",
|
||||
isSpoilered && "blur-2xl brightness-75",
|
||||
)}
|
||||
src={rewriteRelayUrl(attachment.url)}
|
||||
/>
|
||||
)}
|
||||
{mode === "view" && isSpoilered ? (
|
||||
/*
|
||||
* Expanded-media counterpart of the thumbnail spoiler treatment:
|
||||
* the media itself is blurred above, and this layer centers the
|
||||
* spoiler glyph. pointer-events-none keeps controls and
|
||||
* backdrop-close clickable.
|
||||
*/
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center text-foreground/70"
|
||||
data-lightbox-media-spoiler=""
|
||||
>
|
||||
<HatGlasses className="h-10 w-10" />
|
||||
</div>
|
||||
) : null}
|
||||
{mode === "view" ? (
|
||||
<div className="absolute right-4 top-4 flex items-center gap-2">
|
||||
{canRevert ? (
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-testid="composer-attachment-revert"
|
||||
onClick={handleRevert}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revert to original</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onToggleSpoiler ? (
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
aria-label={
|
||||
isSpoilered ? "Remove spoiler" : "Mark as spoiler"
|
||||
}
|
||||
className={cn(
|
||||
LIGHTBOX_BUTTON_CLASS,
|
||||
"h-auto min-w-0",
|
||||
)}
|
||||
data-testid="composer-attachment-spoiler"
|
||||
onPressedChange={() =>
|
||||
onToggleSpoiler(attachment.url)
|
||||
}
|
||||
pressed={isSpoilered}
|
||||
>
|
||||
<HatGlasses className="h-4 w-4" />
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isSpoilered ? "Remove spoiler" : "Mark as spoiler"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={LIGHTBOX_BUTTON_CLASS}
|
||||
data-testid="composer-attachment-edit"
|
||||
onClick={() => setMode("edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Draw on image</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Draw on image</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<DialogPrimitive.Close className={LIGHTBOX_BUTTON_CLASS}>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.url)}
|
||||
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove attachment</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Thumbnail previews for uploaded attachments in the composer.
|
||||
* Each attachment shows as a small image with a remove button and
|
||||
@@ -60,7 +365,11 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
|
||||
uploadingCount = 0,
|
||||
uploadingPreviews = [],
|
||||
onCancelUpload,
|
||||
onEditSave,
|
||||
onRemove,
|
||||
onRevert,
|
||||
originalUrlByUrl,
|
||||
onToggleSpoiler,
|
||||
spoileredUrls,
|
||||
}: ComposerAttachmentsProps) {
|
||||
if (attachments.length === 0 && !isUploading) return null;
|
||||
@@ -85,16 +394,6 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
|
||||
const isVideo = attachment.type.startsWith("video/");
|
||||
const isImage = attachment.type.startsWith("image/");
|
||||
const isFile = !isVideo && !isImage;
|
||||
const isSpoilered = spoileredUrls?.has(attachment.url) ?? false;
|
||||
const thumbUrl = attachment.thumb
|
||||
? rewriteRelayUrl(attachment.thumb)
|
||||
: rewriteRelayUrl(attachment.url);
|
||||
const videoPosterUrl = attachment.image
|
||||
? rewriteRelayUrl(attachment.image)
|
||||
: attachment.thumb
|
||||
? rewriteRelayUrl(attachment.thumb)
|
||||
: undefined;
|
||||
const mediaStyle = composerMediaStyle();
|
||||
|
||||
// Generic file: compact chip with a file icon + filename, plus the
|
||||
// same remove button. No lightbox (nothing to preview).
|
||||
@@ -113,13 +412,13 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="group relative"
|
||||
>
|
||||
<div className="flex h-5 max-w-[10rem] items-center gap-1 rounded border border-border/70 bg-muted px-1.5">
|
||||
<div className="flex h-5 max-w-40 items-center gap-1 rounded border border-border/70 bg-muted px-1.5">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-2xs text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,39 +434,21 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
|
||||
);
|
||||
}
|
||||
|
||||
const originalUrl = originalUrlByUrl?.get(attachment.url);
|
||||
return (
|
||||
<motion.div
|
||||
key={attachment.url}
|
||||
layout
|
||||
initial={false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="group relative"
|
||||
>
|
||||
<AttachmentMediaLightbox
|
||||
alt={`Attachment ${hash} preview`}
|
||||
hash={hash}
|
||||
isSpoilered={isSpoilered}
|
||||
isVideo={isVideo}
|
||||
mediaStyle={mediaStyle}
|
||||
thumbUrl={thumbUrl}
|
||||
url={attachment.url}
|
||||
videoPosterUrl={videoPosterUrl ?? null}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.url)}
|
||||
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove attachment</TooltipContent>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
<MediaAttachmentItem
|
||||
attachment={attachment}
|
||||
isSpoilered={spoileredUrls?.has(attachment.url) ?? false}
|
||||
// Annotated attachments keep their original URL as the key so
|
||||
// the in-place edit/revert URL swap doesn't remount the item
|
||||
// (which would close its open lightbox dialog).
|
||||
key={originalUrl ?? attachment.url}
|
||||
onEditSave={onEditSave}
|
||||
onRemove={onRemove}
|
||||
onRevert={onRevert}
|
||||
onToggleSpoiler={onToggleSpoiler}
|
||||
originalUrl={originalUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isUploading &&
|
||||
@@ -210,7 +491,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
|
||||
</div>
|
||||
</div>
|
||||
{onCancelUpload && preview.id >= 0 ? (
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -232,83 +513,3 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
|
||||
</LayoutGroup>
|
||||
);
|
||||
});
|
||||
|
||||
function AttachmentMediaLightbox({
|
||||
alt,
|
||||
hash,
|
||||
isSpoilered,
|
||||
isVideo,
|
||||
mediaStyle,
|
||||
thumbUrl,
|
||||
url,
|
||||
videoPosterUrl,
|
||||
}: {
|
||||
alt: string;
|
||||
hash: string;
|
||||
isSpoilered: boolean;
|
||||
isVideo: boolean;
|
||||
mediaStyle: React.CSSProperties;
|
||||
thumbUrl: string;
|
||||
url: string;
|
||||
videoPosterUrl: string | null;
|
||||
}) {
|
||||
const [lightboxOpen, setLightboxOpen] = React.useState(false);
|
||||
const previewSrc = rewriteRelayUrl(url);
|
||||
|
||||
return (
|
||||
<div className="relative h-[55px] max-w-[55px]" style={mediaStyle}>
|
||||
<button
|
||||
className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
{isVideo ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
|
||||
{videoPosterUrl ? (
|
||||
<img
|
||||
alt={`Video attachment ${hash}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={videoPosterUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-muted/80" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/15" />
|
||||
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
|
||||
<Play className="h-4 w-4 fill-white text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
alt={`Attachment ${hash}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={thumbUrl}
|
||||
/>
|
||||
)}
|
||||
{isSpoilered ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
|
||||
data-composer-media-spoiler=""
|
||||
>
|
||||
<HatGlasses className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<SimpleImageLightbox
|
||||
alt={alt}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
src={previewSrc}
|
||||
>
|
||||
{isVideo ? (
|
||||
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
|
||||
<video
|
||||
className="relative max-h-[90vh] max-w-[90vw] rounded-lg"
|
||||
controls
|
||||
src={previewSrc}
|
||||
/>
|
||||
) : undefined}
|
||||
</SimpleImageLightbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
|
||||
}: ComposerEmojiPickerProps) {
|
||||
return (
|
||||
<Popover onOpenChange={onOpenChange} open={open}>
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import * as React from "react";
|
||||
import { Loader2, Redo2, Undo2 } from "lucide-react";
|
||||
|
||||
import { fetchMediaBytes } from "@/shared/api/tauriMedia";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
type EditorPoint = { x: number; y: number };
|
||||
|
||||
/** A committed pen stroke, in natural-image pixel coordinates. */
|
||||
type EditorStroke = {
|
||||
color: string;
|
||||
points: EditorPoint[];
|
||||
/** Line width in natural-image pixels (already scaled from CSS px). */
|
||||
width: number;
|
||||
};
|
||||
|
||||
const PEN_COLORS = [
|
||||
{ label: "Red", value: "#ef4444" },
|
||||
{ label: "Yellow", value: "#f59e0b" },
|
||||
{ label: "Green", value: "#22c55e" },
|
||||
{ label: "Blue", value: "#3b82f6" },
|
||||
{ label: "White", value: "#ffffff" },
|
||||
{ label: "Black", value: "#111111" },
|
||||
] as const;
|
||||
|
||||
/** Pen stroke width range, in CSS pixels: five whole-pixel slider stops. */
|
||||
const PEN_WIDTH_MIN_CSS = 4;
|
||||
const PEN_WIDTH_MAX_CSS = 12;
|
||||
const PEN_WIDTH_STEP_CSS = 2;
|
||||
const PEN_WIDTH_DEFAULT_CSS = 6;
|
||||
|
||||
function drawStroke(ctx: CanvasRenderingContext2D, stroke: EditorStroke) {
|
||||
const [first, ...rest] = stroke.points;
|
||||
if (!first) return;
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.fillStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
if (rest.length === 0) {
|
||||
// Single click — leave a dot instead of an invisible zero-length line.
|
||||
ctx.beginPath();
|
||||
ctx.arc(first.x, first.y, stroke.width / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(first.x, first.y);
|
||||
for (const point of rest) ctx.lineTo(point.x, point.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSegment(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
from: EditorPoint,
|
||||
to: EditorPoint,
|
||||
stroke: EditorStroke,
|
||||
) {
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite the source image and strokes into a PNG at natural resolution.
|
||||
*
|
||||
* The source bytes are fetched over Tauri IPC and wrapped in a `blob:` URL.
|
||||
* Blob URLs are same-origin, so the canvas stays un-tainted and `toBlob`
|
||||
* works without any CORS involvement (the media proxy sends no CORS
|
||||
* headers, and cross-origin `crossOrigin="anonymous"` loads would need
|
||||
* them).
|
||||
*/
|
||||
async function renderAnnotatedPng(
|
||||
sourceUrl: string,
|
||||
sourceType: string,
|
||||
strokes: EditorStroke[],
|
||||
): Promise<Uint8Array> {
|
||||
const bytes = await fetchMediaBytes(sourceUrl);
|
||||
// The explicit type matters: blob: image decoding is not content-sniffed
|
||||
// for all formats, so an untyped blob may fail to decode.
|
||||
const sourceBlob = new Blob([bytes], { type: sourceType });
|
||||
const blobUrl = URL.createObjectURL(sourceBlob);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.src = blobUrl;
|
||||
await image.decode();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||
ctx.drawImage(image, 0, 0);
|
||||
for (const stroke of strokes) drawStroke(ctx, stroke);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, "image/png");
|
||||
});
|
||||
if (!blob) throw new Error("PNG encoding failed");
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
|
||||
type ComposerImageEditorProps = {
|
||||
alt: string;
|
||||
/** Resolved (proxy-rewritten) image URL, for display. */
|
||||
src: string;
|
||||
/** Original relay media URL — export fetches its bytes over IPC. */
|
||||
sourceUrl: string;
|
||||
/** MIME type of the source image (from the blob descriptor). */
|
||||
sourceType: string;
|
||||
onCancel: () => void;
|
||||
/** Upload the annotated PNG; rejection keeps the editor open. */
|
||||
onSave: (bytes: Uint8Array) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Freehand drawing mode for a composer image attachment: the image at
|
||||
* lightbox size with a canvas overlay, plus a pen toolbar (color, stroke
|
||||
* width, undo, clear, cancel, save). Strokes are stored in natural-image
|
||||
* coordinates so the exported PNG matches what's on screen.
|
||||
*/
|
||||
export function ComposerImageEditor({
|
||||
alt,
|
||||
src,
|
||||
sourceUrl,
|
||||
sourceType,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: ComposerImageEditorProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const activeStrokeRef = React.useRef<EditorStroke | null>(null);
|
||||
// Committed strokes plus the undone strokes available for redo. Kept in
|
||||
// one state object so undo/redo move strokes between stacks atomically.
|
||||
const [history, setHistory] = React.useState<{
|
||||
strokes: EditorStroke[];
|
||||
undone: EditorStroke[];
|
||||
}>({ strokes: [], undone: [] });
|
||||
const strokes = history.strokes;
|
||||
const [activeColor, setActiveColor] = React.useState<string>(
|
||||
PEN_COLORS[0].value,
|
||||
);
|
||||
const [activeWidthCss, setActiveWidthCss] = React.useState<number>(
|
||||
PEN_WIDTH_DEFAULT_CSS,
|
||||
);
|
||||
const [naturalSize, setNaturalSize] = React.useState<{
|
||||
height: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [saveError, setSaveError] = React.useState<string | null>(null);
|
||||
|
||||
const handleImageLoad = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const { naturalHeight, naturalWidth } = event.currentTarget;
|
||||
if (naturalWidth > 0 && naturalHeight > 0) {
|
||||
setNaturalSize({ height: naturalHeight, width: naturalWidth });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Redraw committed strokes whenever they change (undo/clear/commit).
|
||||
// Live segments are drawn imperatively during pointermove for latency.
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const stroke of strokes) drawStroke(ctx, stroke);
|
||||
}, [strokes]);
|
||||
|
||||
const undo = React.useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
const last = prev.strokes[prev.strokes.length - 1];
|
||||
if (!last) return prev;
|
||||
return {
|
||||
strokes: prev.strokes.slice(0, -1),
|
||||
undone: [...prev.undone, last],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const redo = React.useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
const last = prev.undone[prev.undone.length - 1];
|
||||
if (!last) return prev;
|
||||
return {
|
||||
strokes: [...prev.strokes, last],
|
||||
undone: prev.undone.slice(0, -1),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const isModZ =
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
event.key.toLowerCase() === "z";
|
||||
if (!isModZ) return;
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [redo, undo]);
|
||||
|
||||
const toNaturalPoint = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLCanvasElement>): EditorPoint | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
|
||||
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (event.button !== 0 || saving) return;
|
||||
const canvas = canvasRef.current;
|
||||
const point = toNaturalPoint(event);
|
||||
if (!canvas || !point) return;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const stroke: EditorStroke = {
|
||||
color: activeColor,
|
||||
points: [point],
|
||||
// Scale the chosen CSS width into natural pixels so the on-screen
|
||||
// preview matches the exported PNG exactly.
|
||||
width: Math.max(1, activeWidthCss * (canvas.width / rect.width)),
|
||||
};
|
||||
activeStrokeRef.current = stroke;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) drawStroke(ctx, stroke);
|
||||
},
|
||||
[activeColor, activeWidthCss, saving, toNaturalPoint],
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const stroke = activeStrokeRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!stroke || !canvas) return;
|
||||
const point = toNaturalPoint(event);
|
||||
if (!point) return;
|
||||
const previous = stroke.points[stroke.points.length - 1];
|
||||
stroke.points.push(point);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx && previous) drawSegment(ctx, previous, point, stroke);
|
||||
},
|
||||
[toNaturalPoint],
|
||||
);
|
||||
|
||||
const commitActiveStroke = React.useCallback(() => {
|
||||
const stroke = activeStrokeRef.current;
|
||||
if (!stroke) return;
|
||||
activeStrokeRef.current = null;
|
||||
// A new stroke invalidates the redo stack, matching editor conventions.
|
||||
setHistory((prev) => ({ strokes: [...prev.strokes, stroke], undone: [] }));
|
||||
}, []);
|
||||
|
||||
const handleSave = React.useCallback(async () => {
|
||||
if (saving || strokes.length === 0) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const bytes = await renderAnnotatedPng(sourceUrl, sourceType, strokes);
|
||||
await onSave(bytes);
|
||||
// On success the parent closes the lightbox and unmounts this component.
|
||||
} catch {
|
||||
setSaveError("Could not save the drawing. Please try again.");
|
||||
setSaving(false);
|
||||
}
|
||||
}, [onSave, saving, sourceType, sourceUrl, strokes]);
|
||||
|
||||
const hasStrokes = strokes.length > 0;
|
||||
|
||||
// The native cursor is hidden over the canvas; this DOM dot follows the
|
||||
// pointer instead. Unlike a `cursor: url(...)` image, an element sized in
|
||||
// CSS pixels is guaranteed to match the on-screen stroke width exactly.
|
||||
// Positioned imperatively during pointermove to avoid re-rendering.
|
||||
const brushPreviewRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const moveBrushPreview = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const preview = brushPreviewRef.current;
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!preview || !rect) return;
|
||||
preview.style.opacity = "1";
|
||||
preview.style.transform = `translate(${event.clientX - rect.left}px, ${event.clientY - rect.top}px) translate(-50%, -50%)`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hideBrushPreview = React.useCallback(() => {
|
||||
const preview = brushPreviewRef.current;
|
||||
if (preview) preview.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex max-h-full max-w-full flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<img
|
||||
alt={alt}
|
||||
className="pointer-events-none max-h-[75vh] max-w-[85vw] select-none rounded-lg object-contain"
|
||||
draggable={false}
|
||||
onLoad={handleImageLoad}
|
||||
src={src}
|
||||
/>
|
||||
{naturalSize ? (
|
||||
<>
|
||||
<canvas
|
||||
aria-label="Drawing canvas"
|
||||
className="absolute inset-0 h-full w-full cursor-none touch-none rounded-lg"
|
||||
data-testid="composer-image-editor-canvas"
|
||||
height={naturalSize.height}
|
||||
onPointerCancel={commitActiveStroke}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={moveBrushPreview}
|
||||
onPointerLeave={hideBrushPreview}
|
||||
onPointerMove={(event) => {
|
||||
handlePointerMove(event);
|
||||
moveBrushPreview(event);
|
||||
}}
|
||||
onPointerUp={commitActiveStroke}
|
||||
ref={canvasRef}
|
||||
width={naturalSize.width}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-0 top-0 rounded-full opacity-0 ring-1 ring-white/60"
|
||||
ref={brushPreviewRef}
|
||||
style={{
|
||||
backgroundColor: activeColor,
|
||||
height: `${activeWidthCss}px`,
|
||||
width: `${activeWidthCss}px`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="fixed right-4 top-4 z-20 flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-3 animate-in fade-in slide-in-from-right-12 duration-300"
|
||||
data-testid="composer-image-editor-toolbar"
|
||||
>
|
||||
<input
|
||||
aria-label="Stroke width"
|
||||
className="h-1 w-12 cursor-pointer appearance-none rounded-full bg-white/25 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
max={PEN_WIDTH_MAX_CSS}
|
||||
min={PEN_WIDTH_MIN_CSS}
|
||||
onChange={(event) => setActiveWidthCss(Number(event.target.value))}
|
||||
step={PEN_WIDTH_STEP_CSS}
|
||||
type="range"
|
||||
value={activeWidthCss}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{PEN_COLORS.map((color) => (
|
||||
<button
|
||||
aria-label={`${color.label} pen`}
|
||||
aria-pressed={activeColor === color.value}
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full transition-transform",
|
||||
activeColor === color.value && "scale-110 ring-2 ring-white",
|
||||
)}
|
||||
key={color.value}
|
||||
onClick={() => setActiveColor(color.value)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full transition-[height,width]",
|
||||
color.label === "Black" && "ring-1 ring-white/30",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: color.value,
|
||||
height: `${activeWidthCss}px`,
|
||||
width: `${activeWidthCss}px`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Undo last stroke"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
disabled={!hasStrokes}
|
||||
onClick={undo}
|
||||
type="button"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Undo (⌘Z)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Redo stroke"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
disabled={history.undone.length === 0}
|
||||
onClick={redo}
|
||||
type="button"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Redo (⇧⌘Z)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="text-white hover:bg-white/10 hover:text-white"
|
||||
disabled={saving}
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="composer-image-editor-save"
|
||||
disabled={saving || !hasStrokes}
|
||||
onClick={() => void handleSave()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<p className="text-xs text-red-300" role="alert">
|
||||
{saveError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react";
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
HatGlasses,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
@@ -28,11 +29,6 @@ type FormattingToolbarProps = {
|
||||
onLinkButton?: () => void;
|
||||
};
|
||||
|
||||
export type SpoilerToggleState = {
|
||||
emptySelection: boolean;
|
||||
nextSpoilered?: boolean;
|
||||
};
|
||||
|
||||
type ActiveStates = {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
@@ -43,6 +39,7 @@ type ActiveStates = {
|
||||
bulletList: boolean;
|
||||
orderedList: boolean;
|
||||
blockquote: boolean;
|
||||
spoiler: boolean;
|
||||
};
|
||||
|
||||
function getActiveStates(editor: Editor): ActiveStates {
|
||||
@@ -56,6 +53,7 @@ function getActiveStates(editor: Editor): ActiveStates {
|
||||
bulletList: editor.isActive("bulletList"),
|
||||
orderedList: editor.isActive("orderedList"),
|
||||
blockquote: editor.isActive("blockquote"),
|
||||
spoiler: isSpoilerFormattingActive(editor),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,12 +75,17 @@ function documentRangeForEmptySelection(editor: Editor): {
|
||||
return from < to ? { from, to } : null;
|
||||
}
|
||||
|
||||
export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
|
||||
const emptySelection = editor.state.selection.empty;
|
||||
/**
|
||||
* Toggles the text spoiler mark. With a selection, toggles the mark on the
|
||||
* selected range; with an empty selection, applies/removes spoiler across the
|
||||
* whole document. Text-only — media spoilers are toggled per-attachment in
|
||||
* the attachment lightbox.
|
||||
*/
|
||||
export function toggleSpoilerFormatting(editor: Editor): void {
|
||||
const range = documentRangeForEmptySelection(editor);
|
||||
if (!range) {
|
||||
editor.chain().focus().toggleMark(SPOILER_MARK_NAME).run();
|
||||
return { emptySelection };
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPosition = editor.state.selection.from;
|
||||
@@ -94,16 +97,14 @@ export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
|
||||
);
|
||||
if (rangeSpoilerState === "no-markable-content") {
|
||||
chain.setTextSelection(cursorPosition).run();
|
||||
return { emptySelection };
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSpoilered = rangeSpoilerState !== "fully-spoiled";
|
||||
if (nextSpoilered) {
|
||||
if (rangeSpoilerState !== "fully-spoiled") {
|
||||
chain.setMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run();
|
||||
} else {
|
||||
chain.unsetMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run();
|
||||
}
|
||||
return { emptySelection, nextSpoilered };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,6 +203,11 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
|
||||
editor?.chain().focus().toggleBlockquote().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleSpoiler = React.useCallback(() => {
|
||||
if (!editor) return;
|
||||
toggleSpoilerFormatting(editor);
|
||||
}, [editor]);
|
||||
|
||||
if (!editor || !activeStates) return null;
|
||||
|
||||
const items = [
|
||||
@@ -264,12 +270,18 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
|
||||
action: toggleBlockquote,
|
||||
active: activeStates.blockquote,
|
||||
},
|
||||
{
|
||||
icon: HatGlasses,
|
||||
label: "Spoiler",
|
||||
action: toggleSpoiler,
|
||||
active: activeStates.spoiler,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{items.map((item) => (
|
||||
<Tooltip key={item.label}>
|
||||
<Tooltip key={item.label} disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
stripImetaMediaLines,
|
||||
} from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
|
||||
import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing";
|
||||
import {
|
||||
type MediaUploadController,
|
||||
useMediaUpload,
|
||||
@@ -815,41 +816,24 @@ function MessageComposerImpl({
|
||||
[media.removeAttachment],
|
||||
);
|
||||
|
||||
const handleComposerSpoilerToggle = React.useCallback(
|
||||
({
|
||||
emptySelection,
|
||||
nextSpoilered,
|
||||
}: {
|
||||
emptySelection: boolean;
|
||||
nextSpoilered?: boolean;
|
||||
}) => {
|
||||
if (!emptySelection) return;
|
||||
const { handleAttachmentEditSave, handleAttachmentRevert } =
|
||||
useAttachmentEditing({
|
||||
revertAttachment: media.revertAttachment,
|
||||
setSpoileredAttachmentUrls,
|
||||
uploadEditedAttachment: media.uploadEditedAttachment,
|
||||
});
|
||||
|
||||
const mediaUrls = media.pendingImetaRef.current
|
||||
.filter(
|
||||
(attachment) =>
|
||||
attachment.type.startsWith("image/") ||
|
||||
attachment.type.startsWith("video/"),
|
||||
)
|
||||
.map((attachment) => attachment.url);
|
||||
if (mediaUrls.length === 0) return;
|
||||
|
||||
setSpoileredAttachmentUrls((current) => {
|
||||
const shouldSpoiler =
|
||||
nextSpoilered ?? mediaUrls.some((url) => !current.has(url));
|
||||
const next = new Set(current);
|
||||
for (const url of mediaUrls) {
|
||||
if (shouldSpoiler) {
|
||||
next.add(url);
|
||||
} else {
|
||||
next.delete(url);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[media.pendingImetaRef],
|
||||
);
|
||||
const handleToggleAttachmentSpoiler = React.useCallback((url: string) => {
|
||||
setSpoileredAttachmentUrls((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(url)) {
|
||||
next.delete(url);
|
||||
} else {
|
||||
next.add(url);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -934,7 +918,11 @@ function MessageComposerImpl({
|
||||
onCancelUpload={media.cancelUpload}
|
||||
uploadingCount={media.uploadingCount}
|
||||
uploadingPreviews={media.uploadingPreviews}
|
||||
onEditSave={handleAttachmentEditSave}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRevert={handleAttachmentRevert}
|
||||
originalUrlByUrl={media.originalUrlByUrl}
|
||||
onToggleSpoiler={handleToggleAttachmentSpoiler}
|
||||
spoileredUrls={spoileredAttachmentUrls}
|
||||
/>
|
||||
</div>
|
||||
@@ -966,9 +954,7 @@ function MessageComposerImpl({
|
||||
onLinkButton={linkEditor.openFromToolbar}
|
||||
onOpenMentionPicker={openMentionPicker}
|
||||
onPaperclip={handlePaperclipClick}
|
||||
onSpoilerToggle={handleComposerSpoilerToggle}
|
||||
sendDisabled={sendDisabled}
|
||||
spoilerActive={spoileredAttachmentUrls.size > 0}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import * as React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
ALargeSmall,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
HatGlasses,
|
||||
Paperclip,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
|
||||
import {
|
||||
FormattingToolbar,
|
||||
isSpoilerFormattingActive,
|
||||
type SpoilerToggleState,
|
||||
toggleSpoilerFormatting,
|
||||
} from "./FormattingToolbar";
|
||||
import { FormattingToolbar } from "./FormattingToolbar";
|
||||
import { SelectionFormattingTray } from "./SelectionFormattingTray";
|
||||
|
||||
/** Spring for enter/exit of button groups — all fire simultaneously. */
|
||||
@@ -46,9 +33,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
onLinkButton,
|
||||
onOpenMentionPicker,
|
||||
onPaperclip,
|
||||
onSpoilerToggle,
|
||||
sendDisabled,
|
||||
spoilerActive,
|
||||
}: {
|
||||
composerDisabled: boolean;
|
||||
editor: Editor | null;
|
||||
@@ -65,38 +50,8 @@ export const MessageComposerToolbar = React.memo(
|
||||
onLinkButton: () => void;
|
||||
onOpenMentionPicker: () => void;
|
||||
onPaperclip: () => void;
|
||||
onSpoilerToggle?: (state: SpoilerToggleState) => void;
|
||||
sendDisabled: boolean;
|
||||
spoilerActive?: boolean;
|
||||
}) {
|
||||
const [spoilerFormattingActive, setSpoilerFormattingActive] =
|
||||
React.useState(() =>
|
||||
editor ? isSpoilerFormattingActive(editor) : false,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editor) {
|
||||
setSpoilerFormattingActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setSpoilerFormattingActive(isSpoilerFormattingActive(editor));
|
||||
};
|
||||
update();
|
||||
editor.on("transaction", update);
|
||||
return () => {
|
||||
editor.off("transaction", update);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const isSpoilerActive = spoilerFormattingActive || Boolean(spoilerActive);
|
||||
|
||||
const handleSpoilerClick = React.useCallback(() => {
|
||||
if (!editor) return;
|
||||
onSpoilerToggle?.(toggleSpoilerFormatting(editor));
|
||||
}, [editor, onSpoilerToggle]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<SelectionFormattingTray
|
||||
@@ -133,7 +88,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
exit={{ x: 8, opacity: 0 }}
|
||||
transition={presenceSpring}
|
||||
>
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle formatting"
|
||||
@@ -158,7 +113,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ ...presenceSpring, delay: 0.15 }}
|
||||
>
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Close formatting"
|
||||
@@ -203,7 +158,8 @@ export const MessageComposerToolbar = React.memo(
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={presenceSpring}
|
||||
>
|
||||
<Tooltip>
|
||||
{/* disableHoverableContent keeps tooltips from lingering over the editor. */}
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Mention someone"
|
||||
@@ -220,7 +176,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mention someone</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Attach image"
|
||||
@@ -244,34 +200,13 @@ export const MessageComposerToolbar = React.memo(
|
||||
onTriggerMouseDown={onCaptureSelection}
|
||||
open={isEmojiPickerOpen}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Spoiler"
|
||||
aria-pressed={isSpoilerActive}
|
||||
className={cn(
|
||||
isSpoilerActive &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
|
||||
)}
|
||||
disabled={composerDisabled || !editor || isUploading}
|
||||
onClick={handleSpoilerClick}
|
||||
onMouseDown={onCaptureSelection}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={isSpoilerActive ? "default" : "ghost"}
|
||||
>
|
||||
<HatGlasses />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Spoiler</TooltipContent>
|
||||
</Tooltip>
|
||||
<motion.div
|
||||
initial={{ x: -8, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -8, opacity: 0 }}
|
||||
transition={presenceSpring}
|
||||
>
|
||||
<Tooltip>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle formatting"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { invokeTauri } from "./tauri";
|
||||
|
||||
/**
|
||||
* Fetch relay media bytes over IPC (Rust reqwest, WARP-tunneled).
|
||||
*
|
||||
* Used by the composer image editor: wrapping the bytes in a same-origin
|
||||
* `blob:` URL gives the canvas pixel access without CORS, so the media
|
||||
* proxy needs no special headers. The Rust side enforces the same URL
|
||||
* validation and size cap as the download commands.
|
||||
*/
|
||||
export async function fetchMediaBytes(
|
||||
url: string,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const bytes = await invokeTauri<number[]>("fetch_media_bytes", { url });
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
@@ -8602,6 +8602,13 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return await resolveMockUploadDescriptors(activeConfig);
|
||||
case "upload_media_bytes":
|
||||
return (await resolveMockUploadDescriptors(activeConfig))[0];
|
||||
case "fetch_media_bytes": {
|
||||
// The real command fetches relay media through Rust reqwest. In E2E
|
||||
// the browser fetch suffices — specs serve the URL via page.route.
|
||||
const response = await fetch((payload as { url: string }).url);
|
||||
if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
|
||||
return Array.from(new Uint8Array(await response.arrayBuffer()));
|
||||
}
|
||||
case "download_image":
|
||||
case "download_file":
|
||||
// The save dialog can't run headlessly; report a successful save so the
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const ORIGINAL_SHA = "a".repeat(64);
|
||||
const EDITED_SHA = "b".repeat(64);
|
||||
const ORIGINAL_URL = "https://example.com/e2e/draw-original.svg";
|
||||
const EDITED_URL = "https://example.com/e2e/draw-edited.svg";
|
||||
|
||||
const ORIGINAL_DESCRIPTOR = {
|
||||
url: ORIGINAL_URL,
|
||||
sha256: ORIGINAL_SHA,
|
||||
size: 1234,
|
||||
type: "image/svg+xml",
|
||||
uploaded: Math.floor(Date.now() / 1000),
|
||||
dim: "320x200",
|
||||
filename: "draw-original.svg",
|
||||
};
|
||||
|
||||
const EDITED_DESCRIPTOR = {
|
||||
url: EDITED_URL,
|
||||
sha256: EDITED_SHA,
|
||||
size: 2345,
|
||||
type: "image/png",
|
||||
uploaded: Math.floor(Date.now() / 1000),
|
||||
dim: "320x200",
|
||||
filename: "draw-original.png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve deterministic same-size SVGs for both attachment URLs. These back
|
||||
* the display <img> loads and the mock bridge's `fetch_media_bytes`
|
||||
* handler (the editor exports via IPC bytes + blob: URL, so no CORS
|
||||
* headers are needed). The CORS header is required only because the mock
|
||||
* bridge's in-page `fetch()` of this cross-origin URL is CORS-mode —
|
||||
* production fetches the bytes in Rust instead.
|
||||
*/
|
||||
async function installImageRoutes(page: Page) {
|
||||
await page.route("https://example.com/e2e/draw-*.svg*", (route) => {
|
||||
const fill = route.request().url().includes("edited")
|
||||
? "#b3574a"
|
||||
: "#4aa3df";
|
||||
route.fulfill({
|
||||
body: `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200"><rect width="100%" height="100%" fill="${fill}"/></svg>`,
|
||||
contentType: "image/svg+xml",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function drawStrokeOnCanvas(page: Page) {
|
||||
const canvas = page.getByTestId("composer-image-editor-canvas");
|
||||
await expect(canvas).toBeVisible();
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Expected drawing canvas to have a layout box");
|
||||
const centerY = box.y + box.height / 2;
|
||||
await page.mouse.move(box.x + box.width * 0.25, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.75, centerY, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installImageRoutes(page);
|
||||
await installMockBridge(page, {
|
||||
uploadDescriptors: [ORIGINAL_DESCRIPTOR],
|
||||
});
|
||||
});
|
||||
|
||||
test("draw on an uploaded image, save replaces it, revert restores in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// Attach the original image via the mocked paperclip flow.
|
||||
await page.getByRole("button", { name: "Attach image" }).click();
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
|
||||
// Open the composer lightbox.
|
||||
await composer.getByAltText("Attachment aaaa").click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
|
||||
|
||||
// No revert affordance before any edit.
|
||||
await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0);
|
||||
|
||||
// Enter canvas mode; Escape leaves canvas mode but keeps the dialog open.
|
||||
await page.getByTestId("composer-attachment-edit").click();
|
||||
await expect(page.getByTestId("composer-image-editor-canvas")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("composer-image-editor-canvas")).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Re-enter canvas mode and draw a stroke.
|
||||
await page.getByTestId("composer-attachment-edit").click();
|
||||
const saveButton = page.getByTestId("composer-image-editor-save");
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await drawStrokeOnCanvas(page);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// The next mocked upload returns the annotated descriptor.
|
||||
await page.evaluate((edited) => {
|
||||
window.__BUZZ_E2E__ = {
|
||||
...window.__BUZZ_E2E__,
|
||||
mock: {
|
||||
...window.__BUZZ_E2E__?.mock,
|
||||
uploadDescriptors: [edited],
|
||||
},
|
||||
};
|
||||
}, EDITED_DESCRIPTOR);
|
||||
|
||||
await saveButton.click();
|
||||
|
||||
// Saving closes the lightbox; the composer thumbnail now shows the
|
||||
// annotated image.
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
|
||||
|
||||
// The annotated PNG went through the real upload command.
|
||||
const uploadCommandCount = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
|
||||
).__BUZZ_E2E_COMMANDS__?.filter(
|
||||
(command) => command === "upload_media_bytes",
|
||||
).length ?? 0,
|
||||
);
|
||||
expect(uploadCommandCount).toBe(1);
|
||||
|
||||
// Reopen the lightbox on the annotated attachment to revert.
|
||||
await composer.getByAltText("Attachment bbbb").click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator(`img[src="${EDITED_URL}"]`)).toBeVisible();
|
||||
|
||||
// Revert swaps back to the original without closing the dialog.
|
||||
await page.getByTestId("composer-attachment-revert").click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
|
||||
await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0);
|
||||
|
||||
// Closing the dialog shows the (restored) original thumbnail.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
});
|
||||
|
||||
test("spoiler marking survives drawing on the attachment", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByRole("button", { name: "Attach image" }).click();
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
|
||||
// Spoiler the attachment from its lightbox (media spoilers are
|
||||
// per-attachment; the text spoiler control no longer affects media),
|
||||
// then draw on it.
|
||||
await composer.getByAltText("Attachment aaaa").click();
|
||||
await page.getByTestId("composer-attachment-spoiler").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
|
||||
|
||||
await composer.getByAltText("Attachment aaaa").click();
|
||||
await page.getByTestId("composer-attachment-edit").click();
|
||||
await drawStrokeOnCanvas(page);
|
||||
|
||||
await page.evaluate((edited) => {
|
||||
window.__BUZZ_E2E__ = {
|
||||
...window.__BUZZ_E2E__,
|
||||
mock: {
|
||||
...window.__BUZZ_E2E__?.mock,
|
||||
uploadDescriptors: [edited],
|
||||
},
|
||||
};
|
||||
}, EDITED_DESCRIPTOR);
|
||||
await page.getByTestId("composer-image-editor-save").click();
|
||||
|
||||
// Saving closes the lightbox.
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
|
||||
// The annotated replacement is still marked as a spoiler.
|
||||
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
|
||||
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* Composer tooltips set disableHoverableContent, so they must dismiss the
|
||||
* instant the cursor leaves the trigger — including when it slides onto the
|
||||
* tooltip popup itself (Radix's default hoverable-content behavior would
|
||||
* keep it open, camping it over the message editor).
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
/** Hover the trigger, then slide the cursor onto the tooltip popup and
|
||||
* assert the tooltip dismisses instead of persisting. */
|
||||
async function expectTooltipDismissesOnLeave(
|
||||
page: import("@playwright/test").Page,
|
||||
trigger: import("@playwright/test").Locator,
|
||||
tooltipName: string,
|
||||
) {
|
||||
await trigger.hover();
|
||||
|
||||
const tip = page.getByRole("tooltip", { name: tooltipName });
|
||||
await expect(tip).toBeVisible();
|
||||
|
||||
// Slide off the trigger onto the tooltip popup.
|
||||
const box = await tip.boundingBox();
|
||||
if (!box) throw new Error("no tooltip box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
||||
steps: 5,
|
||||
});
|
||||
|
||||
await expect(tip).toBeHidden();
|
||||
}
|
||||
|
||||
test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await expectTooltipDismissesOnLeave(
|
||||
page,
|
||||
page.getByTestId("message-insert-mention"),
|
||||
"Mention someone",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// Open the formatting sub-toolbar (Bold / Italic / lists / Quote …).
|
||||
await page.getByRole("button", { name: "Toggle formatting" }).first().click();
|
||||
|
||||
const bold = page.getByRole("button", { name: "Bold" });
|
||||
await expect(bold).toBeVisible();
|
||||
|
||||
// Tooltip text is "<label> (<shortcut>)" for items that carry a shortcut.
|
||||
await expectTooltipDismissesOnLeave(page, bold, "Bold (⌘B)");
|
||||
});
|
||||
|
||||
test("emoji picker tooltip dismisses when cursor leaves the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// The emoji button wraps its tooltip around a nested PopoverTrigger, so
|
||||
// cover it separately from the plain toolbar buttons.
|
||||
await expectTooltipDismissesOnLeave(
|
||||
page,
|
||||
page.getByTestId("composer-emoji-button"),
|
||||
"Insert emoji",
|
||||
);
|
||||
});
|
||||
@@ -71,6 +71,7 @@ test("no-selection spoiler applies to every composer paragraph", async ({
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await expect(input.locator("p")).toHaveCount(paragraphs.length);
|
||||
|
||||
await page.getByRole("button", { name: "Toggle formatting" }).click();
|
||||
await page.getByRole("button", { name: "Spoiler", exact: true }).click();
|
||||
|
||||
await expect
|
||||
@@ -100,7 +101,10 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment cccc")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Spoiler", exact: true }).click();
|
||||
// Media spoilers are toggled per-attachment from the lightbox.
|
||||
await composer.getByAltText("Attachment cccc").click();
|
||||
await page.getByTestId("composer-attachment-spoiler").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
@@ -119,7 +123,7 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
|
||||
await expect(page.getByRole("dialog", { name: "image" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("spoiler button is disabled while attachment upload is pending", async ({
|
||||
test("text spoiler stays usable while attachment upload is pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installSpoilerBridge(page, { uploadDelayMs: 1_000 });
|
||||
@@ -127,19 +131,31 @@ test("spoiler button is disabled while attachment upload is pending", async ({
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await page.keyboard.type("pending secret");
|
||||
|
||||
// Kick off the (delayed) upload first — the attach button lives in the
|
||||
// passive toolbar, which is replaced while formatting is expanded.
|
||||
await page.getByRole("button", { name: "Attach image" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Toggle formatting" }).click();
|
||||
const spoilerButton = page.getByRole("button", {
|
||||
name: "Spoiler",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Text spoilers are independent of media uploads, so the button stays
|
||||
// enabled and works while the upload is still in flight.
|
||||
await expect(spoilerButton).toBeEnabled();
|
||||
await spoilerButton.click();
|
||||
await expect(input.locator(".buzz-spoiler[data-spoiler]")).toContainText(
|
||||
"pending secret",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Attach image" }).click();
|
||||
|
||||
await expect(spoilerButton).toBeDisabled({ timeout: 500 });
|
||||
await expect(
|
||||
page.getByTestId("message-composer").getByAltText("Attachment cccc"),
|
||||
).toBeVisible();
|
||||
await expect(spoilerButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("hidden spoiler links reveal without opening on the first click", async ({
|
||||
|
||||
Reference in New Issue
Block a user