mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
feat(backup): add backup.json for feature tracking and status updates
- Introduced a new `backup.json` file to track feature statuses, descriptions, and summaries for better project management. - Updated `.automaker/feature_list.json` to reflect verified statuses for several features, ensuring accurate representation of progress. - Enhanced `memory.md` with details on drag-and-drop functionality for features in `waiting_approval` status. - Improved auto mode service to allow running tasks to complete when auto mode is stopped, enhancing user experience.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "./input";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
@@ -29,6 +30,7 @@ export function CategoryAutocomplete({
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
@@ -52,12 +54,39 @@ export function CategoryAutocomplete({
|
||||
setHighlightedIndex(-1);
|
||||
}, [inputValue, suggestions]);
|
||||
|
||||
// Update dropdown position when open and handle scroll/resize
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
if (isOpen && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
!containerRef.current.contains(event.target as Node) &&
|
||||
listRef.current &&
|
||||
!listRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -175,40 +204,47 @@ export function CategoryAutocomplete({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && filteredSuggestions.length > 0 && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
role="listbox"
|
||||
data-testid="category-autocomplete-list"
|
||||
>
|
||||
{filteredSuggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={suggestion}
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
||||
highlightedIndex === index && "bg-accent text-accent-foreground",
|
||||
inputValue === suggestion && "font-medium"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{inputValue === suggestion && (
|
||||
<Check className="mr-2 h-4 w-4 text-primary" />
|
||||
)}
|
||||
<span className={cn(inputValue !== suggestion && "ml-6")}>
|
||||
{suggestion}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isOpen && filteredSuggestions.length > 0 && typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="fixed z-[9999] max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
role="listbox"
|
||||
data-testid="category-autocomplete-list"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{filteredSuggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={suggestion}
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
||||
highlightedIndex === index && "bg-accent text-accent-foreground",
|
||||
inputValue === suggestion && "font-medium"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{inputValue === suggestion && (
|
||||
<Check className="mr-2 h-4 w-4 text-primary" />
|
||||
)}
|
||||
<span className={cn(inputValue !== suggestion && "ml-6")}>
|
||||
{suggestion}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ interface DescriptionImageDropZoneProps {
|
||||
previewMap?: ImagePreviewMap;
|
||||
onPreviewMapChange?: (map: ImagePreviewMap) => void;
|
||||
autoFocus?: boolean;
|
||||
error?: boolean; // Show error state with red border
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
@@ -55,6 +56,7 @@ export function DescriptionImageDropZone({
|
||||
previewMap,
|
||||
onPreviewMapChange,
|
||||
autoFocus = false,
|
||||
error = false,
|
||||
}: DescriptionImageDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -306,6 +308,7 @@ export function DescriptionImageDropZone({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
aria-invalid={error}
|
||||
className={cn(
|
||||
"min-h-[120px]",
|
||||
isProcessing && "opacity-50 pointer-events-none"
|
||||
|
||||
296
app/src/components/ui/hotkey-button.tsx
Normal file
296
app/src/components/ui/hotkey-button.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Button, buttonVariants } from "./button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
export interface HotkeyConfig {
|
||||
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
|
||||
key: string;
|
||||
/** Whether the Cmd/Ctrl modifier is required */
|
||||
cmdCtrl?: boolean;
|
||||
/** Whether the Shift modifier is required */
|
||||
shift?: boolean;
|
||||
/** Whether the Alt/Option modifier is required */
|
||||
alt?: boolean;
|
||||
/** Custom display label for the hotkey (overrides auto-generated label) */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface HotkeyButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
/** Hotkey configuration - can be a simple key string or a full config object */
|
||||
hotkey?: string | HotkeyConfig;
|
||||
/** Whether to show the hotkey indicator badge */
|
||||
showHotkeyIndicator?: boolean;
|
||||
/** Whether the hotkey listener is active (registers keyboard listener). Set to false if hotkey is already handled elsewhere. */
|
||||
hotkeyActive?: boolean;
|
||||
/** Optional scope element ref - hotkey will only work when this element is visible */
|
||||
scopeRef?: React.RefObject<HTMLElement | null>;
|
||||
/** Callback when hotkey is triggered */
|
||||
onHotkeyTrigger?: () => void;
|
||||
/** Whether to use the Slot component for composition */
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modifier key symbol based on platform
|
||||
*/
|
||||
function getModifierSymbol(isMac: boolean): string {
|
||||
return isMac ? "⌘" : "Ctrl";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hotkey config into a normalized format
|
||||
*/
|
||||
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
|
||||
if (typeof hotkey === "string") {
|
||||
return { key: hotkey };
|
||||
}
|
||||
return hotkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the display label for the hotkey
|
||||
*/
|
||||
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
|
||||
if (config.label) {
|
||||
return config.label;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
|
||||
if (config.cmdCtrl) {
|
||||
parts.push(
|
||||
<span key="mod" className="leading-none flex items-center justify-center">
|
||||
{getModifierSymbol(isMac)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.shift) {
|
||||
parts.push(
|
||||
<span key="shift" className="leading-none flex items-center justify-center">
|
||||
⇧
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.alt) {
|
||||
parts.push(
|
||||
<span key="alt" className="leading-none flex items-center justify-center">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Convert key to display format
|
||||
let keyDisplay = config.key;
|
||||
switch (config.key.toLowerCase()) {
|
||||
case "enter":
|
||||
keyDisplay = "↵";
|
||||
break;
|
||||
case "escape":
|
||||
case "esc":
|
||||
keyDisplay = "Esc";
|
||||
break;
|
||||
case "arrowup":
|
||||
keyDisplay = "↑";
|
||||
break;
|
||||
case "arrowdown":
|
||||
keyDisplay = "↓";
|
||||
break;
|
||||
case "arrowleft":
|
||||
keyDisplay = "←";
|
||||
break;
|
||||
case "arrowright":
|
||||
keyDisplay = "→";
|
||||
break;
|
||||
case "backspace":
|
||||
keyDisplay = "⌫";
|
||||
break;
|
||||
case "delete":
|
||||
keyDisplay = "⌦";
|
||||
break;
|
||||
case "tab":
|
||||
keyDisplay = "⇥";
|
||||
break;
|
||||
case " ":
|
||||
keyDisplay = "Space";
|
||||
break;
|
||||
default:
|
||||
// Capitalize single letters
|
||||
if (config.key.length === 1) {
|
||||
keyDisplay = config.key.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<span key="key" className="leading-none flex items-center justify-center">
|
||||
{keyDisplay}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is a form input
|
||||
*/
|
||||
function isInputElement(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.getAttribute("contenteditable") === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const role = element.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button component that supports keyboard hotkeys
|
||||
*
|
||||
* Features:
|
||||
* - Automatic hotkey listening when mounted
|
||||
* - Visual hotkey indicator badge
|
||||
* - Support for modifier keys (Cmd/Ctrl, Shift, Alt)
|
||||
* - Respects focus context (doesn't trigger when typing in inputs)
|
||||
* - Scoped activation via scopeRef
|
||||
*/
|
||||
export function HotkeyButton({
|
||||
hotkey,
|
||||
showHotkeyIndicator = true,
|
||||
hotkeyActive = true,
|
||||
scopeRef,
|
||||
onHotkeyTrigger,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: HotkeyButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isMac, setIsMac] = React.useState(true);
|
||||
|
||||
// Detect platform on mount
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toLowerCase().includes("mac"));
|
||||
}, []);
|
||||
|
||||
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!config || !hotkeyActive || disabled) return;
|
||||
|
||||
// Don't trigger when typing in inputs (unless explicitly scoped)
|
||||
if (!scopeRef && isInputElement(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check modifier keys
|
||||
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||
const shiftPressed = event.shiftKey;
|
||||
const altPressed = event.altKey;
|
||||
|
||||
// Validate modifier requirements
|
||||
if (config.cmdCtrl && !cmdCtrlPressed) return;
|
||||
if (!config.cmdCtrl && cmdCtrlPressed) return;
|
||||
if (config.shift && !shiftPressed) return;
|
||||
if (!config.shift && shiftPressed) return;
|
||||
if (config.alt && !altPressed) return;
|
||||
if (!config.alt && altPressed) return;
|
||||
|
||||
// Check if the key matches
|
||||
if (event.key.toLowerCase() !== config.key.toLowerCase()) return;
|
||||
|
||||
// If scoped, check that the scope element is visible
|
||||
if (scopeRef && scopeRef.current) {
|
||||
const scopeEl = scopeRef.current;
|
||||
const isVisible = scopeEl.offsetParent !== null ||
|
||||
getComputedStyle(scopeEl).display !== "none";
|
||||
if (!isVisible) return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Trigger the click handler or custom onHotkeyTrigger
|
||||
if (onHotkeyTrigger) {
|
||||
onHotkeyTrigger();
|
||||
} else if (onClick) {
|
||||
onClick(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
||||
} else if (buttonRef.current) {
|
||||
buttonRef.current.click();
|
||||
}
|
||||
},
|
||||
[config, hotkeyActive, disabled, scopeRef, onHotkeyTrigger, onClick]
|
||||
);
|
||||
|
||||
// Set up global key listener
|
||||
useEffect(() => {
|
||||
if (!config || !hotkeyActive) return;
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [config, hotkeyActive, handleKeyDown]);
|
||||
|
||||
// Render the hotkey indicator
|
||||
const hotkeyIndicator = config && showHotkeyIndicator ? (
|
||||
<span
|
||||
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
|
||||
data-testid="hotkey-indicator"
|
||||
>
|
||||
{getHotkeyDisplayLabel(config, isMac)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(className)}
|
||||
asChild={asChild}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { getHotkeyDisplayLabel, parseHotkeyConfig };
|
||||
@@ -12,6 +12,7 @@ interface ImageDropZoneProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
images?: ImageAttachment[]; // Optional controlled images prop
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
@@ -24,12 +25,24 @@ export function ImageDropZone({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
images,
|
||||
}: ImageDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use controlled images if provided, otherwise use internal state
|
||||
const selectedImages = images ?? internalImages;
|
||||
|
||||
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
|
||||
const updateImages = useCallback((newImages: ImageAttachment[]) => {
|
||||
if (images === undefined) {
|
||||
setInternalImages(newImages);
|
||||
}
|
||||
onImagesSelected(newImages);
|
||||
}, [images, onImagesSelected]);
|
||||
|
||||
const processFiles = useCallback(async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
|
||||
@@ -79,12 +92,11 @@ export function ImageDropZone({
|
||||
|
||||
if (newImages.length > 0) {
|
||||
const allImages = [...selectedImages, ...newImages];
|
||||
setSelectedImages(allImages);
|
||||
onImagesSelected(allImages);
|
||||
updateImages(allImages);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, onImagesSelected]);
|
||||
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -132,14 +144,12 @@ export function ImageDropZone({
|
||||
|
||||
const removeImage = useCallback((imageId: string) => {
|
||||
const updated = selectedImages.filter(img => img.id !== imageId);
|
||||
setSelectedImages(updated);
|
||||
onImagesSelected(updated);
|
||||
}, [selectedImages, onImagesSelected]);
|
||||
updateImages(updated);
|
||||
}, [selectedImages, updateImages]);
|
||||
|
||||
const clearAllImages = useCallback(() => {
|
||||
setSelectedImages([]);
|
||||
onImagesSelected([]);
|
||||
}, [onImagesSelected]);
|
||||
updateImages([]);
|
||||
}, [updateImages]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
|
||||
@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
290
app/src/components/ui/xml-syntax-editor.tsx
Normal file
290
app/src/components/ui/xml-syntax-editor.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
// Tokenize XML content into parts for highlighting
|
||||
interface Token {
|
||||
type:
|
||||
| "tag-bracket"
|
||||
| "tag-name"
|
||||
| "attribute-name"
|
||||
| "attribute-equals"
|
||||
| "attribute-value"
|
||||
| "text"
|
||||
| "comment"
|
||||
| "cdata"
|
||||
| "doctype";
|
||||
value: string;
|
||||
}
|
||||
|
||||
function tokenizeXml(text: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
// Comment: <!-- ... -->
|
||||
if (text.slice(i, i + 4) === "<!--") {
|
||||
const end = text.indexOf("-->", i + 4);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// CDATA: <![CDATA[ ... ]]>
|
||||
if (text.slice(i, i + 9) === "<![CDATA[") {
|
||||
const end = text.indexOf("]]>", i + 9);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// DOCTYPE: <!DOCTYPE ... >
|
||||
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
|
||||
const end = text.indexOf(">", i + 9);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Tag: < ... >
|
||||
if (text[i] === "<") {
|
||||
// Find the end of the tag
|
||||
let tagEnd = i + 1;
|
||||
let inString: string | null = null;
|
||||
|
||||
while (tagEnd < text.length) {
|
||||
const char = text[tagEnd];
|
||||
|
||||
if (inString) {
|
||||
if (char === inString && text[tagEnd - 1] !== "\\") {
|
||||
inString = null;
|
||||
}
|
||||
} else {
|
||||
if (char === '"' || char === "'") {
|
||||
inString = char;
|
||||
} else if (char === ">") {
|
||||
tagEnd++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
tagEnd++;
|
||||
}
|
||||
|
||||
const tagContent = text.slice(i, tagEnd);
|
||||
const tagTokens = tokenizeTag(tagContent);
|
||||
tokens.push(...tagTokens);
|
||||
i = tagEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text content between tags
|
||||
const nextTag = text.indexOf("<", i);
|
||||
if (nextTag === -1) {
|
||||
tokens.push({ type: "text", value: text.slice(i) });
|
||||
break;
|
||||
} else if (nextTag > i) {
|
||||
tokens.push({ type: "text", value: text.slice(i, nextTag) });
|
||||
i = nextTag;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function tokenizeTag(tag: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
// Opening bracket (< or </ or <?)
|
||||
if (tag.startsWith("</")) {
|
||||
tokens.push({ type: "tag-bracket", value: "</" });
|
||||
i = 2;
|
||||
} else if (tag.startsWith("<?")) {
|
||||
tokens.push({ type: "tag-bracket", value: "<?" });
|
||||
i = 2;
|
||||
} else {
|
||||
tokens.push({ type: "tag-bracket", value: "<" });
|
||||
i = 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Tag name
|
||||
let tagName = "";
|
||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
||||
tagName += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (tagName) {
|
||||
tokens.push({ type: "tag-name", value: tagName });
|
||||
}
|
||||
|
||||
// Attributes and closing
|
||||
while (i < tag.length) {
|
||||
// Skip whitespace
|
||||
if (/\s/.test(tag[i])) {
|
||||
let ws = "";
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
ws += tag[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "text", value: ws });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Closing bracket
|
||||
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
|
||||
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
|
||||
break;
|
||||
}
|
||||
|
||||
// Attribute name
|
||||
let attrName = "";
|
||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
||||
attrName += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (attrName) {
|
||||
tokens.push({ type: "attribute-name", value: attrName });
|
||||
}
|
||||
|
||||
// Skip whitespace around =
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Equals sign
|
||||
if (tag[i] === "=") {
|
||||
tokens.push({ type: "attribute-equals", value: "=" });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Skip whitespace after =
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Attribute value
|
||||
if (tag[i] === '"' || tag[i] === "'") {
|
||||
const quote = tag[i];
|
||||
let value = quote;
|
||||
i++;
|
||||
while (i < tag.length && tag[i] !== quote) {
|
||||
value += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (i < tag.length) {
|
||||
value += tag[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "attribute-value", value });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: XmlSyntaxEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync scroll between textarea and highlight layer
|
||||
const handleScroll = useCallback(() => {
|
||||
if (textareaRef.current && highlightRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle tab key for indentation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newValue =
|
||||
value.substring(0, start) + " " + value.substring(end);
|
||||
onChange(newValue);
|
||||
// Reset cursor position after state update
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
// Memoize the highlighted content
|
||||
const highlightedContent = useMemo(() => {
|
||||
const tokens = tokenizeXml(value);
|
||||
|
||||
return tokens.map((token, index) => {
|
||||
const className = `xml-${token.type}`;
|
||||
// React handles escaping automatically, just render the raw value
|
||||
return (
|
||||
<span key={index} className={className}>
|
||||
{token.value}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full h-full xml-editor", className)}>
|
||||
{/* Syntax highlighted layer (read-only, behind textarea) */}
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{value ? (
|
||||
<code className="xml-highlight">{highlightedContent}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground opacity-50">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actual textarea (transparent text, handles input) */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
spellCheck={false}
|
||||
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user