diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index 0ff3d34..6a34312 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -7,7 +7,6 @@ import { Dashboard, StateIndicator, ControlButton, - HtmlEditor, Editor, Terminal, } from './pad'; @@ -28,7 +27,11 @@ export const renderCustomEmbeddable = ( switch (path) { case 'html': - content = ; + content = ; title = "HTML Editor"; break; case 'editor': diff --git a/src/frontend/src/pad/editors/Editor.scss b/src/frontend/src/pad/editors/Editor.scss index 0653cce..6e3695c 100644 --- a/src/frontend/src/pad/editors/Editor.scss +++ b/src/frontend/src/pad/editors/Editor.scss @@ -1,18 +1,105 @@ .editor { &__wrapper { - display: flex; - flex-direction: column; + position: relative; /* For absolute positioning of toolbar */ height: 100%; width: 100%; + overflow: hidden; /* Prevent overflow issues */ + border-bottom-left-radius: 20px; + border-bottom-right-radius: 20px; + + &--split { + .monaco-editor { + width: 50% !important; /* Force Monaco editor to resize */ + } + + .editor__container { + width: 50% !important; /* Force container to resize */ + } + + .editor__toolbar { + right: 50%; /* Limit toolbar width to match the editor in split view */ + } + } } &__toolbar { display: flex; - justify-content: right; + justify-content: flex-end; /* Default justify to the right when no HTML controls */ padding: 8px; + position: absolute; /* Position at bottom */ + bottom: 0; + left: 0; + right: 0; + z-index: 10; /* Keep toolbar above other elements */ + // background-color: #191919; /* Match editor background */ + border-top: 2px solid #3c3c3c; + } + + &__toolbar-right { + display: flex; + align-items: center; + gap: 8px; /* Add spacing between toolbar items */ + + .excalidraw-tooltip-wrapper { + height: 100%; + display: flex; + align-items: center; + } + } + + &__html-controls { + display: flex; + align-items: center; + gap: 8px; + margin-right: auto; /* Push to the left side */ + + .html-editor__label { + font-size: 12px; + color: #e0e0e0; + display: flex; + align-items: center; + gap: 4px; + } + + .html-editor__button { + padding: 6px 12px; + background: #5294f6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + + &:hover { + background: #4285e7; + } + } + } + + &__format-button { + background-color: #252526; + color: #cccccc; + border: 1px solid #3c3c3c; + border-radius: 7px; + padding: 4px 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + &:hover { + background-color: #2a2d2e; + color: #ffffff; + } + + &:focus { + outline: none; + border-color: #007fd4; + } } &__language-selector { + display: flex; margin-right: 10px; } @@ -35,8 +122,106 @@ color: #cccccc; } } + + &__searchable-language-container { + display: flex; + align-items: center; + background-color: #252526; + border: 1px solid #3c3c3c; + border-radius: 7px; + max-width: 150px; + position: relative; + + &:focus-within { + border-color: #007fd4; + } + } + + &__searchable-language-input { + background-color: transparent; + color: #cccccc; + border: none; + padding: 4px 8px; + font-size: 12px; + width: 100%; + + &:focus { + outline: none; + } + + &::placeholder { + color: #cccccc; + opacity: 0.8; + } + } + + &__searchable-language-toggle { + background: none; + border: none; + color: #cccccc; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + transition: transform 0.2s ease; + + &:hover { + color: #ffffff; + } + + &:focus { + outline: none; + } + } + + &__searchable-language-dropdown { + position: absolute; + bottom: 100%; /* Position above instead of below */ + left: 0; + right: 0; + max-height: 200px; + overflow-y: auto; + background-color: #252526; + border: 1px solid #3c3c3c; + border-radius: 7px; + margin-bottom: 4px; /* Margin at bottom instead of top */ + z-index: 20; /* Higher z-index to ensure it's above the toolbar */ + box-shadow: 0 -4px 8px rgba(0, 0, 0, 0.3); /* Shadow adjusted for upward direction */ + } + + &__searchable-language-option { + padding: 4px 8px; + font-size: 12px; + color: #cccccc; + cursor: pointer; + + &:hover { + background-color: #2a2d2e; + } + + &--highlighted { + background-color: #04395e; + } + + &--selected { + color: #ffffff; + font-weight: 500; + } + } + + &__searchable-language-no-results { + padding: 4px 8px; + font-size: 12px; + color: #cccccc; + font-style: italic; + text-align: center; + } &__container { - flex: 1; + height: 100%; + width: 100%; + padding-bottom: 60px; /* Make room for the toolbar */ + box-sizing: border-box; } } diff --git a/src/frontend/src/pad/editors/Editor.tsx b/src/frontend/src/pad/editors/Editor.tsx index 7c44411..8701c2f 100644 --- a/src/frontend/src/pad/editors/Editor.tsx +++ b/src/frontend/src/pad/editors/Editor.tsx @@ -1,8 +1,36 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import MonacoEditor from '@monaco-editor/react'; -import LanguageSelector from './LanguageSelector'; +import { Tooltip, updateTooltipPosition, getTooltipDiv } from '@atyrode/excalidraw'; +import SearchableLanguageSelector from './SearchableLanguageSelector'; +import { useHtmlEditor, HtmlEditorControls, defaultHtml, HtmlEditorSplitView } from './HtmlEditor'; import './Editor.scss'; +// Custom tooltip wrapper that positions the tooltip at the top +const TopTooltip: React.FC<{label: string, children: React.ReactNode}> = ({ label, children }) => { + const handlePointerEnter = (event: React.PointerEvent) => { + const tooltip = getTooltipDiv(); + tooltip.classList.add("excalidraw-tooltip--visible"); + tooltip.textContent = label; + + const itemRect = event.currentTarget.getBoundingClientRect(); + updateTooltipPosition(tooltip, itemRect, "top"); + }; + + const handlePointerLeave = () => { + getTooltipDiv().classList.remove("excalidraw-tooltip--visible"); + }; + + return ( +
+ {children} +
+ ); +}; + interface EditorProps { defaultValue?: string; language?: string; @@ -21,7 +49,7 @@ interface EditorProps { const Editor: React.FC = ({ defaultValue = '', - language = 'javascript', + language = 'plaintext', theme = 'vs-dark', height = '100%', options = { @@ -259,25 +287,84 @@ const Editor: React.FC = ({ } }; + // Format document function + const formatDocument = () => { + if (editorRef.current) { + // Trigger Monaco's format document action + editorRef.current.getAction('editor.action.formatDocument')?.run(); + } + }; + + // Check if the language is HTML + const isHtml = currentLanguage === 'html'; + + // Always initialize HTML editor hooks, but pass isActive flag + const htmlEditor = useHtmlEditor( + element, + editorRef, + excalidrawAPI, + isHtml + ); + + // Determine if we should show the split view + const showSplitView = isHtml && !htmlEditor.createNew && htmlEditor.showPreview; + return ( -
+
+ + {/* Render the HTML preview in split view mode */} + {showSplitView && ( + + )} {showLanguageSelector && (
- + {/* Show HTML-specific controls when language is HTML */} + {isHtml && ( +
+ +
+ )} + + {/* Group format button and language selector together on the right */} +
+ + + + + + } /> + + +
)}
diff --git a/src/frontend/src/pad/editors/HtmlEditor.scss b/src/frontend/src/pad/editors/HtmlEditor.scss index f414772..3131f26 100644 --- a/src/frontend/src/pad/editors/HtmlEditor.scss +++ b/src/frontend/src/pad/editors/HtmlEditor.scss @@ -1,47 +1,4 @@ .html-editor { - &__container { - display: flex; - position: relative; - width: 100%; - height: 100%; - flex-direction: column; - box-sizing: border-box; - padding: 32px; - font-family: sans-serif; - background: #2d2d2d; - color: #e0e0e0; - border-radius: 4px; - border: 1px solid #484848; - } - - &__content { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - position: relative; - } - - &__monaco-container { - width: 100%; - position: relative; - flex: 1; - min-height: 0; - border: 1px solid #484848; - border-radius: 4px; - margin-bottom: 10px; - } - - &__controls { - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - width: 100%; - flex-shrink: 0; - } - &__label { font-size: 12px; color: #e0e0e0; @@ -58,9 +15,38 @@ border-radius: 4px; cursor: pointer; font-size: 12px; + margin-right: 8px; &:hover { background: #4285e7; } + + &--active { + background: #3a75c4; + } + } + + &__split-view { + position: absolute; + top: 0; + right: 0; + width: 50%; + height: 100%; + box-sizing: border-box; + z-index: 5; + background: #ffffff00; + border-left: 2px solid #484848; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); + } +} + +// Split view styles for the editor container +.editor__wrapper.editor__wrapper--split { + .monaco-editor { + width: 50% !important; /* Force Monaco editor to resize */ + } + + .editor__container { + width: 50% !important; /* Force container to resize */ } } diff --git a/src/frontend/src/pad/editors/HtmlEditor.tsx b/src/frontend/src/pad/editors/HtmlEditor.tsx index 0691d0b..78e1974 100644 --- a/src/frontend/src/pad/editors/HtmlEditor.tsx +++ b/src/frontend/src/pad/editors/HtmlEditor.tsx @@ -1,77 +1,93 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; -import type { AppState } from '@atyrode/excalidraw/types'; -import Editor from './Editor'; import { ExcalidrawElementFactory } from '../../lib/ExcalidrawElementFactory'; +import HtmlPreview from './HtmlPreview'; import './HtmlEditor.scss'; -interface HtmlEditorProps { - element: NonDeleted; - appState: AppState; - excalidrawAPI?: any; -} +// Default HTML content for new HTML elements with API usage documentation as a comment +export const defaultHtml = ` +`; -export const HtmlEditor: React.FC = ({ - element, - appState, - excalidrawAPI -}) => { - const [createNew, setCreateNew] = useState(true); - const defaultHtml = ''; - const [editorValue, setEditorValue] = useState( - element.customData?.editorContent || defaultHtml - ); - const editorRef = useRef(null); - const elementIdRef = useRef(element.id); - // Load content from customData when element changes (e.g., when cloned or pasted) +// Hook to manage HTML editor state and functionality +export const useHtmlEditor = ( + element: NonDeleted | undefined, + editorRef: React.RefObject, + excalidrawAPI?: any, + isActive: boolean = true // New parameter to control if the hook is active +) => { + // Always initialize these hooks regardless of isActive + const [createNew, setCreateNew] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [previewContent, setPreviewContent] = useState(''); + + // Update preview content when editor content changes useEffect(() => { - // Check if element ID has changed (indicating a new element) - if (element.id !== elementIdRef.current) { - elementIdRef.current = element.id; - - // If element has customData with editorContent, update the state - if (element.customData?.editorContent) { - setEditorValue(element.customData.editorContent); - } else { - setEditorValue(defaultHtml); - } - - // Note: We don't need to update language here since HtmlEditor always uses 'html' - // But we still save it in customData for consistency + if (!isActive || !showPreview || !editorRef.current) return; + + try { + // Get the current content from the editor + const currentContent = editorRef.current.getValue(); + setPreviewContent(currentContent); + } catch (error) { + // Handle case where editor model might be disposed + console.warn("Could not get editor value:", error); } - }, [element.id, element.customData, defaultHtml]); - - const handleEditorMount = (editor: any) => { - editorRef.current = editor; - }; + }, [showPreview, editorRef, isActive]); const applyHtml = () => { - if (!excalidrawAPI || !editorRef.current) return; - - const htmlContent = editorRef.current.getValue(); - const elements = excalidrawAPI.getSceneElements(); - - // Get the current editor content - const currentContent = editorRef.current.getValue(); + if (!isActive || !excalidrawAPI || !editorRef.current || !element) return; - // Create a new iframe element with the HTML content using our factory - const newElement = ExcalidrawElementFactory.createIframeElement({ - x: createNew ? element.x + element.width + 20 : element.x, - y: createNew ? element.y : element.y, - width: element.width, - height: element.height, - htmlContent: htmlContent, - id: createNew ? undefined : element.id, - customData: { - editorContent: currentContent, - editorLanguage: 'html' // Always set to html for HtmlEditor + try { + const htmlContent = editorRef.current.getValue(); + + // If not creating a new element, show the preview instead + if (!createNew) { + setPreviewContent(htmlContent); + setShowPreview(true); + return; } - }); - - // If creating a new element, add it to the scene - // If updating an existing element, replace it in the scene - if (createNew) { + + // Otherwise, create a new element as before + const elements = excalidrawAPI.getSceneElements(); + + // Get the current editor content + const currentContent = editorRef.current.getValue(); + + // Create a new iframe element with the HTML content using our factory + const newElement = ExcalidrawElementFactory.createIframeElement({ + x: element.x + element.width + 20, + y: element.y, + width: element.width, + height: element.height, + htmlContent: htmlContent, + id: undefined, // Always create a new element + customData: { + editorContent: currentContent, + editorLanguage: 'html' // Always set to html for HTML content + } + }); + + // Add the new element to the scene excalidrawAPI.updateScene({ elements: [...elements, newElement] }); @@ -80,47 +96,89 @@ export const HtmlEditor: React.FC = ({ viewportZoomFactor: 0.95, // Slight zoom out to ensure element is fully visible animate: true }); - } else { - // Replace the existing element - const updatedElements = elements.map(el => - el.id === element.id ? newElement : el - ); - excalidrawAPI.updateScene({ - elements: updatedElements - }); + + excalidrawAPI.setActiveTool({ type: "selection" }); + } catch (error) { + console.warn("Error applying HTML:", error); } + }; + + const togglePreview = () => { + if (!isActive) return; - excalidrawAPI.setActiveTool({ type: "selection" }); + if (showPreview) { + setShowPreview(false); + } else { + try { + const htmlContent = editorRef.current?.getValue() || ''; + setPreviewContent(htmlContent); + setShowPreview(true); + } catch (error) { + console.warn("Could not toggle preview:", error); + } + } }; + return { + createNew, + setCreateNew: (value: boolean) => isActive && setCreateNew(value), + showPreview, + setShowPreview: (value: boolean) => isActive && setShowPreview(value), + previewContent, + setPreviewContent: (value: string) => isActive && setPreviewContent(value), + applyHtml, + togglePreview + }; +}; + + +// HTML-specific toolbar controls component +export const HtmlEditorControls: React.FC<{ + createNew: boolean; + setCreateNew: (value: boolean) => void; + applyHtml: () => void; + showPreview?: boolean; + togglePreview?: () => void; +}> = ({ createNew, setCreateNew, applyHtml, showPreview, togglePreview }) => { + return ( + <> + + + + {!createNew && togglePreview && ( + + )} + + ); +}; + +// Split view component for HTML editor +export const HtmlEditorSplitView: React.FC<{ + editorContent: string; + previewContent: string; + showPreview: boolean; +}> = ({ editorContent, previewContent, showPreview }) => { + if (!showPreview) { + return null; + } + return ( -
-
- value && setEditorValue(value)} - onMount={handleEditorMount} - element={element} - excalidrawAPI={excalidrawAPI} - showLanguageSelector={false} - className="html-editor__monaco-container" - /> -
- - -
-
+
+
); }; diff --git a/src/frontend/src/pad/editors/HtmlPreview.scss b/src/frontend/src/pad/editors/HtmlPreview.scss new file mode 100644 index 0000000..72fe021 --- /dev/null +++ b/src/frontend/src/pad/editors/HtmlPreview.scss @@ -0,0 +1,18 @@ +.html-preview { + &__container { + height: 100%; + width: 100%; + overflow: hidden; + border-radius: 4px; + border: 1px solid #484848; + background: #fff; + position: relative; + } + + &__iframe { + width: 100%; + height: 100%; + border: none; + background: #fff; + } +} diff --git a/src/frontend/src/pad/editors/HtmlPreview.tsx b/src/frontend/src/pad/editors/HtmlPreview.tsx new file mode 100644 index 0000000..04affae --- /dev/null +++ b/src/frontend/src/pad/editors/HtmlPreview.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useRef } from 'react'; +import './HtmlPreview.scss'; + +// Message passing script to be injected into the iframe +const messagePassingScript = ` + +`; + +interface HtmlPreviewProps { + htmlContent: string; + className?: string; +} + +const HtmlPreview: React.FC = ({ + htmlContent, + className = 'html-preview__container' +}) => { + const iframeRef = useRef(null); + + // Handle API requests from the iframe + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'API_REQUEST') { + // Make the API call using the current user's authentication + fetch(event.data.endpoint, { + method: event.data.method, + credentials: 'include', // This includes the current viewer's cookies + body: event.data.method !== 'GET' ? JSON.stringify(event.data.data) : undefined, + headers: { 'Content-Type': 'application/json' } + }) + .then(response => { + if (!response.ok) throw new Error('API request failed'); + return response.json(); + }) + .then(data => { + // Send successful response back to iframe + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage({ + type: 'API_RESPONSE', + requestId: event.data.requestId, + data: data + }, '*'); + } + }) + .catch(error => { + // Send error back to iframe + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage({ + type: 'API_RESPONSE', + requestId: event.data.requestId, + error: error.message + }, '*'); + } + }); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + + // Prepare the HTML content with the message passing script + const prepareHtmlContent = () => { + // Insert the message passing script before the closing body tag + // If there's no body tag, append it to the end + if (htmlContent.includes('')) { + return htmlContent.replace('', `${messagePassingScript}`); + } else { + return `${htmlContent}${messagePassingScript}`; + } + }; + + return ( +
+