From dbbd7d290f3ffe9d8bc73f42e2d521d2f6a92694 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 7 May 2025 00:35:03 +0000 Subject: [PATCH 1/4] feat: enhance editor functionality with searchable language selector and formatting options - Introduced a new SearchableLanguageSelector component for improved language selection with search capabilities. - Updated Editor component to include a format button with tooltip for document formatting. - Enhanced styling for editor toolbar and language selector for better user experience. - Added responsive design elements to prevent overflow and improve layout consistency. --- src/frontend/src/pad/editors/Editor.scss | 137 ++++++++- src/frontend/src/pad/editors/Editor.tsx | 52 +++- .../editors/SearchableLanguageSelector.tsx | 290 ++++++++++++++++++ 3 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/pad/editors/SearchableLanguageSelector.tsx diff --git a/src/frontend/src/pad/editors/Editor.scss b/src/frontend/src/pad/editors/Editor.scss index 0653cce..b5717a5 100644 --- a/src/frontend/src/pad/editors/Editor.scss +++ b/src/frontend/src/pad/editors/Editor.scss @@ -1,18 +1,51 @@ .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; } &__toolbar { display: flex; justify-content: right; 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: 1px solid #3c3c3c; + gap: 8px; /* Add spacing between toolbar items */ + } + + &__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; + + &:hover { + background-color: #2a2d2e; + color: #ffffff; + } + + &:focus { + outline: none; + border-color: #007fd4; + } } &__language-selector { + display: flex; margin-right: 10px; } @@ -35,8 +68,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: 40px; /* 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..5d2168e 100644 --- a/src/frontend/src/pad/editors/Editor.tsx +++ b/src/frontend/src/pad/editors/Editor.tsx @@ -1,8 +1,35 @@ 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 './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 +48,7 @@ interface EditorProps { const Editor: React.FC = ({ defaultValue = '', - language = 'javascript', + language = 'plaintext', theme = 'vs-dark', height = '100%', options = { @@ -259,6 +286,14 @@ 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(); + } + }; + return (
= ({ /> {showLanguageSelector && (
- + + + + + } /> + void; + className?: string; +} + +// Language options based on Monaco editor supported languages +const languageOptions = [ + { value: 'plaintext', label: 'plaintext' }, + { value: 'abap', label: 'abap' }, + { value: 'apex', label: 'apex' }, + { value: 'azcli', label: 'azcli' }, + { value: 'bat', label: 'bat' }, + { value: 'bicep', label: 'bicep' }, + { value: 'cameligo', label: 'cameligo' }, + { value: 'clojure', label: 'clojure' }, + { value: 'coffeescript', label: 'coffeescript' }, + { value: 'c', label: 'c' }, + { value: 'cpp', label: 'cpp' }, + { value: 'csharp', label: 'csharp' }, + { value: 'csp', label: 'csp' }, + { value: 'css', label: 'css' }, + { value: 'cypher', label: 'cypher' }, + { value: 'dart', label: 'dart' }, + { value: 'dockerfile', label: 'dockerfile' }, + { value: 'ecl', label: 'ecl' }, + { value: 'elixir', label: 'elixir' }, + { value: 'flow9', label: 'flow9' }, + { value: 'fsharp', label: 'fsharp' }, + { value: 'freemarker2', label: 'freemarker2' }, + { value: 'freemarker2.tag-angle.interpolation-dollar', label: 'freemarker2.tag-angle.interpolation-dollar' }, + { value: 'freemarker2.tag-bracket.interpolation-dollar', label: 'freemarker2.tag-bracket.interpolation-dollar' }, + { value: 'freemarker2.tag-angle.interpolation-bracket', label: 'freemarker2.tag-angle.interpolation-bracket' }, + { value: 'freemarker2.tag-bracket.interpolation-bracket', label: 'freemarker2.tag-bracket.interpolation-bracket' }, + { value: 'freemarker2.tag-auto.interpolation-dollar', label: 'freemarker2.tag-auto.interpolation-dollar' }, + { value: 'freemarker2.tag-auto.interpolation-bracket', label: 'freemarker2.tag-auto.interpolation-bracket' }, + { value: 'go', label: 'go' }, + { value: 'graphql', label: 'graphql' }, + { value: 'handlebars', label: 'handlebars' }, + { value: 'hcl', label: 'hcl' }, + { value: 'html', label: 'html' }, + { value: 'ini', label: 'ini' }, + { value: 'java', label: 'java' }, + { value: 'javascript', label: 'javascript' }, + { value: 'julia', label: 'julia' }, + { value: 'kotlin', label: 'kotlin' }, + { value: 'less', label: 'less' }, + { value: 'lexon', label: 'lexon' }, + { value: 'lua', label: 'lua' }, + { value: 'liquid', label: 'liquid' }, + { value: 'm3', label: 'm3' }, + { value: 'markdown', label: 'markdown' }, + { value: 'mdx', label: 'mdx' }, + { value: 'mips', label: 'mips' }, + { value: 'msdax', label: 'msdax' }, + { value: 'mysql', label: 'mysql' }, + { value: 'objective-c', label: 'objective-c' }, + { value: 'pascal', label: 'pascal' }, + { value: 'pascaligo', label: 'pascaligo' }, + { value: 'perl', label: 'perl' }, + { value: 'pgsql', label: 'pgsql' }, + { value: 'php', label: 'php' }, + { value: 'pla', label: 'pla' }, + { value: 'postiats', label: 'postiats' }, + { value: 'powerquery', label: 'powerquery' }, + { value: 'powershell', label: 'powershell' }, + { value: 'proto', label: 'proto' }, + { value: 'pug', label: 'pug' }, + { value: 'python', label: 'python' }, + { value: 'qsharp', label: 'qsharp' }, + { value: 'r', label: 'r' }, + { value: 'razor', label: 'razor' }, + { value: 'redis', label: 'redis' }, + { value: 'redshift', label: 'redshift' }, + { value: 'restructuredtext', label: 'restructuredtext' }, + { value: 'ruby', label: 'ruby' }, + { value: 'rust', label: 'rust' }, + { value: 'sb', label: 'sb' }, + { value: 'scala', label: 'scala' }, + { value: 'scheme', label: 'scheme' }, + { value: 'scss', label: 'scss' }, + { value: 'shell', label: 'shell' }, + { value: 'sol', label: 'sol' }, + { value: 'aes', label: 'aes' }, + { value: 'sparql', label: 'sparql' }, + { value: 'sql', label: 'sql' }, + { value: 'st', label: 'st' }, + { value: 'swift', label: 'swift' }, + { value: 'systemverilog', label: 'systemverilog' }, + { value: 'verilog', label: 'verilog' }, + { value: 'tcl', label: 'tcl' }, + { value: 'twig', label: 'twig' }, + { value: 'typescript', label: 'typescript' }, + { value: 'typespec', label: 'typespec' }, + { value: 'vb', label: 'vb' }, + { value: 'wgsl', label: 'wgsl' }, + { value: 'xml', label: 'xml' }, + { value: 'yaml', label: 'yaml' }, + { value: 'json', label: 'json' } +]; + +const SearchableLanguageSelector: React.FC = ({ + value, + onChange, + className = 'editor__language-selector' +}) => { + const [searchText, setSearchText] = useState(''); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [filteredOptions, setFilteredOptions] = useState(languageOptions); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + // Find the current language label + const currentLanguageLabel = languageOptions.find(option => option.value === value)?.label || value; + + // Filter options based on search text + useEffect(() => { + if (!searchText.trim()) { + setFilteredOptions(languageOptions); + return; + } + + const searchLower = searchText.toLowerCase(); + const filtered = languageOptions.filter(option => + option.label.toLowerCase().includes(searchLower) + ); + + // Sort results: exact matches first, then starts with, then includes + filtered.sort((a, b) => { + const aLower = a.label.toLowerCase(); + const bLower = b.label.toLowerCase(); + + // Exact match + if (aLower === searchLower && bLower !== searchLower) return -1; + if (bLower === searchLower && aLower !== searchLower) return 1; + + // Starts with + if (aLower.startsWith(searchLower) && !bLower.startsWith(searchLower)) return -1; + if (bLower.startsWith(searchLower) && !aLower.startsWith(searchLower)) return 1; + + // Alphabetical order + return aLower.localeCompare(bLower); + }); + + setFilteredOptions(filtered); + setHighlightedIndex(filtered.length > 0 ? 0 : -1); + }, [searchText]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + // Reset search text to current selection when closing + setSearchText(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchText(e.target.value); + // Only open dropdown if there's text to search + if (e.target.value.trim()) { + setIsDropdownOpen(true); + } else { + setIsDropdownOpen(false); + } + }; + + const handleOptionClick = (optionValue: string) => { + onChange(optionValue); + setIsDropdownOpen(false); + setSearchText(''); + inputRef.current?.blur(); + }; + + const handleInputFocus = () => { + // Don't open dropdown on focus, only when typing or clicking the arrow + }; + + const handleToggleDropdown = () => { + setIsDropdownOpen(!isDropdownOpen); + if (!isDropdownOpen) { + inputRef.current?.focus(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isDropdownOpen) { + if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + setIsDropdownOpen(true); + e.preventDefault(); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + setHighlightedIndex(prev => + prev < filteredOptions.length - 1 ? prev + 1 : prev + ); + e.preventDefault(); + break; + case 'ArrowUp': + setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0); + e.preventDefault(); + break; + case 'Enter': + if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { + handleOptionClick(filteredOptions[highlightedIndex].value); + e.preventDefault(); + } + break; + case 'Escape': + setIsDropdownOpen(false); + setSearchText(''); + inputRef.current?.blur(); + e.preventDefault(); + break; + case 'Tab': + setIsDropdownOpen(false); + setSearchText(''); + break; + } + }; + + return ( +
+
+ + +
+ + {isDropdownOpen && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( +
handleOptionClick(option.value)} + > + {option.label} +
+ )) + ) : ( +
No matches found
+ )} +
+ )} +
+ ); +}; + +export default SearchableLanguageSelector; From b26a603bafa88226702319fef3597d0f9507af05 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 7 May 2025 00:40:36 +0000 Subject: [PATCH 2/4] style: update Editor.scss for improved toolbar appearance - Commented out the background color for the toolbar to allow for a more flexible design. - Increased the border-top thickness for better visibility. - Adjusted padding-bottom to ensure sufficient space for the toolbar, enhancing overall layout consistency. --- src/frontend/src/pad/editors/Editor.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/pad/editors/Editor.scss b/src/frontend/src/pad/editors/Editor.scss index b5717a5..1933356 100644 --- a/src/frontend/src/pad/editors/Editor.scss +++ b/src/frontend/src/pad/editors/Editor.scss @@ -17,8 +17,8 @@ left: 0; right: 0; z-index: 10; /* Keep toolbar above other elements */ - background-color: #191919; /* Match editor background */ - border-top: 1px solid #3c3c3c; + // background-color: #191919; /* Match editor background */ + border-top: 2px solid #3c3c3c; gap: 8px; /* Add spacing between toolbar items */ } @@ -167,7 +167,7 @@ &__container { height: 100%; width: 100%; - padding-bottom: 40px; /* Make room for the toolbar */ + padding-bottom: 60px; /* Make room for the toolbar */ box-sizing: border-box; } } From 4194206e6c5b1c3fee77e60a05c317021de0bbb4 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 7 May 2025 01:15:33 +0000 Subject: [PATCH 3/4] feat: enhance HTML editor integration and toolbar functionality - Replaced HtmlEditor component with a more versatile Editor component for HTML content rendering. - Introduced useHtmlEditor hook to manage HTML editor state and functionality. - Updated Editor.scss to improve toolbar layout and added new HTML-specific controls. - Enhanced Editor component to conditionally display HTML controls based on the selected language. - Refactored HtmlEditor to provide reusable controls for HTML editing, improving code organization and maintainability. --- src/frontend/src/CustomEmbeddableRenderer.tsx | 7 +- src/frontend/src/pad/editors/Editor.scss | 44 +++++++- src/frontend/src/pad/editors/Editor.tsx | 58 +++++++--- src/frontend/src/pad/editors/HtmlEditor.tsx | 106 ++++++------------ src/frontend/src/pad/editors/index.ts | 2 +- 5 files changed, 125 insertions(+), 92 deletions(-) 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 1933356..87e0efa 100644 --- a/src/frontend/src/pad/editors/Editor.scss +++ b/src/frontend/src/pad/editors/Editor.scss @@ -10,7 +10,7 @@ &__toolbar { display: flex; - justify-content: right; + justify-content: space-between; /* Space between HTML controls and right-side controls */ padding: 8px; position: absolute; /* Position at bottom */ bottom: 0; @@ -19,7 +19,47 @@ 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 { @@ -32,7 +72,7 @@ display: flex; align-items: center; justify-content: center; - + height: 100%; &:hover { background-color: #2a2d2e; color: #ffffff; diff --git a/src/frontend/src/pad/editors/Editor.tsx b/src/frontend/src/pad/editors/Editor.tsx index 5d2168e..a5fb795 100644 --- a/src/frontend/src/pad/editors/Editor.tsx +++ b/src/frontend/src/pad/editors/Editor.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import MonacoEditor from '@monaco-editor/react'; import { Tooltip, updateTooltipPosition, getTooltipDiv } from '@atyrode/excalidraw'; import SearchableLanguageSelector from './SearchableLanguageSelector'; +import { useHtmlEditor, HtmlEditorControls, defaultHtml } from './HtmlEditor'; import './Editor.scss'; // Custom tooltip wrapper that positions the tooltip at the top @@ -294,12 +295,20 @@ const Editor: React.FC = ({ } }; + // Initialize HTML editor functionality if the language is HTML + const isHtml = currentLanguage === 'html'; + + // Only initialize HTML editor hooks if we're in HTML mode and have the necessary props + const htmlEditor = isHtml && element && excalidrawAPI + ? useHtmlEditor(element, editorRef, excalidrawAPI) + : null; + return (
= ({ /> {showLanguageSelector && (
- - - - - - } /> - + {/* Show HTML-specific controls when language is HTML */} + {isHtml && htmlEditor && ( +
+ +
+ )} + + {/* Group format button and language selector together on the right */} +
+ + + + + + } /> + + +
)}
diff --git a/src/frontend/src/pad/editors/HtmlEditor.tsx b/src/frontend/src/pad/editors/HtmlEditor.tsx index 0691d0b..a6847a7 100644 --- a/src/frontend/src/pad/editors/HtmlEditor.tsx +++ b/src/frontend/src/pad/editors/HtmlEditor.tsx @@ -1,50 +1,18 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState } 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 './HtmlEditor.scss'; -interface HtmlEditorProps { - element: NonDeleted; - appState: AppState; - excalidrawAPI?: any; -} +// Default HTML content for new HTML elements +export const defaultHtml = ''; -export const HtmlEditor: React.FC = ({ - element, - appState, - excalidrawAPI -}) => { +// Hook to manage HTML editor state and functionality +export const useHtmlEditor = ( + element: NonDeleted, + editorRef: React.RefObject, + excalidrawAPI?: any +) => { 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) - 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 - } - }, [element.id, element.customData, defaultHtml]); - - const handleEditorMount = (editor: any) => { - editorRef.current = editor; - }; const applyHtml = () => { if (!excalidrawAPI || !editorRef.current) return; @@ -65,7 +33,7 @@ export const HtmlEditor: React.FC = ({ id: createNew ? undefined : element.id, customData: { editorContent: currentContent, - editorLanguage: 'html' // Always set to html for HtmlEditor + editorLanguage: 'html' // Always set to html for HTML content } }); @@ -93,34 +61,32 @@ export const HtmlEditor: React.FC = ({ excalidrawAPI.setActiveTool({ type: "selection" }); }; + return { + createNew, + setCreateNew, + applyHtml + }; +}; + +// HTML-specific toolbar controls component +export const HtmlEditorControls: React.FC<{ + createNew: boolean; + setCreateNew: (value: boolean) => void; + applyHtml: () => void; +}> = ({ createNew, setCreateNew, applyHtml }) => { 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/index.ts b/src/frontend/src/pad/editors/index.ts index 05d9982..1fcd8d7 100644 --- a/src/frontend/src/pad/editors/index.ts +++ b/src/frontend/src/pad/editors/index.ts @@ -1,3 +1,3 @@ -export { HtmlEditor } from './HtmlEditor'; +export { useHtmlEditor, HtmlEditorControls, defaultHtml } from './HtmlEditor'; export { default as Editor } from './Editor'; export { default as LanguageSelector } from './LanguageSelector'; From c20c1c14b059a0f41f774e19630e21dc9a344738 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 7 May 2025 06:49:53 +0000 Subject: [PATCH 4/4] feat: implement split view for HTML editor with preview functionality - Added a split view mode to the HTML editor, allowing simultaneous editing and previewing of HTML content. - Introduced HtmlEditorSplitView component to manage the rendering of the editor and preview side by side. - Enhanced useHtmlEditor hook to support active state management and preview toggling. - Updated Editor and HtmlEditor components to integrate new split view features and improve user experience. - Refactored styles in Editor.scss and HtmlEditor.scss to accommodate the new layout and ensure responsive design. --- src/frontend/src/pad/editors/Editor.scss | 16 +- src/frontend/src/pad/editors/Editor.tsx | 33 +++- src/frontend/src/pad/editors/HtmlEditor.scss | 72 +++---- src/frontend/src/pad/editors/HtmlEditor.tsx | 176 +++++++++++++----- src/frontend/src/pad/editors/HtmlPreview.scss | 18 ++ src/frontend/src/pad/editors/HtmlPreview.tsx | 120 ++++++++++++ src/frontend/src/pad/editors/index.ts | 3 +- src/frontend/src/ui/MainMenu.tsx | 21 --- 8 files changed, 343 insertions(+), 116 deletions(-) create mode 100644 src/frontend/src/pad/editors/HtmlPreview.scss create mode 100644 src/frontend/src/pad/editors/HtmlPreview.tsx diff --git a/src/frontend/src/pad/editors/Editor.scss b/src/frontend/src/pad/editors/Editor.scss index 87e0efa..6e3695c 100644 --- a/src/frontend/src/pad/editors/Editor.scss +++ b/src/frontend/src/pad/editors/Editor.scss @@ -6,11 +6,25 @@ 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: space-between; /* Space between HTML controls and right-side controls */ + justify-content: flex-end; /* Default justify to the right when no HTML controls */ padding: 8px; position: absolute; /* Position at bottom */ bottom: 0; diff --git a/src/frontend/src/pad/editors/Editor.tsx b/src/frontend/src/pad/editors/Editor.tsx index a5fb795..8701c2f 100644 --- a/src/frontend/src/pad/editors/Editor.tsx +++ b/src/frontend/src/pad/editors/Editor.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import MonacoEditor from '@monaco-editor/react'; import { Tooltip, updateTooltipPosition, getTooltipDiv } from '@atyrode/excalidraw'; import SearchableLanguageSelector from './SearchableLanguageSelector'; -import { useHtmlEditor, HtmlEditorControls, defaultHtml } from './HtmlEditor'; +import { useHtmlEditor, HtmlEditorControls, defaultHtml, HtmlEditorSplitView } from './HtmlEditor'; import './Editor.scss'; // Custom tooltip wrapper that positions the tooltip at the top @@ -295,16 +295,22 @@ const Editor: React.FC = ({ } }; - // Initialize HTML editor functionality if the language is HTML + // Check if the language is HTML const isHtml = currentLanguage === 'html'; - // Only initialize HTML editor hooks if we're in HTML mode and have the necessary props - const htmlEditor = isHtml && element && excalidrawAPI - ? useHtmlEditor(element, editorRef, excalidrawAPI) - : null; + // 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 ( -
+
= ({ onChange={handleEditorChange} className={className} /> + + {/* Render the HTML preview in split view mode */} + {showSplitView && ( + + )} {showLanguageSelector && (
{/* Show HTML-specific controls when language is HTML */} - {isHtml && htmlEditor && ( + {isHtml && (
)} 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 a6847a7..78e1974 100644 --- a/src/frontend/src/pad/editors/HtmlEditor.tsx +++ b/src/frontend/src/pad/editors/HtmlEditor.tsx @@ -1,45 +1,93 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; import { ExcalidrawElementFactory } from '../../lib/ExcalidrawElementFactory'; +import HtmlPreview from './HtmlPreview'; import './HtmlEditor.scss'; -// Default HTML content for new HTML elements -export const defaultHtml = ''; +// Default HTML content for new HTML elements with API usage documentation as a comment +export const defaultHtml = ` +`; + // Hook to manage HTML editor state and functionality export const useHtmlEditor = ( - element: NonDeleted, + element: NonDeleted | undefined, editorRef: React.RefObject, - excalidrawAPI?: any + excalidrawAPI?: any, + isActive: boolean = true // New parameter to control if the hook is active ) => { - const [createNew, setCreateNew] = useState(true); + // 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(() => { + 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); + } + }, [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 HTML content + 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] }); @@ -48,32 +96,50 @@ export const useHtmlEditor = ( 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, - applyHtml + 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; -}> = ({ createNew, setCreateNew, applyHtml }) => { + 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 ( +
+ +
+ ); +}; 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 ( +
+