"添加前端模板和运行代码模块"

This commit is contained in:
Codex
2026-06-01 13:43:09 +08:00
parent 24045274ad
commit 8092c4b1f8
587 changed files with 99761 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
import { UpdateState, UpdateResult, UpdateOperation, UpdateManagerConfig } from '../types/UpdateTypes';
import { SourceInfo } from '@xagi/design-mode-shared/messages';
import { extractSourceInfo, hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
import { resolveSourceInfo } from '@xagi/design-mode-shared/sourceInfoResolver';
export class EditManager {
constructor(
private processUpdate: (update: UpdateState) => Promise<UpdateResult>,
private config: UpdateManagerConfig
) { }
/**
* Handle direct edit (double click or mutation)
*/
public handleDirectEdit(element: HTMLElement, type: 'content' | 'style') {
if (type === 'content') {
this.editTextContent(element);
} else {
this.editStyle(element);
}
}
/**
* Edit text content using contentEditable
*/
public async editTextContent(element: HTMLElement) {
const sourceInfo = resolveSourceInfo(element);
if (!sourceInfo) return;
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
if (!hasStaticContent) {
console.warn('[EditManager] Cannot edit: element does not have static-content attribute. This might be a component definition, not a usage site.');
return;
}
const originalText = element.innerText;
const originalContentEditable = element.contentEditable;
element.contentEditable = 'true';
element.setAttribute('data-ignore-mutation', 'true');
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const handleSave = () => {
const newText = element.innerText.trim();
element.contentEditable = 'false';
element.removeAttribute('data-ignore-mutation');
element.removeEventListener('blur', handleSave);
element.removeEventListener('input', handleInput);
element.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside, true);
if (newText !== originalText.trim()) {
element.innerText = newText;
const relatedElements = this.findAllElementsWithSameSource(element, sourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.innerText = newText;
}
});
this.notifyContentChanged(element, newText, sourceInfo, originalText);
}
};
const handleCancel = () => {
element.innerText = originalText;
element.contentEditable = 'false';
element.removeAttribute('data-ignore-mutation');
element.removeEventListener('blur', handleSave);
element.removeEventListener('input', handleInput);
element.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside, true);
};
const handleClickOutside = (e: MouseEvent) => {
if (!element.contains(e.target as Node)) {
handleSave();
}
};
const handleInput = () => {
const currentText = element.innerText.trim();
const relatedElements = this.findAllElementsWithSameSource(element, sourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.innerText = currentText;
}
});
this.notifyContentChangedRealtime(element, currentText, sourceInfo, originalText);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.ctrlKey || e.metaKey) {
return;
} else {
e.preventDefault();
element.blur();
}
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
element.addEventListener('blur', handleSave);
element.addEventListener('input', handleInput);
element.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside, true);
element.focus();
}
/**
* Update content
* @param element Target DOM node
* @param newValue New text
* @param sourceInfo Optional; extracted from element when omitted
* @param oldValue Optional; uses current innerText when omitted
*/
public async updateContent(
element: HTMLElement,
newValue: string,
sourceInfo?: SourceInfo,
oldValue?: string
): Promise<UpdateResult> {
const finalSourceInfo = sourceInfo || extractSourceInfo(element);
if (!finalSourceInfo) {
throw new Error('Cannot update content: no source info available');
}
const finalOldValue = oldValue ?? element.innerText;
const update: UpdateState = {
id: this.generateUpdateId(),
operation: 'content_update',
sourceInfo: finalSourceInfo,
element,
oldValue: finalOldValue,
newValue,
status: 'pending',
timestamp: Date.now(),
retryCount: 0,
persist: false,
};
const relatedElements = this.findAllElementsWithSameSource(element, finalSourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.innerText = newValue;
}
});
return this.processUpdate(update);
}
/**
* Update style (class)
*/
public async updateStyle(
element: HTMLElement,
newClass: string,
sourceInfo: SourceInfo
): Promise<UpdateResult> {
const oldClass = element.className;
const finalSourceInfo = sourceInfo || extractSourceInfo(element);
if (!finalSourceInfo) {
throw new Error('Cannot update style: no source info available');
}
const update: UpdateState = {
id: this.generateUpdateId(),
operation: 'class_update',
sourceInfo,
element,
oldValue: oldClass,
newValue: newClass,
status: 'pending',
timestamp: Date.now(),
retryCount: 0,
persist: false,
};
const relatedElements = this.findAllElementsWithSameSource(element, finalSourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.className = newClass;
}
});
return this.processUpdate(update);
}
/**
* Edit style (trigger UI)
*/
public editStyle(element: HTMLElement) {
}
/**
* Update attribute
*/
public async updateAttribute(
element: HTMLElement,
attributeName: string,
newValue: string,
sourceInfo: SourceInfo
): Promise<UpdateResult> {
const oldValue = element.getAttribute(attributeName) || '';
const update: UpdateState = {
id: this.generateUpdateId(),
operation: 'attribute_update',
sourceInfo,
element,
oldValue,
newValue,
status: 'pending',
timestamp: Date.now(),
retryCount: 0,
persist: false,
};
const relatedElements = this.findAllElementsWithSameSource(element, sourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.setAttribute(attributeName, newValue);
}
});
return this.processUpdate(update);
}
/**
* Edit attributes (trigger UI)
*/
public editAttributes(element: HTMLElement) {
}
private generateUpdateId(): string {
return `update-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Notify parent of final text (iframe only); does not write source files.
*/
private notifyContentChanged(
element: HTMLElement,
newValue: string,
sourceInfo?: SourceInfo,
oldValue?: string
): void {
const finalSourceInfo = sourceInfo || resolveSourceInfo(element);
if (!finalSourceInfo) {
console.warn('[EditManager] Cannot notify: no source info available');
return;
}
if (window.self !== window.top) {
window.parent.postMessage({
type: 'CONTENT_UPDATED',
payload: {
sourceInfo: finalSourceInfo,
oldValue: oldValue || '',
newValue: newValue,
},
timestamp: Date.now(),
}, '*');
}
}
private lastRealtimeNotify: number = 0;
private readonly REALTIME_THROTTLE_MS = 300;
/**
* Throttled CONTENT_UPDATED while typing (realtime: true).
*/
private notifyContentChangedRealtime(
element: HTMLElement,
newValue: string,
sourceInfo?: SourceInfo,
oldValue?: string
): void {
const now = Date.now();
if (now - this.lastRealtimeNotify < this.REALTIME_THROTTLE_MS) {
return;
}
this.lastRealtimeNotify = now;
const finalSourceInfo = sourceInfo || resolveSourceInfo(element);
if (!finalSourceInfo) {
return;
}
if (window.self !== window.top) {
window.parent.postMessage({
type: 'CONTENT_UPDATED',
payload: {
sourceInfo: finalSourceInfo,
oldValue: oldValue || '',
newValue: newValue,
realtime: true,
},
timestamp: Date.now(),
}, '*');
}
}
/**
* List peers that share the same logical instance (lists): same `element-id`,
* same static-content presence, same mapped file.
*
* @param sourceInfo Deprecated; kept for signature compatibility
*/
private findAllElementsWithSameSource(element: HTMLElement, sourceInfo?: SourceInfo): HTMLElement[] {
const elementId = element.getAttribute(AttributeNames.elementId);
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
const refSourceInfo = extractSourceInfo(element);
const refFileName = refSourceInfo?.fileName;
if (!elementId) {
console.warn('[EditManager] Element missing element-id attribute:', element);
return [element];
}
const allElementsWithId = Array.from(
document.querySelectorAll(`[${AttributeNames.elementId}]`)
) as HTMLElement[];
return allElementsWithId.filter(el => {
const elId = el.getAttribute(AttributeNames.elementId);
const elHasStaticContent = el.hasAttribute(AttributeNames.staticContent);
const elSourceInfo = extractSourceInfo(el);
const elFileName = elSourceInfo?.fileName;
if (elId !== elementId) return false;
if (elHasStaticContent !== hasStaticContent) return false;
if (elFileName !== refFileName) return false;
return true;
});
}
}

View File

@@ -0,0 +1,80 @@
import { hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
export class ObserverManager {
private observer: MutationObserver | null = null;
constructor(
private onEdit: (target: HTMLElement, type: 'content' | 'style') => void,
private onNodeAdded: (node: HTMLElement) => void
) {}
public enable() {
if (this.observer) return;
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
// Check if the mutation should be ignored
const targetNode = mutation.target;
const targetElement = targetNode.nodeType === Node.ELEMENT_NODE
? targetNode as HTMLElement
: targetNode.parentElement;
if (targetElement && targetElement.hasAttribute('data-ignore-mutation')) {
return;
}
if (mutation.type === 'childList') {
// Handle element addition
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.onNodeAdded(node as HTMLElement);
}
});
} else if (mutation.type === 'characterData') {
// Handle text content change
const target = mutation.target.parentElement as HTMLElement;
if (target && hasSourceMapping(target)) {
if (target.hasAttribute('data-ignore-mutation')) {
return;
}
this.onEdit(target, 'content');
}
} else if (mutation.type === 'attributes') {
// Handle attribute change (style, class)
const target = mutation.target as HTMLElement;
if (target && hasSourceMapping(target)) {
const attributeName = mutation.attributeName;
const newValue = target.getAttribute(attributeName!);
const oldValue = mutation.oldValue;
if (newValue === oldValue) {
return;
}
if (attributeName === 'class') {
this.onEdit(target, 'style');
} else if (attributeName?.startsWith('style')) {
this.onEdit(target, 'style');
}
}
}
});
});
this.observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeOldValue: true,
attributeFilter: ['class', 'style'],
});
}
public disable() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}