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
220 lines
10 KiB
JavaScript
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)] }));
|
|
}
|