summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/files/ElementContextMenu.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/files/ElementContextMenu.tsx
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/files/ElementContextMenu.tsx')
-rw-r--r--makima/frontend/src/components/files/ElementContextMenu.tsx292
1 files changed, 292 insertions, 0 deletions
diff --git a/makima/frontend/src/components/files/ElementContextMenu.tsx b/makima/frontend/src/components/files/ElementContextMenu.tsx
new file mode 100644
index 0000000..dcb430c
--- /dev/null
+++ b/makima/frontend/src/components/files/ElementContextMenu.tsx
@@ -0,0 +1,292 @@
+import { useEffect, useRef, useState } from "react";
+import type { BodyElement } from "../../lib/api";
+
+interface ElementContextMenuProps {
+ x: number;
+ y: number;
+ element: BodyElement;
+ elementIndex: number;
+ selectedText?: string;
+ onClose: () => void;
+ onFocus: (index: number) => void;
+ onDelete: (index: number) => void;
+ onDuplicate: (index: number) => void;
+ onConvert: (index: number, toType: string) => void;
+ onGenerate: (index: number, action: string) => void;
+ onCreateTask: (index: number, selectedText?: string) => void;
+}
+
+export function ElementContextMenu({
+ x,
+ y,
+ element,
+ elementIndex,
+ selectedText,
+ onClose,
+ onFocus,
+ onDelete,
+ onDuplicate,
+ onConvert,
+ onGenerate,
+ onCreateTask,
+}: ElementContextMenuProps) {
+ const menuRef = useRef<HTMLDivElement>(null);
+ const [activeSubmenu, setActiveSubmenu] = useState<"generate" | "convert" | null>(null);
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onClose]);
+
+ // Adjust position if menu would overflow viewport
+ useEffect(() => {
+ if (menuRef.current) {
+ const rect = menuRef.current.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ if (rect.right > viewportWidth) {
+ menuRef.current.style.left = `${x - rect.width}px`;
+ }
+ if (rect.bottom > viewportHeight) {
+ menuRef.current.style.top = `${y - rect.height}px`;
+ }
+ }
+ }, [x, y]);
+
+ const getElementTypeLabel = () => {
+ switch (element.type) {
+ case "heading":
+ return `Heading ${element.level}`;
+ case "paragraph":
+ return "Paragraph";
+ case "code":
+ return element.language ? `Code (${element.language})` : "Code";
+ case "list":
+ return element.ordered ? "Ordered List" : "Bullet List";
+ case "chart":
+ return `Chart (${element.chartType})`;
+ case "image":
+ return "Image";
+ default:
+ return "Element";
+ }
+ };
+
+ const menuItemClass =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
+ const submenuTriggerClass =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center justify-between";
+ const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1";
+
+ return (
+ <div
+ ref={menuRef}
+ className="fixed z-50 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
+ style={{ left: x, top: y }}
+ >
+ {/* Header showing element type */}
+ <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)]">
+ {getElementTypeLabel()} [{elementIndex}]
+ </div>
+
+ {/* Focus action */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onFocus(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">&gt;</span>
+ Focus on this element
+ </button>
+
+ {/* Create task action */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onCreateTask(elementIndex, selectedText);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">@</span>
+ {selectedText ? "Create task from selection" : "Create task from this"}
+ </button>
+
+ <div className={dividerClass} />
+
+ {/* Generate submenu */}
+ <div
+ className="relative"
+ onMouseEnter={() => setActiveSubmenu("generate")}
+ onMouseLeave={() => setActiveSubmenu(null)}
+ >
+ <button className={submenuTriggerClass}>
+ <span className="flex items-center gap-2">
+ <span className="text-[#75aafc]">+</span>
+ Generate from this
+ </span>
+ <span className="text-[#555]">&rsaquo;</span>
+ </button>
+
+ {activeSubmenu === "generate" && (
+ <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[160px]">
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "elaborate");
+ onClose();
+ }}
+ >
+ Elaborate/Expand
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "summarize");
+ onClose();
+ }}
+ >
+ Summarize
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "extract_actions");
+ onClose();
+ }}
+ >
+ Extract action items
+ </button>
+ </div>
+ )}
+ </div>
+
+ {/* Convert submenu */}
+ <div
+ className="relative"
+ onMouseEnter={() => setActiveSubmenu("convert")}
+ onMouseLeave={() => setActiveSubmenu(null)}
+ >
+ <button className={submenuTriggerClass}>
+ <span className="flex items-center gap-2">
+ <span className="text-[#75aafc]">~</span>
+ Convert to...
+ </span>
+ <span className="text-[#555]">&rsaquo;</span>
+ </button>
+
+ {activeSubmenu === "convert" && (
+ <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[140px]">
+ {element.type !== "paragraph" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "paragraph");
+ onClose();
+ }}
+ >
+ Paragraph
+ </button>
+ )}
+ {element.type !== "list" && (
+ <>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "list_unordered");
+ onClose();
+ }}
+ >
+ Bullet List
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "list_ordered");
+ onClose();
+ }}
+ >
+ Numbered List
+ </button>
+ </>
+ )}
+ {element.type !== "code" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "code");
+ onClose();
+ }}
+ >
+ Code Block
+ </button>
+ )}
+ {element.type !== "heading" && (
+ <>
+ <div className={dividerClass} />
+ {[1, 2, 3, 4, 5, 6].map((level) => (
+ <button
+ key={level}
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, `heading_${level}`);
+ onClose();
+ }}
+ >
+ Heading {level}
+ </button>
+ ))}
+ </>
+ )}
+ </div>
+ )}
+ </div>
+
+ <div className={dividerClass} />
+
+ {/* Duplicate */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onDuplicate(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">++</span>
+ Duplicate
+ </button>
+
+ {/* Delete */}
+ <button
+ className={`${menuItemClass} text-red-400 hover:bg-red-400/10`}
+ onClick={() => {
+ onDelete(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-red-400">x</span>
+ Delete
+ </button>
+ </div>
+ );
+}