mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54:06 +02:00
Merge branch 'main' into SaaS
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for the floating action button chat widget.
|
||||
* The real implementation lives in proprietary/components/chat/ChatFAB.tsx
|
||||
* and shadows this via the @app/* alias cascade in proprietary builds.
|
||||
*/
|
||||
export function ChatFAB() {
|
||||
return null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
import LandingPage from "@app/components/shared/LandingPage";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||
import { ChatFAB } from "@app/components/chat/ChatFAB";
|
||||
|
||||
// Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the
|
||||
// full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file
|
||||
@@ -225,6 +226,9 @@ export default function Workbench() {
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Floating AI chat button + panel */}
|
||||
<ChatFAB />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
|
||||
|
||||
@@ -8,6 +8,10 @@ export const Z_ANALYTICS_MODAL = 1301;
|
||||
export const Z_INDEX_CONFIG_MODAL = 1400;
|
||||
|
||||
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
||||
|
||||
// Chat FAB overlay — sits above normal app chrome (fullscreen surface: 1000)
|
||||
// but below all modals (automate: 1100, file manager: 1200, config: 1400).
|
||||
export const Z_INDEX_CHAT_FAB_OVERLAY = 1050;
|
||||
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
|
||||
|
||||
export const Z_INDEX_AUTOMATE_MODAL = 1100;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Overlay layer ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.chat-fab-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* FAB trigger button ─────────────────────────────────────────────────────── */
|
||||
|
||||
.chat-fab-trigger {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: calc(var(--footer-height, 2rem) + 16px);
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
/* Include box-shadow so the ChatFABButton hover shadow still animates */
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-fab-trigger {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-fab-trigger--hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.78);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Panel layout ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ChatPanel fills remaining height when inside the FAB window */
|
||||
.chat-fab-panel-rnd .chat-panel--embedded {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Header is the drag handle — grab cursor on background areas */
|
||||
.chat-fab-panel-rnd .chat-panel__header {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-fab-panel-rnd .chat-panel__header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Buttons inside the header keep their pointer cursor */
|
||||
.chat-fab-panel-rnd .chat-panel__header button,
|
||||
.chat-fab-panel-rnd .chat-panel__header [role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createTheme, MantineProvider, Popover } from "@mantine/core";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChatFABButton } from "@shared/components/ChatFABButton";
|
||||
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { useAgentsEnabled } from "@app/components/agents/AgentsPanel";
|
||||
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
|
||||
import "@app/components/chat/ChatFAB.css";
|
||||
|
||||
const PANEL_WIDTH_PX = 390;
|
||||
const PANEL_HEIGHT_PX = 520;
|
||||
const PANEL_MIN_WIDTH_PX = 300;
|
||||
const PANEL_MIN_HEIGHT_PX = 380;
|
||||
const FAB_GAP_PX = 16;
|
||||
// footer-height (2rem = 32px) + gap so the panel clears the footer
|
||||
const FAB_BOTTOM_OFFSET_PX = 32 + FAB_GAP_PX;
|
||||
|
||||
const RESET_MS = 380;
|
||||
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
|
||||
const RESET_TRANSITION = `transform ${RESET_MS}ms ${RESET_EASING}, width ${RESET_MS}ms ${RESET_EASING}, height ${RESET_MS}ms ${RESET_EASING}`;
|
||||
|
||||
// Raise Mantine popup z-index so Menu/Popover portals appear above the FAB overlay.
|
||||
const FAB_PANEL_THEME = createTheme({
|
||||
components: {
|
||||
Popover: Popover.extend({
|
||||
defaultProps: { zIndex: Z_INDEX_CHAT_FAB_OVERLAY + 300 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Resize handle strips sit half-inside / half-outside the 1px border.
|
||||
// zIndex: 1 ensures they appear above ChatFABWindow's stacking context
|
||||
// (which is created by the CSS open/close transform), so the resize cursor
|
||||
// is visible on hover — not just during active drag.
|
||||
// Corners get a 14×14 zone; edges get a 6px-wide strip.
|
||||
const RESIZE_HANDLES = {
|
||||
top: { top: -3, left: 14, right: 14, height: 6, zIndex: 1 },
|
||||
bottom: { bottom: -3, left: 14, right: 14, height: 6, zIndex: 1 },
|
||||
left: { left: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
|
||||
right: { right: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
|
||||
topLeft: { top: -4, left: -4, width: 14, height: 14, zIndex: 1 },
|
||||
topRight: { top: -4, right: -4, width: 14, height: 14, zIndex: 1 },
|
||||
bottomLeft: { bottom: -4, left: -4, width: 14, height: 14, zIndex: 1 },
|
||||
bottomRight: { bottom: -4, right: -4, width: 14, height: 14, zIndex: 1 },
|
||||
};
|
||||
|
||||
export function ChatFAB() {
|
||||
const { t } = useTranslation();
|
||||
// Intentionally separate from useChat().isOpen — the FAB tracks its own
|
||||
// open state so it doesn't interact with the right-rail chat panel.
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
|
||||
const { isLoading } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
// Detect loading → done transition. If the FAB is closed when the agent
|
||||
// finishes, show the tick badge until the user opens the panel.
|
||||
const isOpenRef = useRef(isOpen);
|
||||
isOpenRef.current = isOpen;
|
||||
// Initialize to false, not isLoading: if loading is already in-flight at
|
||||
// mount we never showed the spinner, so we shouldn't show the tick on completion.
|
||||
const prevIsLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const wasLoading = prevIsLoadingRef.current;
|
||||
prevIsLoadingRef.current = isLoading;
|
||||
if (wasLoading && !isLoading && !isOpenRef.current) {
|
||||
setHasUnviewedResult(true);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [rndPos, setRndPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [rndSize, setRndSize] = useState({
|
||||
width: PANEL_WIDTH_PX,
|
||||
height: PANEL_HEIGHT_PX,
|
||||
});
|
||||
const [isAnimatingReset, setIsAnimatingReset] = useState(false);
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getDefaultPos = () => {
|
||||
const el = overlayRef.current;
|
||||
if (!el) return null;
|
||||
return {
|
||||
x: el.offsetWidth - PANEL_WIDTH_PX - FAB_GAP_PX,
|
||||
y: el.offsetHeight - PANEL_HEIGHT_PX - FAB_BOTTOM_OFFSET_PX,
|
||||
};
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pos = getDefaultPos();
|
||||
if (pos) setRndPos(pos);
|
||||
}, []);
|
||||
|
||||
// Clear the reset timer on unmount to avoid state updates on dead components.
|
||||
// Also ensure body user-select is restored if we unmount mid-resize.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
|
||||
document.body.style.removeProperty("user-select");
|
||||
document.body.style.removeProperty("-webkit-user-select");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelResetAnimation = () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
}
|
||||
setIsAnimatingReset(false);
|
||||
};
|
||||
|
||||
const handleHeaderDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".chat-panel__header") && !target.closest("button")) {
|
||||
const pos = getDefaultPos();
|
||||
if (!pos) return;
|
||||
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
|
||||
setIsAnimatingReset(true);
|
||||
setRndPos(pos);
|
||||
setRndSize({ width: PANEL_WIDTH_PX, height: PANEL_HEIGHT_PX });
|
||||
resetTimerRef.current = setTimeout(() => {
|
||||
setIsAnimatingReset(false);
|
||||
resetTimerRef.current = null;
|
||||
}, RESET_MS + 60);
|
||||
}
|
||||
};
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="chat-fab-overlay"
|
||||
style={{ zIndex: Z_INDEX_CHAT_FAB_OVERLAY }}
|
||||
>
|
||||
{/* Trigger button — fades out while panel is open */}
|
||||
<ChatFABButton
|
||||
className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setHasUnviewedResult(false);
|
||||
}}
|
||||
aria-label={t("chat.fab.open", "Open Stirling AI assistant")}
|
||||
aria-expanded={isOpen}
|
||||
isLoading={isLoading}
|
||||
showTick={hasUnviewedResult && !isLoading}
|
||||
/>
|
||||
|
||||
{/* Draggable / resizable panel */}
|
||||
{rndPos !== null && (
|
||||
<Rnd
|
||||
className="chat-fab-panel-rnd"
|
||||
position={rndPos}
|
||||
size={rndSize}
|
||||
minWidth={PANEL_MIN_WIDTH_PX}
|
||||
minHeight={PANEL_MIN_HEIGHT_PX}
|
||||
bounds="parent"
|
||||
enableResizing={true}
|
||||
// Drag from the header; cancel keeps buttons inside it clickable
|
||||
dragHandleClassName="chat-panel__header"
|
||||
cancel="button, [role='button']"
|
||||
onDragStart={cancelResetAnimation}
|
||||
onDragStop={(_e, d) => setRndPos({ x: d.x, y: d.y })}
|
||||
onResizeStart={() => {
|
||||
cancelResetAnimation();
|
||||
document.body.style.setProperty("user-select", "none");
|
||||
document.body.style.setProperty("-webkit-user-select", "none");
|
||||
}}
|
||||
onResizeStop={(_e, _dir, ref, _delta, pos) => {
|
||||
document.body.style.removeProperty("user-select");
|
||||
document.body.style.removeProperty("-webkit-user-select");
|
||||
setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight });
|
||||
setRndPos(pos);
|
||||
}}
|
||||
// Invisible strips centred on the border — cursor change is the affordance.
|
||||
// zIndex: 1 lifts them above ChatFABWindow's stacking context.
|
||||
resizeHandleStyles={RESIZE_HANDLES}
|
||||
style={{
|
||||
pointerEvents: isOpen ? "auto" : "none",
|
||||
transition: isAnimatingReset ? RESET_TRANSITION : undefined,
|
||||
}}
|
||||
>
|
||||
<ChatFABWindow open={isOpen} onDoubleClick={handleHeaderDoubleClick}>
|
||||
<MantineProvider theme={FAB_PANEL_THEME}>
|
||||
<ChatPanel
|
||||
onBack={() => setIsOpen(false)}
|
||||
backLabel={t("chat.fab.close", "Close chat")}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</ChatFABWindow>
|
||||
</Rnd>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user