Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default defineConfig({
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-link-shortcut.spec.ts",
"**/composer-selection-formatting.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/team-mentions.spec.ts",
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
)}
{canDropInMainColumn && mainComposerMedia.isDragOver ? (
<DropZoneOverlay className="z-30 rounded-none" />
<DropZoneOverlay className="z-50 rounded-2xl bg-primary/20 backdrop-blur-sm" />
) : null}
</section>
) : null}
Expand Down
86 changes: 86 additions & 0 deletions desktop/src/features/messages/lib/selectionBlockFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { TextSelection, type Transaction } from "@tiptap/pm/state";
import { canSplit } from "@tiptap/pm/transform";

function canSplitInsideTextblock(
transaction: Transaction,
position: number,
): boolean {
const $position = transaction.doc.resolve(position);
return (
$position.parent.inlineContent &&
$position.parentOffset > 0 &&
$position.parentOffset < $position.parent.content.size &&
canSplit(transaction.doc, position)
);
}

function mapRangeThroughLatestStep(
transaction: Transaction,
from: number,
to: number,
): { from: number; to: number } {
const stepMap = transaction.steps.at(-1)?.getMap();
return stepMap
? {
from: stepMap.map(from, 1),
to: stepMap.map(to, -1),
}
: { from, to };
}

/**
* Isolate a non-empty text selection at exact block boundaries.
*
* ProseMirror's block commands operate on whole textblocks. The composer can
* hold an entire draft in one paragraph, so toggling a list or code block for
* a substring otherwise formats the whole draft. Splitting at the selection
* end and start first gives the selected text its own block while preserving
* the surrounding content as sibling paragraphs.
*
* This mutates the transaction supplied by a Tiptap command chain so the
* isolation and the following block toggle remain one undoable edit.
*/
export function isolateSelectionForBlockFormatting(
transaction: Transaction,
): boolean {
if (
!(transaction.selection instanceof TextSelection) ||
transaction.selection.empty
) {
return false;
}

const isBackward = transaction.selection.anchor > transaction.selection.head;
let { from, to } = transaction.selection;

const nodeAfterSelection = transaction.doc.resolve(to).nodeAfter;
if (nodeAfterSelection?.type.name === "hardBreak") {
transaction.delete(to, to + nodeAfterSelection.nodeSize);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}

const nodeBeforeSelection = transaction.doc.resolve(from).nodeBefore;
if (nodeBeforeSelection?.type.name === "hardBreak") {
transaction.delete(from - nodeBeforeSelection.nodeSize, from);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}

if (canSplitInsideTextblock(transaction, to)) {
transaction.split(to);
Comment thread
klopez4212 marked this conversation as resolved.
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}

if (canSplitInsideTextblock(transaction, from)) {
transaction.split(from);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}

transaction.setSelection(
TextSelection.create(
transaction.doc,
isBackward ? to : from,
isBackward ? from : to,
),
);
return true;
}
10 changes: 8 additions & 2 deletions desktop/src/features/messages/ui/ComposerAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
HatGlasses,
Pencil,
Play,
UploadCloud,
Users,
X,
} from "lucide-react";
Expand Down Expand Up @@ -39,13 +40,18 @@ import { ComposerImageEditor } from "./ComposerImageEditor";
export function DropZoneOverlay({ className }: { className?: string }) {
return (
<div
data-testid="drop-zone-overlay"
className={cn(
"pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10",
className,
)}
>
<span className="text-sm font-medium text-primary">
Drop files to upload
<span
className="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 text-sm font-semibold text-background shadow-sm ring-1 ring-background/15"
data-testid="drop-zone-label"
>
<UploadCloud aria-hidden="true" className="size-4" />
<span>Drop files to upload</span>
</span>
</div>
);
Expand Down
111 changes: 91 additions & 20 deletions desktop/src/features/messages/ui/FormattingToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import { TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
import {
Bold,
Expand All @@ -15,6 +16,7 @@ import {

import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { isolateSelectionForBlockFormatting } from "@/features/messages/lib/selectionBlockFormatting";
import { getEditorSpoilerRangeState } from "@/features/messages/lib/spoilerFormatting";
import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark";

Expand All @@ -29,6 +31,11 @@ type FormattingToolbarProps = {
onLinkButton?: () => void;
};

type FormattingSelectionRange = {
anchor: number;
head: number;
};

type ActiveStates = {
bold: boolean;
italic: boolean;
Expand Down Expand Up @@ -117,6 +124,9 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
disabled = false,
onLinkButton,
}: FormattingToolbarProps) {
const pendingSelectionRef = React.useRef<FormattingSelectionRange | null>(
null,
);
const [activeStates, setActiveStates] = React.useState<ActiveStates | null>(
() => (editor ? getActiveStates(editor) : null),
);
Expand All @@ -137,36 +147,83 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
};
}, [editor]);

const toggleBold = React.useCallback(() => {
editor?.chain().focus().toggleBold().run();
const captureSelection = React.useCallback(() => {
if (!editor || editor.state.selection.empty) {
pendingSelectionRef.current = null;
return;
}

const { anchor, head } = editor.state.selection;
pendingSelectionRef.current = { anchor, head };
}, [editor]);

const toggleItalic = React.useCallback(() => {
editor?.chain().focus().toggleItalic().run();
const formattingChain = React.useCallback(() => {
if (!editor) return null;

const range = pendingSelectionRef.current;
pendingSelectionRef.current = null;
const chain = editor.chain();

if (
range &&
range.anchor !== range.head &&
range.anchor <= editor.state.doc.content.size &&
range.head <= editor.state.doc.content.size
) {
chain.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, range.anchor, range.head));
return true;
});
}

return chain.focus();
}, [editor]);

const toggleBold = React.useCallback(() => {
formattingChain()?.toggleBold().run();
}, [formattingChain]);

const toggleItalic = React.useCallback(() => {
formattingChain()?.toggleItalic().run();
}, [formattingChain]);

const toggleStrike = React.useCallback(() => {
editor?.chain().focus().toggleStrike().run();
}, [editor]);
formattingChain()?.toggleStrike().run();
}, [formattingChain]);

