feat: refine Beam attachments and peek layouts

This commit is contained in:
DCCONSTRUCTIONS 2026-06-05 14:45:48 +03:00
parent ff30eeb8fd
commit 81abcb5b36
6 changed files with 455 additions and 181 deletions

View File

@ -213,10 +213,7 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"nodedc-dropdown-surface z-30 my-1 overflow-hidden",
optionsClassName
)}
className={cn("nodedc-dropdown-surface z-[760] my-1 overflow-hidden", optionsClassName)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}

View File

@ -8,11 +8,12 @@ import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
import { LayoutGrid, List, UploadCloud } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { convertBytesToSize, getFileExtension } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web hooks
@ -25,6 +26,8 @@ import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
// types
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
type TAttachmentViewMode = "grid" | "list";
type TIssueAttachmentItemList = {
workspaceSlug: string;
projectId: string;
@ -46,6 +49,10 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
const { t } = useTranslation();
// states
const [isUploading, setIsUploading] = useState(false);
const [attachmentViewMode, setAttachmentViewMode] = useState<TAttachmentViewMode>(() => {
if (typeof window === "undefined") return "grid";
return window.localStorage.getItem("nodedc-issue-attachment-view-mode") === "list" ? "list" : "grid";
});
// store hooks
const {
attachment: { getAttachmentsByIssueId },
@ -67,6 +74,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
activeUploads.length > 0
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
: 0;
const isListView = attachmentViewMode === "list";
// handlers
const handleFetchPropertyActivities = useCallback(() => {
@ -122,6 +130,14 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
disabled: isUploading || disabled,
});
const handleToggleAttachmentViewMode = useCallback(() => {
setAttachmentViewMode((currentMode) => {
const nextMode: TAttachmentViewMode = currentMode === "grid" ? "list" : "grid";
if (typeof window !== "undefined") window.localStorage.setItem("nodedc-issue-attachment-view-mode", nextMode);
return nextMode;
});
}, []);
return (
<div className="space-y-3">
{attachmentDeleteModalId && (
@ -173,13 +189,73 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
)}
{hasAttachmentRows && (
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]">
<div
className="nodedc-attachments-panel rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]"
data-view-mode={attachmentViewMode}
>
<div className="mb-2 flex items-center justify-between gap-3 px-1">
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
<div className="flex items-center gap-2">
<div className="text-12 text-tertiary">
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
</div>
<button
type="button"
className="grid size-8 place-items-center rounded-full bg-surface-2/80 text-tertiary transition hover:bg-surface-2 hover:text-primary"
onClick={handleToggleAttachmentViewMode}
title={isListView ? "Показать плитками" : "Показать списком"}
>
{isListView ? <LayoutGrid className="size-4" /> : <List className="size-4" />}
</button>
</div>
</div>
{isListView ? (
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
<div className="min-w-[760px]">
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
<div>Файл</div>
<div>Дата</div>
<div>Версия</div>
<div>Добавил</div>
<div className="text-right">Действия</div>
</div>
{uploadStatus?.map((status) => (
<div
key={status.id}
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
>
<div className="min-w-0">
<div className="truncate font-semibold text-secondary">{status.name}</div>
<div className="mt-1 text-11 text-tertiary">
{(getFileExtension(status.name) || "file").toUpperCase()} · {convertBytesToSize(status.size)}
</div>
</div>
<div className="text-tertiary">Загрузка</div>
<div className="text-secondary">v1</div>
<div className="text-tertiary">{status.progress}%</div>
<div className="h-1 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-[rgb(var(--nodedc-accent-rgb))]"
style={{ width: `${status.progress}%` }}
/>
</div>
</div>
))}
{issueAttachments.map((attachmentId) => (
<IssueAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueId={issueId}
issueServiceType={issueServiceType}
projectId={projectId}
viewMode="list"
workspaceSlug={workspaceSlug}
/>
))}
</div>
</div>
) : (
<div className="flex gap-3 overflow-x-auto pb-1">
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
@ -196,6 +272,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
/>
))}
</div>
)}
</div>
)}
</div>

