feat: Object Editor Preview Mode & Mobile Optimizations

Major Enhancements:
- Added Preview/Edit mode toggle to StructuredEditor component
  * Preview mode: Read-only view with full text visibility
  * Edit mode: Full editing capabilities with all controls
  * Toggle positioned below title, responsive on mobile
  * Works in both main ObjectEditor view and nested modals

- Clickable nested data in Preview mode
  * JSON/serialized values are blue and clickable
  * Opens modal directly without switching to Edit mode
  * Hover effects and tooltips for better UX
  * No longer need edit mode just to explore structure

Mobile Responsiveness Improvements:
- Fixed all data load notices in ObjectEditor (URL, Paste, Open tabs)
- Fixed all data load notices in TableEditor (URL, Paste, Open tabs)
- Notices now stack vertically on mobile with proper spacing
- Added break-words for long text, whitespace-nowrap for buttons
- Dark mode colors added for better visibility

Table Editor Fixes:
- Fixed sticky header showing row underneath (top-[-1px])
- Made Export section header mobile responsive
- Updated object modal footer layout:
  * Format info and properties combined on single line
  * Buttons moved to separate row below
  * Changed 'Apply Changes' to 'Save Changes' for consistency

StructuredEditor Improvements:
- Moved overflow-x handling from ObjectEditor to StructuredEditor
- Now works consistently in main view and nested modals
- Long strings scroll horizontally everywhere
- 'Add Property' button hidden in Preview mode
- Improved chevron colors for dark mode visibility

Technical Changes:
- StructuredEditor now manages its own editMode state
- readOnly prop can still be passed from parent if needed
- Proper conditional rendering for all UI elements
- Consistent mobile-first responsive design patterns
This commit is contained in:
dwindown
2025-10-15 22:40:57 +07:00
parent f6c19e855d
commit df0fb5d22a
9 changed files with 929 additions and 600 deletions

View File

