ui-dev-content/dist/components/EditableHighlight.js
autocommit 8b284e01b9 chore: initial package split from monorepo
Package: @lilith/ui-dev-content
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:45 -07:00

220 lines
10 KiB
JavaScript

import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/**
* EditableHighlight - Single editable content highlight
*
* Renders a dashed border overlay for one editable element.
* Supports both manual text editing and AI-powered transformers.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import styled, { keyframes } from '@lilith/ui-styled-components';
import { Button } from '@lilith/ui-primitives';
import { EditIcon } from '@lilith/ui-icons';
import { ZINDEX_LAYERS } from '@lilith/ui-zname';
import { TransformerModal } from './TransformerModal';
import { TransformerPicker } from './TransformerPicker';
import { TextEditorModal } from './TextEditorModal';
import { contentEditingRegistry } from '../core/ContentEditingRegistry';
import { useOperationQueue } from '../core/OperationQueue';
import { MANUAL_EDIT_TRANSFORMER_ID } from '../transformers/ManualTextEditTransformer';
// ============================================================================
// Animations
// ============================================================================
const fadeIn = keyframes `
from { opacity: 0; }
to { opacity: 1; }
`;
const HighlightBox = styled.div `
position: fixed;
pointer-events: none; /* Don't block clicks - let them pass through to navigation */
border: 2px dashed ${props => props.$isHovered ? '#0af' : 'transparent'};
border-radius: 4px;
transition: border-color 0.2s ease;
animation: ${fadeIn} 0.2s ease;
background: ${props => props.$isHovered ? 'rgba(0, 170, 255, 0.05)' : 'transparent'};
z-index: ${ZINDEX_LAYERS.system}; /* Below toggle button but above app content/modals */
`;
const EditButtonWrapper = styled.div `
position: absolute;
top: -8px;
right: -8px;
pointer-events: auto; /* Only the edit button receives clicks */
`;
const ContentLabel = styled.div `
position: absolute;
bottom: -20px;
left: 0;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 10px;
font-family: monospace;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
`;
/**
* Renders a highlight overlay for a single editable element.
* Uses pointer-events: none to allow clicks to pass through,
* with only the Edit button receiving pointer events.
*/
export function EditableHighlight({ handle, isHovered, }) {
const [bounds, setBounds] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [textEditorOpen, setTextEditorOpen] = useState(false);
const [selectedTransformer, setSelectedTransformer] = useState(null);
const [currentContent, setCurrentContent] = useState('');
const [showPicker, setShowPicker] = useState(false);
const [pickerPosition, setPickerPosition] = useState({ x: 0, y: 0 });
const [availableTransformers, setAvailableTransformers] = useState([]);
// Use operation queue for concurrent transformer execution
const { queueOperation, updateOperation, completeOperation, failOperation, getOperation } = useOperationQueue();
const [currentOperationId, setCurrentOperationId] = useState(null);
// Update bounds when element changes or on scroll
useEffect(() => {
const updateBounds = () => {
if (handle.element) {
setBounds(handle.element.getBoundingClientRect());
}
};
updateBounds();
// Update on scroll and resize
window.addEventListener('scroll', updateBounds, true);
window.addEventListener('resize', updateBounds);
return () => {
window.removeEventListener('scroll', updateBounds, true);
window.removeEventListener('resize', updateBounds);
};
}, [handle.element]);
if (!bounds) {
return null;
}
/**
* Queue and run a transformer operation (non-blocking)
*/
const queueAndRunTransformer = async (transformer) => {
try {
// Queue the operation (creates toast automatically)
const opId = await queueOperation(handle, transformer);
setCurrentOperationId(opId);
// Get the source to read current content
const source = contentEditingRegistry.getSource(handle.sourceId);
if (!source) {
failOperation(opId, 'Source not found');
return;
}
// Update progress: 10% (content loading)
updateOperation(opId, { progress: 10, status: 'running' });
// Read current content
const content = await source.read(handle);
const contentString = typeof content === 'string' ? content : JSON.stringify(content);
// Update progress: 20% (content loaded)
updateOperation(opId, { progress: 20 });
// Create transform context with progress callback
const context = {
handle,
metadata: {},
onProgress: (progress) => {
// Map transformer progress (0-100) to our operation progress (20-90)
const mappedProgress = 20 + (progress * 0.7);
updateOperation(opId, { progress: mappedProgress });
},
};
// Run transformer
const result = await transformer.transform(contentString, context);
// Update progress: 90% (transform complete)
updateOperation(opId, { progress: 90 });
// Complete operation
completeOperation(opId, result);
// If successful and has changes, setup click handler to open modal
if (result.success && result.changes && result.changes.length > 0) {
// Setup toast click handler to open modal with results
// Note: This requires the toast onClick to be set via updateOperation
// which we'll handle in a moment
}
}
catch (error) {
const opId = currentOperationId;
if (opId) {
failOperation(opId, error instanceof Error ? error.message : 'Unknown error');
}
}
};
const handleClick = async (e) => {
e.stopPropagation();
try {
// Get the source to read current content (just to get transformers)
const source = contentEditingRegistry.getSource(handle.sourceId);
if (!source) {
console.error('Source not found:', handle.sourceId);
return;
}
// Read current content to determine available transformers
const content = await source.read(handle);
const contentString = typeof content === 'string' ? content : JSON.stringify(content);
setCurrentContent(contentString);
// Get available transformers for this content type
const transformers = contentEditingRegistry.getTransformers(handle, contentString);
if (transformers.length === 0) {
// No transformers - open manual edit directly
setTextEditorOpen(true);
return;
}
if (transformers.length === 1) {
const transformer = transformers[0];
// Check if it's the manual edit transformer
if (transformer.id === MANUAL_EDIT_TRANSFORMER_ID) {
setTextEditorOpen(true);
}
else {
// Single AI transformer - queue operation directly (non-blocking)
await queueAndRunTransformer(transformer);
}
}
else {
// Multiple transformers - show picker context menu
setAvailableTransformers(transformers);
setPickerPosition({ x: e.clientX, y: e.clientY });
setShowPicker(true);
}
}
catch (error) {
console.error('Failed to open editor:', error);
}
};
const handleTransformerSelect = async (transformer) => {
setShowPicker(false);
// Check if manual edit was selected
if (transformer.id === MANUAL_EDIT_TRANSFORMER_ID) {
setTextEditorOpen(true);
return;
}
// Queue AI transformer operation (non-blocking)
await queueAndRunTransformer(transformer);
};
const handleApply = async (transformedContent) => {
try {
// Get sink for this content type
const sink = contentEditingRegistry.getSink(handle);
if (!sink) {
throw new Error(`No sink found for ${handle.sourceId}:${handle.identifier}`);
}
// Write transformed content
await sink.write(handle, transformedContent);
// Close modal on success
setModalOpen(false);
// Optionally refresh the page or update UI
console.log('Content updated successfully');
}
catch (error) {
console.error('Failed to apply changes:', error);
throw error; // Re-throw to let TransformerModal handle the error display
}
};
return (_jsxs(_Fragment, { children: [createPortal(_jsx(HighlightBox, { "$isHovered": isHovered, style: {
top: `${bounds.top}px`,
left: `${bounds.left}px`,
width: `${bounds.width}px`,
height: `${bounds.height}px`,
}, children: isHovered && (_jsxs(_Fragment, { children: [_jsx(EditButtonWrapper, { children: _jsx(Button, { "data-testid": "edit-button", variant: "primary", size: "sm", icon: _jsx(EditIcon, { size: 14 }), onClick: handleClick, children: "Edit" }) }), _jsxs(ContentLabel, { children: [handle.sourceId, ":", handle.identifier] })] })) }), document.body), modalOpen && selectedTransformer && createPortal(_jsx(TransformerModal, { isOpen: modalOpen, onClose: () => setModalOpen(false), handle: handle, transformer: selectedTransformer, onApply: handleApply }), document.body), showPicker && createPortal(_jsx(TransformerPicker, { transformers: availableTransformers, position: pickerPosition, onSelect: handleTransformerSelect, onClose: () => setShowPicker(false) }), document.body), textEditorOpen && createPortal(_jsx(TextEditorModal, { isOpen: textEditorOpen, onClose: () => setTextEditorOpen(false), handle: handle, onSave: handleApply }), document.body)] }));
}