View File

@ -6,6 +6,7 @@
import { observer } from "mobx-react";
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
@ -45,6 +46,7 @@ type TIssueAttachmentsListItem = {
issueServiceType?: TIssueServiceType;
issueId: string;
projectId: string;
viewMode?: "grid" | "list";
workspaceSlug: string;
};
@ -144,6 +146,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
issueId,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
viewMode = "grid",
workspaceSlug,
} = props;
// store hooks
@ -199,6 +202,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
"Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.";
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isThumbnailError, setIsThumbnailError] = useState(false);
const rawVersion = attachment?.attributes.version;
const versionLabel =
typeof rawVersion === "number"
? `v${rawVersion}`
: typeof rawVersion === "string"
? rawVersion.toLowerCase().startsWith("v")
? rawVersion
: `v${rawVersion}`
: "v1";
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—";
const openModelViewer = () => {
if (!modelViewerUrl) return;
dispatchBeamViewerOpenEvent({
@ -294,6 +308,148 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!attachment) return <></>;
if (viewMode === "list") {
return (
<>
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
<button
type="button"
className="flex min-w-0 items-center gap-3 text-left"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true);
}}
>
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
{isBeamModel ? (
isBeamConversionFailed ? (
<AlertTriangle className="text-red-400 size-5" />
) : isBeamAwaitingPreview ? (
<Spinner
height="20px"
width="20px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
) : (
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
<ImageIcon className="size-5" />
)}
</div>
<div className="min-w-0">
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
<div className="truncate font-semibold text-secondary">{fullFileName}</div>
</Tooltip>
<div className="mt-1 flex min-w-0 items-center gap-2 text-11 text-tertiary">
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
<span className="size-1 rounded-full bg-layer-1" />
<span>{convertBytesToSize(attachment.attributes.size)}</span>
{beamConversionStatusLabel && (
<>
<span className="size-1 rounded-full bg-layer-1" />
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
<span
className={
isBeamConversionFailed
? "text-red-400"
: isBeamAwaitingPreview
? "text-[rgb(var(--nodedc-accent-rgb))]"
: "text-tertiary"
}
>
{beamConversionStatusLabel}
</span>
</Tooltip>
</>
)}
</div>
</div>
</button>
<div className="truncate text-tertiary">{uploadedAt}</div>
<div className="text-secondary">{versionLabel}</div>
<div className="truncate text-tertiary">{uploadedBy}</div>
<div className="flex items-center justify-end gap-1">
{(modelViewerUrl || previewURL) && (
<Tooltip
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
isMobile={isMobile}
>
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true);
}}
>
<Eye className="size-4" />
</button>
</Tooltip>
)}
{previewDownloadUrl && (
<Tooltip tooltipContent={modelDownloadUrl ? "Скачать оригинал" : "Скачать файл"} isMobile={isMobile}>
<a
href={previewDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={(e) => e.stopPropagation()}
>
<Download className="size-4" />
</a>
</Tooltip>
)}
<Tooltip tooltipContent={t("common.actions.delete")} isMobile={isMobile}>
<button
type="button"
className="hover:bg-red-500/10 hover:text-red-400 grid size-8 place-items-center rounded-full text-tertiary transition disabled:pointer-events-none disabled:opacity-40"
disabled={!!disabled}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDeleteAttachmentModal(attachmentId);
}}
>
<TrashIcon className="size-4" />
</button>
</Tooltip>
</div>
</div>
<ModalCore
isOpen={isPreviewOpen}
handleClose={() => setIsPreviewOpen(false)}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
>
<AttachmentPreviewContent
beamConversionStatusLabel={beamConversionStatusLabel}
beamStatusTooltipContent={beamStatusTooltipContent}
fileIcon={fileIcon}
fullFileName={fullFileName}
isBeamAwaitingPreview={isBeamAwaitingPreview}
isBeamConversionFailed={isBeamConversionFailed}
modelDownloadUrl={modelDownloadUrl}
previewDownloadUrl={previewDownloadUrl}
previewType={previewType}
previewURL={previewURL}
setIsPreviewOpen={setIsPreviewOpen}
size={attachment.attributes.size}
/>
</ModalCore>
</>
);
}
return (
<>
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
@ -400,6 +556,57 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
width={EModalWidth.VIIXL}
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
>
<AttachmentPreviewContent
beamConversionStatusLabel={beamConversionStatusLabel}
beamStatusTooltipContent={beamStatusTooltipContent}
fileIcon={fileIcon}
fullFileName={fullFileName}
isBeamAwaitingPreview={isBeamAwaitingPreview}
isBeamConversionFailed={isBeamConversionFailed}
modelDownloadUrl={modelDownloadUrl}
previewDownloadUrl={previewDownloadUrl}
previewType={previewType}
previewURL={previewURL}
setIsPreviewOpen={setIsPreviewOpen}
size={attachment.attributes.size}
/>
</ModalCore>
</>
);
});
type TAttachmentPreviewContent = {
beamConversionStatusLabel: string | null;
beamStatusTooltipContent: string;
fileIcon: ReactNode;
fullFileName: string;
isBeamAwaitingPreview: boolean;
isBeamConversionFailed: boolean;
modelDownloadUrl: string | undefined;
previewDownloadUrl: string | undefined;
previewType: "image" | "video" | "pdf" | "file";
previewURL: string;
setIsPreviewOpen: (isOpen: boolean) => void;
size: number;
};
const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
const {
beamConversionStatusLabel,
beamStatusTooltipContent,
fileIcon,
fullFileName,
isBeamAwaitingPreview,
isBeamConversionFailed,
modelDownloadUrl,
previewDownloadUrl,
previewType,
previewURL,
setIsPreviewOpen,
size,
} = props;
return (
<div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
{previewDownloadUrl && (
@ -425,7 +632,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<div className="flex-shrink-0 pr-24">
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(size)}</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-2xl bg-black/20">
@ -493,7 +700,5 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
)}
</div>
</div>
</ModalCore>
</>
);
});
};