const toggleCode = React.useCallback(() => {
editor?.chain().focus().toggleCode().run();
}, [editor]);
formattingChain()?.toggleCode().run();
}, [formattingChain]);

const toggleCodeBlock = React.useCallback(() => {
editor?.chain().focus().toggleCodeBlock().run();
}, [editor]);
formattingChain()
?.command(({ tr }) => {
isolateSelectionForBlockFormatting(tr);
return true;
})
.toggleCodeBlock()
.run();
}, [formattingChain]);

const restorePendingSelection = React.useCallback(() => {
formattingChain()?.run();
}, [formattingChain]);

const toggleLink = React.useCallback(() => {
if (!editor) return;

// Preferred path: open the link-edit modal, which handles add, edit, and
// remove with proper display-text + URL fields.
// Restore the range captured on pointer-down before opening the modal.
// WKWebView may otherwise collapse it as focus moves to the toolbar.
if (onLinkButton) {
restorePendingSelection();
onLinkButton();
return;
}

const chain = formattingChain();
if (!chain) return;
chain.run();

// Legacy fallback (no modal wired): the native prompts below are a no-op
// in the Tauri WebView, so this path effectively does nothing there.
if (editor.isActive("link")) {
Expand All @@ -189,24 +246,37 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor.chain().focus().insertContent(`[${label}](${url})`).run();
}
}
}, [editor, onLinkButton]);
}, [editor, formattingChain, onLinkButton, restorePendingSelection]);

const toggleBulletList = React.useCallback(() => {
editor?.chain().focus().toggleBulletList().run();
}, [editor]);
formattingChain()
?.command(({ tr }) => {
isolateSelectionForBlockFormatting(tr);
return true;
})
.toggleBulletList()
.run();
}, [formattingChain]);

const toggleOrderedList = React.useCallback(() => {
editor?.chain().focus().toggleOrderedList().run();
}, [editor]);
formattingChain()
?.command(({ tr }) => {
isolateSelectionForBlockFormatting(tr);
return true;
})
.toggleOrderedList()
.run();
}, [formattingChain]);

const toggleBlockquote = React.useCallback(() => {
editor?.chain().focus().toggleBlockquote().run();
}, [editor]);
formattingChain()?.toggleBlockquote().run();
}, [formattingChain]);

const toggleSpoiler = React.useCallback(() => {
if (!editor) return;
restorePendingSelection();
toggleSpoilerFormatting(editor);
}, [editor]);
}, [editor, restorePendingSelection]);

if (!editor || !activeStates) return null;

Expand Down Expand Up @@ -289,6 +359,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
aria-pressed={item.active}
disabled={disabled}
onClick={() => item.action()}
onMouseDown={captureSelection}
className={cn(
"inline-flex h-7 w-7 min-w-7 items-center justify-center rounded-md text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export function SelectionFormattingTray({
? "-translate-x-1/2 -translate-y-full"
: "-translate-x-1/2",
)}
data-buzz-selection-formatting-tray
data-testid="selection-formatting-tray"
onMouseDown={(event) => event.preventDefault()}
role="toolbar"
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/shared/styles/globals/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,28 @@
--buzz-active-foreground: 0 0% 100%;
}

/*
* Use the Buzz primary color for the floating selection-formatting tray.
* Other themes retain the standard popover treatment.
*/
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] {
border-color: hsl(var(--primary-foreground) / 0.18);
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}

:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button {
color: hsl(var(--primary-foreground));
}

:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button:hover,
:root[data-buzz-sidebar]
[data-buzz-selection-formatting-tray]
button[aria-pressed="true"] {
background-color: hsl(var(--primary-foreground) / 0.16);
color: hsl(var(--primary-foreground));
}

/* Keep the subtle content-card lift in Buzz Light only. */
:root[data-buzz-sidebar]:not(.dark) [data-buzz-content-surface] {
box-shadow:
Expand Down
Loading