@@ -1,8 +1,9 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { basicSetup } from 'codemirror';
import { json } from '@codemirror/lang-json';
import { sql } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark';
import { Maximize2, Minimize2 } from 'lucide-react';
@@ -13,7 +14,8 @@ const CodeMirrorEditor = ({
className = '',
language = 'json',
maxLines = 12,
showToggle = true
showToggle = true,
cardRef = null // Reference to the card header for scroll target
}) => {
const editorRef = useRef(null);
const viewRef = useRef(null);
@@ -38,13 +40,26 @@ const CodeMirrorEditor = ({
return () => observer.disconnect();
}, []);
// Detect if content is single-line (minified)
const isSingleLine = (value || '').split('\n').length === 1;
// Initialize editor only once
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
// Language extension
let langExtension = [];
if (language === 'json') {
langExtension = [json()];
} else if (language === 'sql') {
langExtension = [sql()];
}
const extensions = [
basicSetup,
language === 'json' ? json() : [],
...langExtension,
// Enable line wrapping for single-line content
...(isSingleLine ? [EditorView.lineWrapping] : []),
EditorView.theme({
'&': {
fontSize: '14px',
@@ -58,6 +73,13 @@ const CodeMirrorEditor = ({
},
'.cm-editor': {
borderRadius: '6px',
},
'.cm-scroller': {
overflowY: 'auto',
height: '100%',
},
'.cm-line': {
wordBreak: isSingleLine ? 'break-word' : 'normal',
}
}),
EditorView.updateListener.of((update) => {
@@ -80,6 +102,25 @@ const CodeMirrorEditor = ({
viewRef.current = view;
// Apply styles immediately after editor creation
setTimeout(() => {
const editorElement = editorRef.current?.querySelector('.cm-editor');
const scrollerElement = editorRef.current?.querySelector('.cm-scroller');
if (editorElement) {
editorElement.style.height = '350px';
editorElement.style.maxHeight = '350px';
}
if (scrollerElement) {
scrollerElement.style.overflowY = 'auto';
scrollerElement.style.overflowX = isSingleLine ? 'hidden' : 'auto';
scrollerElement.style.height = '100%';
}
// No manual wrapping needed - EditorView.lineWrapping handles it
}, 0);
return () => {
if (viewRef.current) {
viewRef.current.destroy();
@@ -87,13 +128,15 @@ const CodeMirrorEditor = ({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDark]); // Only recreate on theme change
}, [isDark, isSingleLine]); // Recreate when theme or line count changes
// Handle height changes without recreating editor
useEffect(() => {
if (!viewRef.current) return;
// Apply overflow and height styles
const applyEditorStyles = useCallback(() => {
if (!viewRef.current || !editorRef.current) return;
const editorElement = editorRef.current.querySelector('.cm-editor');
const scrollerElement = editorRef.current.querySelector('.cm-scroller');
const editorElement = editorRef.current?.querySelector('.cm-editor');
if (editorElement) {
if (isExpanded) {
editorElement.style.height = 'auto';
@@ -103,8 +146,19 @@ const CodeMirrorEditor = ({
editorElement.style.maxHeight = '350px';
}
}
// Handle scrolling
if (scrollerElement) {
scrollerElement.style.overflowY = isExpanded ? 'visible' : 'auto';
scrollerElement.style.height = '100%';
}
}, [isExpanded]);
// Apply styles on mount, expand/collapse, and content changes
useEffect(() => {
applyEditorStyles();
}, [applyEditorStyles]);
// Update content when value changes externally
useEffect(() => {
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
@@ -116,8 +170,11 @@ const CodeMirrorEditor = ({
}
});
viewRef.current.dispatch(transaction);
// Re-apply styles after content change (e.g., tab switch)
setTimeout(() => applyEditorStyles(), 10);
}
}, [value]);
}, [value, applyEditorStyles]);
return (
<div className={`relative ${className}`}>
@@ -132,7 +189,12 @@ const CodeMirrorEditor = ({
onClick={() => {
setIsExpanded(!isExpanded);
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
// Scroll to card header if cardRef is provided, otherwise scroll to top
if (cardRef?.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 50);
}}
className="absolute bottom-2 right-2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-600 shadow-sm z-10"

View File

@@ -1,13 +1,17 @@
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X } from 'lucide-react';
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X, Eye, Pencil } from 'lucide-react';
const StructuredEditor = ({ onDataChange, initialData = {} }) => {
const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyProp = false }) => {
const [data, setData] = useState(initialData);
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
const isInternalUpdate = useRef(false);
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
const [nestedData, setNestedData] = useState(null);
const [editMode, setEditMode] = useState(false); // Internal edit mode state
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
// Update internal data when initialData prop changes (but not from internal updates)
useEffect(() => {
@@ -533,7 +537,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{canExpand && (
<button
onClick={() => toggleNode(path)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
@@ -558,74 +562,106 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
// Object properties: icon + editable key + colon (compact)
<>
{getTypeIcon(value)}
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
/>
<span className="text-gray-500 hidden sm:inline">:</span>
{readOnly ? (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono">
{key}
</span>
) : (
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
/>
)}
<span className="text-gray-500 inline">:</span>
</>
)}
{!canExpand ? (
typeof value === 'boolean' ? (
<div className="flex-1 flex items-center space-x-2">
<button
onClick={() => updateValue((!value).toString(), path)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{value.toString()}
</span>
{readOnly ? (
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
{value.toString()}
</span>
) : (
<>
<button
onClick={() => updateValue((!value).toString(), path)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{value.toString()}
</span>
</>
)}
</div>
) : (
<div className="flex-1 flex items-center gap-2">
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
<textarea
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y items"
placeholder="Long text value"
rows={3}
/>
{readOnly ? (
typeof value === 'string' && detectNestedData(value) ? (
<span
onClick={() => openNestedEditor(value, path)}
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400 font-mono break-all cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title={`Click to view nested ${detectNestedData(value).type} data`}
>
{getDisplayValue(value)}
</span>
) : (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{getDisplayValue(value)}
</span>
)
) : (
<input
type="text"
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
placeholder="Value"
/>
)}
{typeof value === 'string' && detectNestedData(value) && (
<button
onClick={() => openNestedEditor(value, path)}
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
title={`Edit nested ${detectNestedData(value).type} data`}
>
<Edit3 className="h-4 w-4" />
</button>
<>
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
<textarea
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y items"
placeholder="Long text value"
rows={3}
/>
) : (
<input
type="text"
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
placeholder="Value"
/>
)}
{typeof value === 'string' && detectNestedData(value) && (
<button
onClick={() => openNestedEditor(value, path)}
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
title={`Edit nested ${detectNestedData(value).type} data`}
>
<Edit3 className="h-4 w-4" />
</button>
)}
</>
)}
</div>
)
@@ -635,38 +671,40 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
</span>
)}
<div className="flex items-center space-x-2 sm:space-x-2">
<select
value={
fieldTypes[path] || (
value === null ? 'null' :
value === undefined ? 'string' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'number' ? 'number' :
typeof value === 'boolean' ? 'boolean' :
Array.isArray(value) ? 'array' : 'object'
)
}
onChange={(e) => changeType(e.target.value, path)}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
>
<option value="string">String</option>
<option value="longtext">Long Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="array">Array</option>
<option value="object">Object</option>
<option value="null">Null</option>
</select>
<button
onClick={() => removeProperty(key, parentPath)}
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0"
title="Remove property"
>
<Minus className="h-4 w-4" />
</button>
</div>
{!readOnly && (
<div className="flex items-center space-x-2 sm:space-x-2">
<select
value={
fieldTypes[path] || (
value === null ? 'null' :
value === undefined ? 'string' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'number' ? 'number' :
typeof value === 'boolean' ? 'boolean' :
Array.isArray(value) ? 'array' : 'object'
)
}
onChange={(e) => changeType(e.target.value, path)}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
>
<option value="string">String</option>
<option value="longtext">Long Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="array">Array</option>
<option value="object">Object</option>
<option value="null">Null</option>
</select>
<button
onClick={() => removeProperty(key, parentPath)}
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0"
title="Remove property"
>
<Minus className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
@@ -677,26 +715,30 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{value.map((item, index) =>
renderValue(item, index.toString(), `${path}.${index}`, path)
)}
<button
onClick={() => addArrayItem(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Item</span>
</button>
{!readOnly && (
<button
onClick={() => addArrayItem(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Item</span>
</button>
)}
</>
) : (
<>
{Object.entries(value).map(([k, v]) =>
renderValue(v, k, `${path}.${k}`, path)
)}
<button
onClick={() => addProperty(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
{!readOnly && (
<button
onClick={() => addProperty(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
)}
</>
)}
</div>
@@ -708,29 +750,65 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
return (
<div className="min-h-96 w-full">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
<div className="flex flex-col gap-3 mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
{/* Mode Toggle - Below title on mobile, inline on desktop */}
{readOnlyProp === false && (
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm w-fit">
<button
onClick={() => setEditMode(false)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
!editMode
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Eye className="h-3.5 w-3.5" />
<span>Preview</span>
</button>
<button
onClick={() => setEditMode(true)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
editMode
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Pencil className="h-3.5 w-3.5" />
<span>Edit</span>
</button>
</div>
)}
</div>
</div>
<div className="overflow-x-hidden">
{Object.keys(data).length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
<div className="w-full overflow-hidden">
<div className="w-full overflow-x-auto">
<div className="min-w-max">
{Object.keys(data).length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
</div>
) : (
Object.entries(data).map(([key, value]) =>
renderValue(value, key, `root.${key}`, 'root')
)
)}
{/* Root level Add Property button */}
{!readOnly && (
<button
onClick={() => addProperty(data, 'root')}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
)}
</div>
) : (
Object.entries(data).map(([key, value]) =>
renderValue(value, key, `root.${key}`, 'root')
)
)}
{/* Root level Add Property button */}
<button
onClick={() => addProperty(data, 'root')}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
</div>
</div>
{/* Nested Data Editor Modal */}
@@ -749,7 +827,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
</div>
<button
onClick={closeNestedEditor}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded self-start"
>
<X className="h-5 w-5" />
</button>