isolating the context menu more

This commit is contained in:
2026-02-20 09:17:19 -07:00
parent 865e86d11f
commit c224a6a9e2
4 changed files with 117 additions and 119 deletions

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useRef, useEffect, FC, useState } from "react"; import { useEffect, FC, useState } from "react";
import { IModuleItem } from "@/features/local/modules/IModuleItem"; import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { useCalendarItemsContext } from "../../../context/calendarItemsContext"; import { useCalendarItemsContext } from "../../../context/calendarItemsContext";
@@ -15,6 +15,7 @@ import {
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils"; import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
import { useCourseContext } from "../../../context/courseContext"; import { useCourseContext } from "../../../context/courseContext";
import Modal, { ModalControl } from "@/components/Modal";
function getDuplicateName(name: string, existingNames: string[]): string { function getDuplicateName(name: string, existingNames: string[]): string {
const match = name.match(/^(.*)\s+(\d+)$/); const match = name.match(/^(.*)\s+(\d+)$/);
@@ -27,15 +28,12 @@ function getDuplicateName(name: string, existingNames: string[]): string {
return `${baseName} ${num}`; return `${baseName} ${num}`;
} }
export const DayItemContextMenu: FC<{ export const AssignmentDayItemContextMenu: FC<{
x: number; modalControl: ModalControl;
y: number;
onClose: () => void;
item: IModuleItem; item: IModuleItem;
moduleName: string; moduleName: string;
}> = ({ x, y, onClose, item, moduleName }) => { }> = ({ modalControl, item, moduleName }) => {
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
const ref = useRef<HTMLDivElement>(null);
const calendarItems = useCalendarItemsContext(); const calendarItems = useCalendarItemsContext();
const createAssignment = useCreateAssignmentMutation(); const createAssignment = useCreateAssignmentMutation();
const deleteLocal = useDeleteAssignmentMutation(); const deleteLocal = useDeleteAssignmentMutation();
@@ -55,29 +53,21 @@ export const DayItemContextMenu: FC<{
: undefined; : undefined;
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setConfirmingDelete(false);
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setConfirmingDelete(false); setConfirmingDelete(false);
onClose(); modalControl.closeModal();
} }
}; };
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape); document.addEventListener("keydown", handleEscape);
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape); document.removeEventListener("keydown", handleEscape);
}; };
}, [onClose]); }, [modalControl]);
const handleClose = () => { const handleClose = () => {
setConfirmingDelete(false); setConfirmingDelete(false);
onClose(); modalControl.closeModal();
}; };
const handleDuplicate = () => { const handleDuplicate = () => {
@@ -120,19 +110,14 @@ export const DayItemContextMenu: FC<{
} }
}; };
const baseButtonClasses = "unstyled w-full text-left px-4 py-2"; const baseButtonClasses = "w-full text-left px-4 py-2";
const normalHoverClasses = "hover:bg-slate-700 disabled:opacity-50"; const normalHoverClasses = "hover:bg-slate-700 disabled:opacity-50";
const dangerClasses = const dangerClasses =
"bg-rose-900/30 hover:bg-rose-950 disabled:opacity-50 text-rose-50"; "bg-rose-900/30 hover:bg-rose-950 disabled:opacity-50 text-rose-50";
return ( return (
<div <Modal modalControl={modalControl}>
ref={ref} {() => (
className=" <>
fixed z-50 bg-slate-900 border-2 border-slate-700
rounded shadow-xl overflow-hidden
"
style={{ left: x, top: y }}
>
{confirmingDelete ? ( {confirmingDelete ? (
<> <>
<button <button
@@ -162,7 +147,7 @@ export const DayItemContextMenu: FC<{
href={canvasUrl} href={canvasUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="unstyled block px-4 py-2 text-sm hover:bg-slate-700 cursor-pointer" className="unstyled block px-4 py-2 hover:bg-slate-700 cursor-pointer"
onClick={handleClose} onClick={handleClose}
> >
View in Canvas View in Canvas
@@ -170,14 +155,14 @@ export const DayItemContextMenu: FC<{
<button <button
onClick={handleUpdateCanvas} onClick={handleUpdateCanvas}
disabled={updateInCanvas.isPending} disabled={updateInCanvas.isPending}
className="w-full text-left px-4 py-2 text-sm hover:bg-slate-700 disabled:opacity-50" className="unstyled w-full text-left px-4 py-2 hover:bg-slate-700 disabled:opacity-50"
> >
Update in Canvas Update in Canvas
</button> </button>
<button <button
onClick={handleDeleteFromCanvas} onClick={handleDeleteFromCanvas}
disabled={deleteFromCanvas.isPending} disabled={deleteFromCanvas.isPending}
className={`${baseButtonClasses} ${dangerClasses}`} className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
> >
Delete from Canvas Delete from Canvas
</button> </button>
@@ -186,19 +171,21 @@ export const DayItemContextMenu: FC<{
{!canvasUrl && ( {!canvasUrl && (
<button <button
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
className={`${baseButtonClasses} ${dangerClasses}`} className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
> >
Delete from Disk Delete from Disk
</button> </button>
)} )}
<button <button
onClick={handleDuplicate} onClick={handleDuplicate}
className={`${baseButtonClasses} ${normalHoverClasses}`} className={`unstyled ${baseButtonClasses} ${normalHoverClasses}`}
> >
Duplicate Duplicate
</button> </button>
</> </>
)} )}
</div> </>
)}
</Modal>
); );
}; };

View File

@@ -2,15 +2,16 @@
import { IModuleItem } from "@/features/local/modules/IModuleItem"; import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils"; import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { FC, ReactNode, useCallback, useState } from "react"; import { FC, ReactNode } from "react";
import { useCourseContext } from "../../../context/courseContext"; import { useCourseContext } from "../../../context/courseContext";
import { useTooltip } from "@/components/useTooltip"; import { useTooltip } from "@/components/useTooltip";
import { DraggableItem } from "../../../context/drag/draggingContext"; import { DraggableItem } from "../../../context/drag/draggingContext";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { useDragStyleContext } from "../../../context/drag/dragStyleContext"; import { useDragStyleContext } from "../../../context/drag/dragStyleContext";
import { Tooltip } from "../../../../../../components/Tooltip"; import { Tooltip } from "../../../../../../components/Tooltip";
import { DayItemContextMenu } from "./DayItemContextMenu"; import { AssignmentDayItemContextMenu } from "./DayItemContextMenu";
import { GetPreviewContent } from "./GetPreviewContent"; import { GetPreviewContent } from "./GetPreviewContent";
import { useModal } from "@/components/Modal";
export const ItemInDay: FC<{ export const ItemInDay: FC<{
type: "assignment" | "page" | "quiz"; type: "assignment" | "page" | "quiz";
@@ -22,23 +23,15 @@ export const ItemInDay: FC<{
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
const { setIsDragging } = useDragStyleContext(); const { setIsDragging } = useDragStyleContext();
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500); const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
const modalControl = useModal();
const [contextMenuPos, setContextMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const handleContextMenu = (e: React.MouseEvent) => { const handleContextMenu = (e: React.MouseEvent) => {
if (type !== "assignment") return; if (type !== "assignment") return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setContextMenuPos({ x: e.clientX, y: e.clientY }); modalControl.openModal({ x: e.clientX, y: e.clientY });
}; };
const closeContextMenu = useCallback(() => {
setContextMenuPos(null);
}, []);
return ( return (
<div className={" relative group "}> <div className={" relative group "}>
<Link <Link
@@ -87,11 +80,9 @@ export const ItemInDay: FC<{
) : ( ) : (
<Tooltip message={message} targetRef={targetRef} visible={visible} /> <Tooltip message={message} targetRef={targetRef} visible={visible} />
)} )}
{contextMenuPos && type === "assignment" && ( {type === "assignment" && (
<DayItemContextMenu <AssignmentDayItemContextMenu
x={contextMenuPos.x} modalControl={modalControl}
y={contextMenuPos.y}
onClose={closeContextMenu}
item={item} item={item}
moduleName={moduleName} moduleName={moduleName}
/> />

View File

@@ -116,7 +116,7 @@ export default function EditQuiz({
const updateQuizMutation = useUpdateQuizMutation(); const updateQuizMutation = useUpdateQuizMutation();
const { data: globalSettings } = useGlobalSettingsQuery(); const { data: globalSettings } = useGlobalSettingsQuery();
const feedbackDelimiters = getFeedbackDelimitersFromSettings( const feedbackDelimiters = getFeedbackDelimitersFromSettings(
(globalSettings ?? ({} as GlobalSettings)) as GlobalSettings (globalSettings ?? ({} as GlobalSettings)) as GlobalSettings,
); );
const { clientIsAuthoritative, text, textUpdate, monacoKey } = const { clientIsAuthoritative, text, textUpdate, monacoKey } =
@@ -141,14 +141,14 @@ export default function EditQuiz({
quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !== quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !==
quizMarkdownUtils.toMarkdown( quizMarkdownUtils.toMarkdown(
quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters), quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters),
feedbackDelimiters feedbackDelimiters,
) )
) { ) {
if (clientIsAuthoritative) { if (clientIsAuthoritative) {
const updatedQuiz = quizMarkdownUtils.parseMarkdown( const updatedQuiz = quizMarkdownUtils.parseMarkdown(
text, text,
quizName, quizName,
feedbackDelimiters feedbackDelimiters,
); );
await updateQuizMutation.mutateAsync({ await updateQuizMutation.mutateAsync({
quiz: updatedQuiz, quiz: updatedQuiz,
@@ -160,7 +160,7 @@ export default function EditQuiz({
}); });
} else { } else {
console.log( console.log(
"client not authoritative, updating client with server quiz" "client not authoritative, updating client with server quiz",
); );
textUpdate(quizMarkdownUtils.toMarkdown(quiz), true); textUpdate(quizMarkdownUtils.toMarkdown(quiz), true);
} }
@@ -178,6 +178,7 @@ export default function EditQuiz({
}, [ }, [
clientIsAuthoritative, clientIsAuthoritative,
courseName, courseName,
feedbackDelimiters,
isFetching, isFetching,
moduleName, moduleName,
quiz, quiz,

View File

@@ -3,23 +3,35 @@ import React, { ReactNode, useCallback, useMemo, useState } from "react";
export interface ModalControl { export interface ModalControl {
isOpen: boolean; isOpen: boolean;
openModal: () => void; openModal: (position?: { x: number; y: number }) => void;
closeModal: () => void; closeModal: () => void;
position?: { x: number; y: number };
} }
export function useModal() { export function useModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<
{ x: number; y: number } | undefined
>(undefined);
const openModal = useCallback(() => setIsOpen(true), []); const openModal = useCallback((pos?: { x: number; y: number }) => {
const closeModal = useCallback(() => setIsOpen(false), []); setPosition(pos);
setIsOpen(true);
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
setPosition(undefined);
}, []);
return useMemo( return useMemo(
() => ({ () => ({
isOpen, isOpen,
openModal, openModal,
closeModal, closeModal,
position,
}), }),
[closeModal, isOpen, openModal] [closeModal, isOpen, openModal, position],
); );
} }
@@ -40,10 +52,13 @@ export default function Modal({
}) { }) {
return ( return (
<> <>
{buttonComponent ? ( {buttonComponent
buttonComponent({ openModal: modalControl.openModal }) ? buttonComponent({ openModal: () => modalControl.openModal() })
) : ( : buttonText && (
<button onClick={modalControl.openModal} className={buttonClass}> <button
onClick={() => modalControl.openModal()}
className={buttonClass}
>
{buttonText} {buttonText}
</button> </button>
)} )}
@@ -51,7 +66,7 @@ export default function Modal({
<div <div
className={ className={
modalControl.isOpen modalControl.isOpen
? "transition-all duration-400 fixed inset-0 flex items-center justify-center h-screen bg-black/80 z-50 w-screen" ? `transition-all duration-400 fixed inset-0 ${modalControl.position ? "" : "flex items-center justify-center"} h-screen bg-black/80 z-50 w-screen`
: "hidden h-0 w-0 p-1 -z-50" : "hidden h-0 w-0 p-1 -z-50"
} }
onClick={modalControl.closeModal} onClick={modalControl.closeModal}
@@ -60,11 +75,15 @@ export default function Modal({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
className={ className={`bg-slate-800 ${modalControl.position ? "" : "p-6"} rounded-lg shadow-lg ${modalControl.position ? "" : modalWidth} transition-all duration-400 ${modalControl.isOpen ? "opacity-100" : "opacity-0"}`}
` bg-slate-800 p-6 rounded-lg shadow-lg ` + style={
modalWidth + modalControl.position
` transition-all duration-400 ` + ? {
` ${modalControl.isOpen ? "opacity-100" : "opacity-0"}` position: "fixed",
left: modalControl.position.x,
top: modalControl.position.y,
}
: undefined
} }
> >
{modalControl.isOpen && {modalControl.isOpen &&