diff --git a/packages/editor/src/components/EditElementModal.tsx b/packages/editor/src/components/EditElementModal.tsx
new file mode 100644
index 00000000..7c797b97
--- /dev/null
+++ b/packages/editor/src/components/EditElementModal.tsx
@@ -0,0 +1,177 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT License.
+ */
+
+export interface EditElementModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: (content: string) => void;
+ initialContent: string;
+ elementLabel: string;
+}
+
+export function EditElementModal({ isOpen, onClose, onSave, initialContent, elementLabel }: EditElementModalProps) {
+ const [content, setContent] = React.useState(initialContent);
+
+ React.useEffect(() => {
+ setContent(initialContent);
+ }, [initialContent, isOpen]);
+
+ if (!isOpen) return null;
+
+ const handleSave = () => {
+ onSave(content);
+ onClose();
+ };
+
+ const handleCancel = () => {
+ setContent(initialContent);
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Modal Header */}
+
+
+ ✏️ Edit Element: {elementLabel}
+
+
+
+
+ {/* Modal Body */}
+
+
+
+ {/* Modal Footer */}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/editor/src/components/TreeView.tsx b/packages/editor/src/components/TreeView.tsx
new file mode 100644
index 00000000..c84fb887
--- /dev/null
+++ b/packages/editor/src/components/TreeView.tsx
@@ -0,0 +1,581 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT License.
+ */
+import { InteractiveDocument, InteractiveElement, PageElement } from '@microsoft/chartifact-schema';
+import { ElementType, ELEMENT_CONFIGS, ELEMENT_TYPES } from '../types/elementTypes.js';
+import { EditElementModal } from './EditElementModal.js';
+
+export interface TreeViewProps {
+ page: InteractiveDocument;
+ onPageChange: (page: InteractiveDocument) => void;
+}
+
+export interface ElementInsertMenuProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onInsert: (elementType: ElementType) => void;
+ position: { x: number; y: number };
+}
+
+function ElementInsertMenu({ isOpen, onClose, onInsert, position }: ElementInsertMenuProps) {
+ React.useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (isOpen) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('click', handleClickOutside as EventListener);
+ }
+
+ return () => {
+ document.removeEventListener('click', handleClickOutside as EventListener);
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+ e.stopPropagation()}
+ >
+
+ Insert Element
+
+ {ELEMENT_TYPES.map((elementConfig) => (
+
+ ))}
+
+ );
+}
+
+export function TreeView({ page, onPageChange }: TreeViewProps) {
+ const [insertMenu, setInsertMenu] = React.useState<{
+ isOpen: boolean;
+ position: { x: number; y: number };
+ groupIndex?: number;
+ elementIndex?: number;
+ }>({
+ isOpen: false,
+ position: { x: 0, y: 0 }
+ });
+
+ const [editModal, setEditModal] = React.useState<{
+ isOpen: boolean;
+ groupIndex?: number;
+ elementIndex?: number;
+ content: string;
+ label: string;
+ }>({
+ isOpen: false,
+ content: '',
+ label: ''
+ });
+
+ const deleteElement = (groupIndex: number, elementIndex: number) => {
+ const newPage = {
+ ...page,
+ groups: page.groups.map((group, gIdx) => {
+ if (gIdx === groupIndex) {
+ return {
+ ...group,
+ elements: group.elements.filter((_, eIdx) => eIdx !== elementIndex)
+ };
+ }
+ return group;
+ })
+ };
+ onPageChange(newPage);
+ };
+
+ const deleteGroup = (groupIndex: number) => {
+ const newPage = {
+ ...page,
+ groups: page.groups.filter((_, gIdx) => gIdx !== groupIndex)
+ };
+ onPageChange(newPage);
+ };
+
+ const addGroup = () => {
+ const newGroupId = `group_${Date.now()}`;
+ const newPage = {
+ ...page,
+ groups: [
+ ...page.groups,
+ {
+ groupId: newGroupId,
+ elements: []
+ }
+ ]
+ };
+ onPageChange(newPage);
+ };
+
+ const createElement = (elementType: ElementType): PageElement => {
+ switch (elementType) {
+ case ElementType.MARKDOWN:
+ return 'New text content here...';
+
+ case ElementType.TEXTBOX:
+ return {
+ type: ElementType.TEXTBOX,
+ variableId: `textbox_${Date.now()}`,
+ label: 'Text Input'
+ } as InteractiveElement;
+
+ case ElementType.NUMBER:
+ return {
+ type: ElementType.NUMBER,
+ variableId: `number_${Date.now()}`,
+ label: 'Number Input',
+ min: 0,
+ max: 100,
+ step: 1
+ } as InteractiveElement;
+
+ case ElementType.SLIDER:
+ return {
+ type: ElementType.SLIDER,
+ variableId: `slider_${Date.now()}`,
+ label: 'Slider',
+ min: 0,
+ max: 100,
+ step: 1
+ } as InteractiveElement;
+
+ case ElementType.CHECKBOX:
+ return {
+ type: ElementType.CHECKBOX,
+ variableId: `checkbox_${Date.now()}`,
+ label: 'Checkbox'
+ } as InteractiveElement;
+
+ case ElementType.DROPDOWN:
+ return {
+ type: ElementType.DROPDOWN,
+ variableId: `dropdown_${Date.now()}`,
+ label: 'Dropdown',
+ options: ['Option 1', 'Option 2', 'Option 3']
+ } as InteractiveElement;
+
+ case ElementType.CHART:
+ return {
+ type: ElementType.CHART,
+ chartKey: 'chart_placeholder'
+ } as InteractiveElement;
+
+ case ElementType.TABULATOR:
+ return {
+ type: ElementType.TABULATOR,
+ dataSourceName: 'data_placeholder'
+ } as InteractiveElement;
+
+ case ElementType.IMAGE:
+ return {
+ type: ElementType.IMAGE,
+ url: 'https://via.placeholder.com/300x200',
+ alt: 'Placeholder image'
+ } as InteractiveElement;
+
+ case ElementType.MERMAID:
+ return {
+ type: ElementType.MERMAID,
+ diagramText: 'graph TD\n A[Start] --> B[End]'
+ } as InteractiveElement;
+
+ case ElementType.TREEBARK:
+ return {
+ type: ElementType.TREEBARK,
+ template: {
+ type: 'div',
+ content: 'Template content',
+ children: []
+ }
+ } as any;
+
+ case ElementType.PRESETS:
+ return {
+ type: ElementType.PRESETS,
+ presets: []
+ } as InteractiveElement;
+
+ default:
+ return 'New element';
+ }
+ };
+
+ const insertElement = (elementType: ElementType) => {
+ const newElement = createElement(elementType);
+
+ if (insertMenu.groupIndex !== undefined) {
+ const newPage = {
+ ...page,
+ groups: page.groups.map((group, gIdx) => {
+ if (gIdx === insertMenu.groupIndex) {
+ const insertIndex = insertMenu.elementIndex !== undefined
+ ? insertMenu.elementIndex + 1
+ : group.elements.length;
+
+ const newElements = [...group.elements];
+ newElements.splice(insertIndex, 0, newElement);
+
+ return {
+ ...group,
+ elements: newElements
+ };
+ }
+ return group;
+ })
+ };
+ onPageChange(newPage);
+ }
+ };
+
+ const openInsertMenu = (event: any, groupIndex: number, elementIndex?: number) => {
+ event.stopPropagation();
+ const rect = event.currentTarget.getBoundingClientRect();
+ setInsertMenu({
+ isOpen: true,
+ position: { x: rect.right + 8, y: rect.top },
+ groupIndex,
+ elementIndex
+ });
+ };
+
+ const closeInsertMenu = () => {
+ setInsertMenu({
+ isOpen: false,
+ position: { x: 0, y: 0 }
+ });
+ };
+
+ const getElementContent = (element: PageElement): string => {
+ if (typeof element === 'string') {
+ return element;
+ }
+ return JSON.stringify(element, null, 2);
+ };
+
+ const openEditModal = (groupIndex: number, elementIndex: number) => {
+ const element = page.groups[groupIndex].elements[elementIndex];
+ setEditModal({
+ isOpen: true,
+ groupIndex,
+ elementIndex,
+ content: getElementContent(element),
+ label: getElementLabel(element)
+ });
+ };
+
+ const closeEditModal = () => {
+ setEditModal({
+ isOpen: false,
+ content: '',
+ label: ''
+ });
+ };
+
+ const saveEditedElement = (newContent: string) => {
+ if (editModal.groupIndex === undefined || editModal.elementIndex === undefined) {
+ return;
+ }
+
+ const currentElement = page.groups[editModal.groupIndex].elements[editModal.elementIndex];
+ let updatedElement: PageElement;
+
+ if (typeof currentElement === 'string') {
+ // For markdown strings, just use the new content directly
+ updatedElement = newContent;
+ } else {
+ // For objects, try to parse JSON
+ try {
+ updatedElement = JSON.parse(newContent);
+ } catch (e) {
+ // If JSON parsing fails, treat it as a markdown string
+ updatedElement = newContent;
+ }
+ }
+
+ const newPage = {
+ ...page,
+ groups: page.groups.map((group, gIdx) => {
+ if (gIdx === editModal.groupIndex) {
+ return {
+ ...group,
+ elements: group.elements.map((el, eIdx) =>
+ eIdx === editModal.elementIndex ? updatedElement : el
+ )
+ };
+ }
+ return group;
+ })
+ };
+ onPageChange(newPage);
+ };
+
+ const getElementIcon = (element: PageElement): string => {
+ if (typeof element === 'string') {
+ return ELEMENT_CONFIGS[ElementType.MARKDOWN].icon;
+ }
+
+ const elementType = element.type as ElementType;
+ return ELEMENT_CONFIGS[elementType]?.icon || '🎨';
+ };
+
+ const getElementLabel = (element: PageElement): string => {
+ if (typeof element === 'string') {
+ return element.slice(0, 30) + (element.length > 30 ? '...' : '');
+ }
+
+ const label = (element as any).label || (element as any).variableId || element.type;
+ return label.slice(0, 30) + (label.length > 30 ? '...' : '');
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ Document Structure
+
+
+ {page.title}
+
+
+
+ {/* Tree Content */}
+
+ {page.groups.map((group, groupIndex) => (
+
+ {/* Group Header */}
+
+
+ 📁 {group.groupId}
+
+
+
+
+
+
+
+ {/* Group Elements */}
+
+ {group.elements.map((element, elementIndex) => (
+
+
+ {getElementIcon(element)} {getElementLabel(element)}
+
+
+
+
+
+
+
+ ))}
+
+
+ ))}
+
+ {/* Add Group Button */}
+
+
+
+ {/* Insert Menu */}
+
+
+ {/* Edit Modal */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/editor/src/editor.tsx b/packages/editor/src/editor.tsx
index 50065997..b471295e 100644
--- a/packages/editor/src/editor.tsx
+++ b/packages/editor/src/editor.tsx
@@ -6,6 +6,7 @@ import { InteractiveDocument } from '@microsoft/chartifact-schema';
import { SandboxDocumentPreview } from "./sandbox.js";
import { Sandbox } from '@microsoft/chartifact-sandbox';
import { EditorPageMessage, EditorReadyMessage, SpecReview, SandboxedPreHydrateMessage } from "common";
+import { TreeView } from './components/TreeView.js';
export interface EditorProps {
postMessageTarget?: Window;
@@ -97,32 +98,6 @@ export function EditorView(props: EditorViewProps) {
postMessageTarget.postMessage(pageMessage, '*');
};
- const deleteElement = (groupIndex: number, elementIndex) => {
- const newPage = {
- ...page,
- groups: page.groups.map((group, gIdx) => {
- if (gIdx === groupIndex) {
- return {
- ...group,
- elements: group.elements.filter((_, eIdx) => eIdx !== elementIndex)
- };
- }
- return group;
- })
- };
-
- sendEditToApp(newPage);
- };
-
- const deleteGroup = (groupIndex) => {
- const newPage = {
- ...page,
- groups: page.groups.filter((_, gIdx) => gIdx !== groupIndex)
- };
-
- sendEditToApp(newPage);
- };
-
return (
-
Tree View
-
-
📄 {page.title}
-
- {page.groups.map((group, groupIndex) => (
-
-
- 📁 {group.groupId}
-
-
-
- {group.elements.map((element, elementIndex) => (
-
-
- {typeof element === 'string'
- ? `📝 ${element.slice(0, 30)}${element.length > 30 ? '...' : ''}`
- : `🎨 ${element.type}`
- }
-
-
-
- ))}
-
-
- ))}
-
-
+
= {
+ [ElementType.MARKDOWN]: {
+ type: ElementType.MARKDOWN,
+ label: 'Text/Markdown',
+ icon: '📝',
+ description: 'Add text content with markdown formatting'
+ },
+ [ElementType.TEXTBOX]: {
+ type: ElementType.TEXTBOX,
+ label: 'Textbox',
+ icon: '📝',
+ description: 'Text input field'
+ },
+ [ElementType.NUMBER]: {
+ type: ElementType.NUMBER,
+ label: 'Number',
+ icon: '🔢',
+ description: 'Numeric input field'
+ },
+ [ElementType.SLIDER]: {
+ type: ElementType.SLIDER,
+ label: 'Slider',
+ icon: '🎚️',
+ description: 'Numeric slider control'
+ },
+ [ElementType.CHECKBOX]: {
+ type: ElementType.CHECKBOX,
+ label: 'Checkbox',
+ icon: '☑️',
+ description: 'Boolean checkbox input'
+ },
+ [ElementType.DROPDOWN]: {
+ type: ElementType.DROPDOWN,
+ label: 'Dropdown',
+ icon: '📋',
+ description: 'Selection dropdown list'
+ },
+ [ElementType.CHART]: {
+ type: ElementType.CHART,
+ label: 'Chart',
+ icon: '📊',
+ description: 'Data visualization chart'
+ },
+ [ElementType.TABULATOR]: {
+ type: ElementType.TABULATOR,
+ label: 'Table',
+ icon: '📋',
+ description: 'Interactive data table'
+ },
+ [ElementType.IMAGE]: {
+ type: ElementType.IMAGE,
+ label: 'Image',
+ icon: '🖼️',
+ description: 'Display images'
+ },
+ [ElementType.MERMAID]: {
+ type: ElementType.MERMAID,
+ label: 'Diagram',
+ icon: '📊',
+ description: 'Mermaid diagrams'
+ },
+ [ElementType.TREEBARK]: {
+ type: ElementType.TREEBARK,
+ label: 'Template',
+ icon: '🌳',
+ description: 'HTML template component'
+ },
+ [ElementType.PRESETS]: {
+ type: ElementType.PRESETS,
+ label: 'Presets',
+ icon: '⚙️',
+ description: 'Variable preset controls'
+ }
+};
+
+/**
+ * Array of all element configurations for iteration
+ */
+export const ELEMENT_TYPES = Object.values(ELEMENT_CONFIGS);