View File

@ -6,16 +6,14 @@
import { useRef, type ReactNode } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { MoveDiagonal, MoveRight } from "lucide-react";
import { Maximize2, Minimize2, MoveRight, X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CenterPanelIcon, CopyLinkIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
import { CopyLinkIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -31,24 +29,6 @@ import { IconButton } from "@plane/propel/icon-button";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
i18n_title: "common.side_peek",
},
{
key: "modal",
icon: CenterPanelIcon,
i18n_title: "common.modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
i18n_title: "common.full_screen",
},
];
export type PeekOverviewHeaderProps = {
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
@ -68,7 +48,6 @@ export type PeekOverviewHeaderProps = {
actionSlot?: ReactNode;
metaSlot?: ReactNode;
showCopyLink?: boolean;
showLayoutSwitcher?: boolean;
showQuickActions?: boolean;
showSubscription?: boolean;
};
@ -82,7 +61,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
issueId,
isArchived,
disabled,
embedIssue = false,
removeRoutePeekId,
toggleDeleteIssueModal,
toggleArchiveIssueModal,
@ -93,7 +71,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
actionSlot,
metaSlot,
showCopyLink = true,
showLayoutSwitcher = !embedIssue,
showQuickActions = true,
showSubscription = true,
} = props;
@ -113,7 +90,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
const { getProjectIdentifierById } = useProject();
// derived values
const issueDetails = getIssueById(issueId);
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
const {
issues: { removeIssue: removeArchivedIssue },
@ -168,50 +144,29 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
<div
ref={parentRef}
className={`relative flex flex-wrap items-start justify-between gap-3 px-6 py-5 ${
currentMode?.key === "full-screen" ? "border-b border-subtle/70" : ""
peekMode === "full-screen" ? "border-b border-subtle/70" : ""
}`}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3 md:gap-4">
<Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}>
<button onClick={removeRoutePeekId}>
{peekMode === "side-peek" ? (
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
) : (
<X className="h-4 w-4 text-tertiary hover:text-secondary" />
)}
</button>
</Tooltip>
<Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}>
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
</Link>
</Tooltip>
{currentMode && showLayoutSwitcher && (
<div className="flex flex-shrink-0 items-center gap-2">
<SelectionDropdown
menuButton={
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}>
<span>
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</span>
</Tooltip>
}
menuButtonWrapperClassName="flex items-center"
options={PEEK_OPTIONS.map((mode) => ({
key: mode.key,
isChecked: currentMode.key === mode.key,
onClick: () => setPeekMode(mode.key),
title: (
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key ? "text-secondary" : "text-placeholder hover:text-secondary"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{t(mode.i18n_title)}
</div>
),
}))}
/>
</div>
<button type="button" onClick={() => setPeekMode(peekMode === "full-screen" ? "side-peek" : "full-screen")}>
{peekMode === "full-screen" ? (
<Minimize2 className="h-4 w-4 text-tertiary hover:text-secondary" />
) : (
<Maximize2 className="h-4 w-4 text-tertiary hover:text-secondary" />
)}
</button>
</Tooltip>
{metaSlot}
</div>
<div className="ml-auto flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-x-4 gap-y-2">

View File

@ -121,7 +121,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const issue = getIssueById(issueId);
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
// remove peek id
const removeRoutePeekId = () => {
@ -371,8 +370,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
!shouldUseBeamPeekShell && {
"nodedc-peek-side-bound top-[5.35rem] right-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:max-w-[calc(100vw-1.5rem)] md:min-w-[640px]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
"nodedc-peek-modal-bound rounded-[28px]": peekMode === "modal",
"nodedc-peek-full-bound rounded-[28px]": peekMode === "full-screen",
},
!embedIssue &&
shouldUseBeamPeekShell &&
@ -448,7 +447,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
isSubmitting={isSubmitting}
disabled={disabled}
embedIssue={embedIssue}
showLayoutSwitcher={shouldAllowPeekModeToggle}
/>
)}
{/* content */}

