Merge branch 'master' of https://github.com/naturalcrit/homebrewery into add-settings-editor

This commit is contained in:
Víctor Losada Hernández
2026-08-23 22:20:54 +02:00
+215 -237
View File
@@ -1,8 +1,6 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
import './editor.less'; import './editor.less';
import React from 'react'; import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import dedent from 'dedent'; import dedent from 'dedent';
import CodeEditor from '@components/codeEditor/codeEditor.jsx'; import CodeEditor from '@components/codeEditor/codeEditor.jsx';
@@ -19,13 +17,9 @@ import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery }; const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const EditorThemes = Object.entries(themes) const EditorThemes = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) && .filter(([name, value])=>Array.isArray(value) && !name.endsWith('Init') && !name.endsWith('Style'))
!name.endsWith('Init') &&
!name.endsWith('Style')
)
.map(([name])=>name); .map(([name])=>name);
//const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; //const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
//const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; //const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
const DEFAULT_STYLE_TEXT = dedent` const DEFAULT_STYLE_TEXT = dedent`
@@ -46,314 +40,298 @@ const DEFAULT_SNIPPET_TEXT = dedent`
let isJumping = false; let isJumping = false;
let jumpSource = null; let jumpSource = null;
const Editor = createReactClass({ const Editor = forwardRef(
displayName : 'Editor', (
getDefaultProps : function() { {
return { brew = {},
brew : {
text : '', onBrewChange = ()=>{},
style : '' reportError = ()=>{},
onCursorPageChange = ()=>{},
onViewPageChange = ()=>{},
editorTheme = 'default',
renderer = 'legacy',
moveBrew,
moveSource,
liveScroll,
setMoveArrows,
updateBrew,
showEditButtons,
themeBundle,
userThemes,
currentEditorCursorPageNum = 1,
currentEditorViewPageNum = 1,
currentBrewRendererPageNum = 1,
}, },
ref,
)=>{
const [currentEditorTheme, setEditorTheme] = useState(editorTheme);
const [view, setView] = useState('text'); // 'text', 'style', 'meta', 'snippet'
const [snippetBarHeight, setSnippetBarHeight] = useState(26);
onBrewChange : ()=>{}, const editor = useRef(null);
reportError : ()=>{}, const codeEditor = useRef(null);
const throttleBrewMove = useRef(null);
onCursorPageChange : ()=>{}, const isText = ()=>isView('text');
onViewPageChange : ()=>{}, const isStyle = ()=>isView('style');
const isMeta = ()=>isView('meta');
const isSnip = ()=>isView('snippet');
editorTheme : 'default', const isView = (name)=>view === name;
renderer : 'legacy',
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
};
},
getInitialState : function() {
return {
editorTheme : this.props.editorTheme,
view : 'text', //'text', 'style', 'meta', 'snippet'
snippetBarHeight : 26,
};
},
editor : React.createRef(null),
codeEditor : React.createRef(null),
isText : function() {return this.state.view == 'text';},
isStyle : function() {return this.state.view == 'style';},
isMeta : function() {return this.state.view == 'meta';},
isSnip : function() {return this.state.view == 'snippet';},
componentDidMount : function() {
useEffect(()=>{
const brewRenderer = document.getElementById('BrewRenderer'); const brewRenderer = document.getElementById('BrewRenderer');
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', this.handleControlKeys); brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', handleControlKeys);
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY); const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme && EditorThemes.includes(editorTheme)) { if(editorTheme && EditorThemes.includes(editorTheme)) setEditorTheme(editorTheme); else setEditorTheme('default');
this.setState({ editorTheme });
} else {
this.setState({ editorTheme: 'default' });
}
const snippetBar = document.querySelector('.editor > .snippetBar'); const snippetBar = document.querySelector('.editor > .snippetBar');
if(!snippetBar) return; if(!snippetBar) return;
this.resizeObserver = new ResizeObserver((entries)=>{ const resizeObserver = new ResizeObserver((entries)=>{
const height = document.querySelector('.editor > .snippetBar').offsetHeight; const height = document.querySelector('.editor > .snippetBar').offsetHeight;
this.setState({ snippetBarHeight: height }); setSnippetBarHeight(height);
}); });
resizeObserver.observe(snippetBar);
this.resizeObserver.observe(snippetBar); return ()=>{
}, if(resizeObserver) resizeObserver.disconnect();
};
}, []);
componentDidUpdate : function(prevProps, prevState, snapshot) { useEffect(()=>{ if(moveBrew) brewJump(); }, [moveBrew]);
useEffect(()=>{ if(moveSource) sourceJump(); }, [moveSource]);
useEffect(()=>{ if(liveScroll) sourceJump(currentBrewRendererPageNum, false); }, [currentBrewRendererPageNum, liveScroll]);
useEffect(()=>{ if(liveScroll) brewJump(currentEditorViewPageNum, false); }, [currentEditorViewPageNum, liveScroll]);
useEffect(()=>{ if(liveScroll) brewJump(currentEditorCursorPageNum, false); }, [currentEditorCursorPageNum, liveScroll]);
if(prevProps.moveBrew !== this.props.moveBrew) const handleControlKeys = (e)=>{
this.brewJump();
if(prevProps.moveSource !== this.props.moveSource)
this.sourceJump();
if(this.props.liveScroll) {
if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) {
this.sourceJump(this.props.currentBrewRendererPageNum, false);
} else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) {
this.brewJump(this.props.currentEditorViewPageNum, false);
} else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) {
this.brewJump(this.props.currentEditorCursorPageNum, false);
}
}
},
componentWillUnmount() {
if(this.resizeObserver) this.resizeObserver.disconnect();
},
handleControlKeys : function(e){
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return; if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
const LEFTARROW_KEY = 37; const LEFTARROW_KEY = 37;
const RIGHTARROW_KEY = 39; const RIGHTARROW_KEY = 39;
if(e.keyCode == RIGHTARROW_KEY) this.brewJump(); if(e.keyCode == RIGHTARROW_KEY) brewJump();
if(e.keyCode == LEFTARROW_KEY) this.sourceJump(); if(e.keyCode == LEFTARROW_KEY) sourceJump();
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) { if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} }
}, };
updateCurrentCursorPage : function(pageNumber) { const updateCurrentCursorPage = (pageNumber)=>{
this.props.onCursorPageChange(pageNumber); onCursorPageChange(pageNumber);
}, };
updateCurrentViewPage : function(pageNumber) { const updateCurrentViewPage = (pageNumber)=>{
this.props.onViewPageChange(pageNumber); onViewPageChange(pageNumber);
}, };
handleInject : function(injectText){ const handleInject = (injectText)=>{
this.codeEditor.current?.injectText(injectText); codeEditor.current?.injectText(injectText);
}, };
handleViewChange : function(newView){ const handleViewChange = (newView)=>{
this.props.setMoveArrows(newView === 'text'); setMoveArrows(newView === 'text');
setView(newView);
};
useEffect(()=>{
codeEditor.current?.focus();
}, [view]);
this.setState({ const brewJump = (targetPage = currentEditorCursorPageNum, smooth = true)=>{
view : newView if(!window || !isText() || isJumping || jumpSource === 'source') return;
}, ()=>{
this.codeEditor.current?.focus();
});
},
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){ const brewRenderer =
if(!window || !this.isText() || isJumping || jumpSource === 'source') window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
return;
// Get current brewRenderer scroll position and calculate target position
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
const currentPos = brewRenderer.scrollTop; const currentPos = brewRenderer.scrollTop;
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
const targetPos = window.frames['BrewRenderer'].contentDocument
.getElementById(`p${targetPage}`)
.getBoundingClientRect().top;
let scrollingTimeout; let scrollingTimeout;
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs const checkIfScrollComplete = ()=>{// Prevent interrupting a scroll in progress if user clicks multiple times
clearTimeout(scrollingTimeout);// Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{ scrollingTimeout = setTimeout(()=>{
isJumping = false; isJumping = false;
jumpSource = null; jumpSource = null;
brewRenderer.removeEventListener('scroll', checkIfScrollComplete); brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done }, 150);// If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
}; };
isJumping = true; isJumping = true;
jumpSource = 'brew'; jumpSource = 'brew';
checkIfScrollComplete(); checkIfScrollComplete();
brewRenderer.addEventListener('scroll', checkIfScrollComplete); brewRenderer.addEventListener('scroll', checkIfScrollComplete);
if(smooth) { if(smooth) {
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
const bounceDelay = 100; const now = Date.now();
const scrollDelay = 500;
if(now - throttleBrewMove.current >= 500) {
throttleBrewMove.current = now;
if(!this.throttleBrewMove) {
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' }); brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
setTimeout(()=>{ setTimeout(()=>{
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' }); brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
}, bounceDelay); }, 100);
}, scrollDelay, { leading: true, trailing: false });
};
this.throttleBrewMove(currentPos, bouncePos, targetPos);
} else {
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
} }
}, } else {
brewRenderer.scrollTo({ top : currentPos + targetPos, behavior : 'instant', block : 'start',
});
}
};
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){ const sourceJump = (targetPage = currentBrewRendererPageNum, smooth = true)=>{
if(!this.isText() || isJumping || jumpSource === 'brew') if(!isText() || isJumping || jumpSource === 'brew') return;
return;
const editor = this.codeEditor.current; if(!codeEditor.current) return;
if(!editor) return;
jumpSource = 'source'; jumpSource = 'source';
editor.scrollToPage(targetPage); codeEditor.current.scrollToPage(targetPage);
setTimeout(()=>{ setTimeout(()=>{
jumpSource = null; jumpSource = null;
}, 200); }, 200);
}, };
//Called when there are changes to the editor's dimensions const updateEditorTheme = (newTheme)=>{
update : function(){},
updateEditorTheme : function(newTheme){
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme); window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
this.setState({ setEditorTheme(newTheme);
editorTheme : newTheme };
});
},
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory const renderEditor = ()=>{
rerenderParent : function (){ if(isText()) {
this.forceUpdate(); return (
}, <>
<CodeEditor
renderEditor : function(){ key='codeEditor'
if(this.isText()){ ref={codeEditor}
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm' language='gfm'
tab='brewText' tab='brewText'
view={this.state.view} view={view}
value={this.props.brew.text} value={brew.text}
onChange={this.props.onBrewChange('text')} onChange={onBrewChange('text')}
onCursorChange={(page)=>this.updateCurrentCursorPage(page)} onCursorChange={(page)=>updateCurrentCursorPage(page)}
onViewChange={(page)=>this.updateCurrentViewPage(page)} onViewChange={(page)=>updateCurrentViewPage(page)}
editorTheme={this.state.editorTheme} editorTheme={currentEditorTheme}
renderer={this.props.brew.renderer} renderer={brew.renderer}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/> style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
</>; />
</>
);
} }
if(this.isStyle()){ if(isStyle()) {
return <> return (
<CodeEditor key='codeEditor' <>
ref={this.codeEditor} <CodeEditor
key='codeEditor'
ref={codeEditor}
language='css' language='css'
tab='brewStyles' tab='brewStyles'
view={this.state.view} view={view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT} value={brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onBrewChange('style')} onChange={onBrewChange('style')}
editorTheme={this.state.editorTheme} editorTheme={currentEditorTheme}
renderer={this.props.brew.renderer} renderer={brew.renderer}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/> style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
</>; />
</>
);
} }
if(this.isMeta()){ if(isSnip()) {
return <> if(!brew.snippets) {
<CodeEditor key='codeEditor' brew.snippets = DEFAULT_SNIPPET_TEXT;
view={this.state.view}
style={{ display: 'none' }}/>
<MetadataEditor
metadata={this.props.brew}
themeBundle={this.props.themeBundle}
onChange={this.props.onBrewChange('metadata')}
reportError={this.props.reportError}
userThemes={this.props.userThemes}/>
</>;
} }
if(this.isSnip()){ return (
if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; } <>
return <> <CodeEditor
<CodeEditor key='codeEditor' key='codeEditor'
ref={this.codeEditor} ref={codeEditor}
language='gfm' language='gfm'
tab='brewSnippets' tab='brewSnippets'
view={this.state.view} view={view}
value={this.props.brew.snippets} value={brew.snippets}
onChange={this.props.onBrewChange('snippets')} onChange={onBrewChange('snippets')}
enableFolding={true} enableFolding={true}
editorTheme={this.state.editorTheme} editorTheme={currentEditorTheme}
renderer={this.props.brew.renderer} renderer={brew.renderer}
rerenderParent={this.rerenderParent} style={{ height: `calc(100% - 25px)` }}
style={{ height: `calc(100% - 25px)` }}/> />
</>;
}
if(this.isSettings()){
return <>
<CodeEditor key='codeEditor'
view={this.state.view}
style={{ display: 'none' }}/>
<SettingsEditor />
</> </>
);
} }
}, if(isMeta()) {
redo : function(){
return this.codeEditor.current?.redo();
},
historySize : function(){
return this.codeEditor.current?.historySize();
},
undo : function(){
return this.codeEditor.current?.undo();
},
foldCode : function() {
return this.codeEditor.current?.foldAll();
},
unfoldCode : function() {
return this.codeEditor.current?.unfoldAll();
},
render : function(){
return ( return (
<div className='editor' ref={this.editor}> <>
<CodeEditor key='codeEditor' view={view} style={{ display: 'none' }} />
<MetadataEditor
metadata={brew}
themeBundle={themeBundle}
onChange={onBrewChange('metadata')}
reportError={reportError}
userThemes={userThemes}
/>
</>
);
}
};
const redo = ()=>codeEditor.current?.redo();
const historySize = ()=>codeEditor.current?.historySize();
const undo = ()=>codeEditor.current?.undo();
const foldCode = ()=>codeEditor.current?.foldAll();
const unfoldCode = ()=>codeEditor.current?.unfoldAll();
//Called when there are changes to the editor's dimensions
const update = ()=>{};
useImperativeHandle(ref, ()=>({
update,
undo,
redo,
foldCode,
unfoldCode,
historySize,
}));
return (
<div className='editor' ref={editor}>
<SnippetBar <SnippetBar
brew={this.props.brew} brew={brew}
view={this.state.view} view={view}
onViewChange={this.handleViewChange} onViewChange={handleViewChange}
onInject={this.handleInject} onInject={handleInject}
showEditButtons={this.props.showEditButtons} showEditButtons={showEditButtons}
renderer={this.props.renderer} renderer={renderer}
theme={this.props.brew.theme} theme={brew.theme}
undo={this.undo} undo={undo}
redo={this.redo} redo={redo}
foldCode={this.foldCode} foldCode={foldCode}
unfoldCode={this.unfoldCode} unfoldCode={unfoldCode}
historySize={this.historySize()} historySize={historySize()}
currentEditorTheme={this.state.editorTheme} currentEditorTheme={currentEditorTheme}
updateEditorTheme={this.updateEditorTheme} updateEditorTheme={updateEditorTheme}
themeBundle={this.props.themeBundle} themeBundle={themeBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} cursorPos={codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew} updateBrew={updateBrew}
/> />
{this.renderEditor()} {renderEditor()}
</div> </div>
); );
} }
}); );
export default Editor; export default Editor;