View File

@ -386,6 +386,31 @@
bottom: var(--nodedc-peek-bottom-offset) !important;
}
.nodedc-peek-modal-bound {
position: fixed !important;
top: 5.35rem !important;
bottom: var(--nodedc-peek-bottom-offset) !important;
left: 50% !important;
right: auto !important;
width: min(76rem, calc(100vw - 3rem)) !important;
max-width: calc(100vw - 3rem) !important;
height: auto !important;
min-height: 0 !important;
transform: translateX(-50%);
}
.nodedc-peek-full-bound {
position: fixed !important;
top: 5.35rem !important;
right: 0.75rem !important;
bottom: var(--nodedc-peek-bottom-offset) !important;
left: 0.75rem !important;
width: auto !important;
max-width: none !important;
height: auto !important;
min-height: 0 !important;
}
.nodedc-beam-peek-shell[data-fullscreen="true"] {
grid-template-columns: minmax(0, 1fr) 0;
column-gap: 0;
@ -5806,6 +5831,23 @@
color: var(--text-color-primary) !important;
}
.nodedc-attachments-panel[data-view-mode="list"] {
padding-bottom: 0.75rem;
}
.nodedc-attachments-list-scroll {
min-height: 13.5rem;
max-height: min(34rem, calc(100vh - 20rem));
resize: vertical;
scrollbar-gutter: stable;
}
.nodedc-attachments-list-scroll::-webkit-resizer {
background:
linear-gradient(135deg, transparent 0 45%, rgba(var(--nodedc-accent-rgb), 0.65) 46% 54%, transparent 55%),
linear-gradient(135deg, transparent 0 62%, rgba(255, 255, 255, 0.22) 63% 70%, transparent 71%);
}
.nodedc-processing-loader,
.nodedc-voice-task-processing-loader {
--nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);