this is mostly working

This commit is contained in:
Víctor Losada Hernández
2026-08-23 19:52:11 +02:00
parent f88d6fd844
commit f53ed8c8e7
+342 -269
View File
@@ -1,29 +1,22 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ import "./editor.less";
import './editor.less'; import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
import React from 'react'; import dedent from "dedent";
import createReactClass from 'create-react-class';
import _ from 'lodash';
import dedent from 'dedent';
import CodeEditor from '@components/codeEditor/codeEditor.jsx'; import CodeEditor from "@components/codeEditor/codeEditor.jsx";
import SnippetBar from './snippetbar/snippetbar.jsx'; import SnippetBar from "./snippetbar/snippetbar.jsx";
import MetadataEditor from './metadataEditor/metadataEditor.jsx'; import MetadataEditor from "./metadataEditor/metadataEditor.jsx";
const EDITOR_THEME_KEY = 'HB_editor_theme'; const EDITOR_THEME_KEY = "HB_editor_theme";
import defaultCM5Theme from '@themes/codeMirror/default.js'; import defaultCM5Theme from "@themes/codeMirror/default.js";
import darkbrewery from '@themes/codeMirror/darkbrewery.js'; import darkbrewery from "@themes/codeMirror/darkbrewery.js";
import cm5Themes from 'codemirror-5-themes'; 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') && .map(([name]) => name);
!name.endsWith('Style')
)
.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\ .*$/;
@@ -45,306 +38,386 @@ 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 = () => {
onViewPageChange : ()=>{}, return view === "text";
editorTheme : 'default',
renderer : 'legacy',
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
}; };
}, const isStyle = () => {
getInitialState : function() { return view === "style";
return { };
editorTheme : this.props.editorTheme, const isMeta = () => {
view : 'text', //'text', 'style', 'meta', 'snippet' return view === "meta";
snippetBarHeight : 26, };
const isSnip = () => {
return view === "snippet";
}; };
},
editor : React.createRef(null), //componentDidMount equivalent
codeEditor : React.createRef(null), useEffect(() => {
const brewRenderer = document.getElementById("BrewRenderer");
isText : function() {return this.state.view == 'text';}, brewRenderer.onload = () => brewRenderer.contentDocument?.addEventListener("keydown", handleControlKeys);
isStyle : function() {return this.state.view == 'style';}, document.addEventListener("keydown", handleControlKeys);
isMeta : function() {return this.state.view == 'meta';},
isSnip : function() {return this.state.view == 'snippet';},
componentDidMount : function() {
const brewRenderer = document.getElementById('BrewRenderer');
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.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)) {
this.setState({ editorTheme }); setEditorTheme(editorTheme);
} else { } else {
this.setState({ editorTheme: 'default' }); setEditorTheme("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);
}); });
this.resizeObserver.observe(snippetBar); resizeObserver.observe(snippetBar);
},
componentDidUpdate : function(prevProps, prevState, snapshot) { //ComponentWillUnmount equivalent
return () => {
if (resizeObserver) resizeObserver.disconnect();
};
}, []);
if(prevProps.moveBrew !== this.props.moveBrew) const previousProps = useRef({
this.brewJump(); moveBrew,
moveSource,
currentBrewRendererPageNum,
currentEditorViewPageNum,
currentEditorCursorPageNum,
});
if(prevProps.moveSource !== this.props.moveSource) //componentDidUpdate Equivalent
this.sourceJump(); useEffect(() => {
const prev = previousProps.current;
if(this.props.liveScroll) { if (prev.moveBrew !== moveBrew) {
if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) { brewJump();
this.sourceJump(this.props.currentBrewRendererPageNum, false); }
} else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) {
this.brewJump(this.props.currentEditorViewPageNum, false); if (prev.moveSource !== moveSource) {
} else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) { sourceJump();
this.brewJump(this.props.currentEditorCursorPageNum, false); }
if (liveScroll) {
if (prev.currentBrewRendererPageNum !== currentBrewRendererPageNum) {
sourceJump(currentBrewRendererPageNum, false);
} else if (prev.currentEditorViewPageNum !== currentEditorViewPageNum) {
brewJump(currentEditorViewPageNum, false);
} else if (prev.currentEditorCursorPageNum !== currentEditorCursorPageNum) {
brewJump(currentEditorCursorPageNum, false);
} }
} }
},
componentWillUnmount() { previousProps.current = {
if(this.resizeObserver) this.resizeObserver.disconnect(); moveBrew,
}, moveSource,
currentBrewRendererPageNum,
currentEditorViewPageNum,
currentEditorCursorPageNum,
};
}, [
moveBrew,
moveSource,
liveScroll,
currentBrewRendererPageNum,
currentEditorViewPageNum,
currentEditorCursorPageNum,
]);
handleControlKeys : function(e){ const handleControlKeys = (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();
} }
}, };
const updateCurrentCursorPage = (pageNumber) => {
onCursorPageChange(pageNumber);
};
updateCurrentCursorPage : function(pageNumber) { const updateCurrentViewPage = (pageNumber) => {
this.props.onCursorPageChange(pageNumber); onViewPageChange(pageNumber);
}, };
updateCurrentViewPage : function(pageNumber) { const handleInject = (injectText) => {
this.props.onViewPageChange(pageNumber); codeEditor.current?.injectText(injectText);
}, };
handleInject : function(injectText){ const handleViewChange = (newView) => {
this.codeEditor.current?.injectText(injectText); setMoveArrows(newView === "text");
}, setView(newView);
};
handleViewChange : function(newView){ useEffect(() => {
this.props.setMoveArrows(newView === 'text'); 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 = () => {
scrollingTimeout = setTimeout(()=>{ clearTimeout(scrollingTimeout);
scrollingTimeout = setTimeout(() => {
isJumping = false; isJumping = false;
jumpSource = null; jumpSource = null;
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done brewRenderer.removeEventListener("scroll", checkIfScrollComplete);
}, 150);
}; };
isJumping = true; isJumping = true;
jumpSource = 'brew'; jumpSource = "brew";
checkIfScrollComplete();
brewRenderer.addEventListener('scroll', checkIfScrollComplete);
if(smooth) { checkIfScrollComplete();
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
brewRenderer.addEventListener("scroll", checkIfScrollComplete);
if (smooth) {
const bouncePos = targetPos >= 0 ? -30 : 30;
const bounceDelay = 100; const bounceDelay = 100;
const scrollDelay = 500; const scrollDelay = 500;
if(!this.throttleBrewMove) { if (!throttleBrewMove.current) {
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{ throttleBrewMove.current = _.throttle(
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' }); (currentPos, bouncePos, targetPos) => {
setTimeout(()=>{ brewRenderer.scrollTo({
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' }); top: currentPos + bouncePos,
}, bounceDelay); behavior: "smooth",
}, scrollDelay, { leading: true, trailing: false });
};
this.throttleBrewMove(currentPos, bouncePos, targetPos);
} else {
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
}
},
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(!this.isText() || isJumping || jumpSource === 'brew')
return;
const editor = this.codeEditor.current;
if(!editor) return;
jumpSource = 'source';
editor.scrollToPage(targetPage);
setTimeout(()=>{
jumpSource = null;
}, 200);
},
//Called when there are changes to the editor's dimensions
update : function(){},
updateEditorTheme : function(newTheme){
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
this.setState({
editorTheme : newTheme
}); });
},
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory setTimeout(() => {
rerenderParent : function (){ brewRenderer.scrollTo({
this.forceUpdate(); top: currentPos + targetPos,
behavior: "smooth",
block: "start",
});
}, bounceDelay);
}, },
scrollDelay,
renderEditor : function(){ {
if(this.isText()){ leading: true,
return <> trailing: false,
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
tab='brewText'
view={this.state.view}
value={this.props.brew.text}
onChange={this.props.onBrewChange('text')}
onCursorChange={(page)=>this.updateCurrentCursorPage(page)}
onViewChange={(page)=>this.updateCurrentViewPage(page)}
editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/>
</>;
}
if(this.isStyle()){
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='css'
tab='brewStyles'
view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onBrewChange('style')}
editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/>
</>;
}
if(this.isMeta()){
return <>
<CodeEditor key='codeEditor'
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()){
if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; }
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
tab='brewSnippets'
view={this.state.view}
value={this.props.brew.snippets}
onChange={this.props.onBrewChange('snippets')}
enableFolding={true}
editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - 25px)` }}/>
</>;
}
}, },
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 (
<div className='editor' ref={this.editor}>
<SnippetBar
brew={this.props.brew}
view={this.state.view}
onViewChange={this.handleViewChange}
onInject={this.handleInject}
showEditButtons={this.props.showEditButtons}
renderer={this.props.renderer}
theme={this.props.brew.theme}
undo={this.undo}
redo={this.redo}
foldCode={this.foldCode}
unfoldCode={this.unfoldCode}
historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme}
themeBundle={this.props.themeBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew}
/>
{this.renderEditor()}
</div>
); );
} }
});
throttleBrewMove.current(currentPos, bouncePos, targetPos);
} else {
brewRenderer.scrollTo({
top: currentPos + targetPos,
behavior: "instant",
block: "start",
});
}
};
const sourceJump = (targetPage = currentBrewRendererPageNum, smooth = true) => {
if (!isText() || isJumping || jumpSource === "brew") return;
const editor = codeEditor.current;
if (!editor) return;
jumpSource = "source";
editor.scrollToPage(targetPage);
setTimeout(() => {
jumpSource = null;
}, 200);
};
const updateEditorTheme = (newTheme) => {
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
setEditorTheme(newTheme);
};
const renderEditor = () => {
if (isText()) {
return (
<>
<CodeEditor
key="codeEditor"
ref={codeEditor}
language="gfm"
tab="brewText"
view={view}
value={brew.text}
onChange={onBrewChange("text")}
onCursorChange={(page) => updateCurrentCursorPage(page)}
onViewChange={(page) => updateCurrentViewPage(page)}
editorTheme={currentEditorTheme}
renderer={brew.renderer}
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
/>
</>
);
}
if (isStyle()) {
return (
<>
<CodeEditor
key="codeEditor"
ref={codeEditor}
language="css"
tab="brewStyles"
view={view}
value={brew.style ?? DEFAULT_STYLE_TEXT}
onChange={onBrewChange("style")}
editorTheme={currentEditorTheme}
renderer={brew.renderer}
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
/>
</>
);
}
if (isMeta()) {
return (
<>
<CodeEditor key="codeEditor" view={view} style={{ display: "none" }} />
<MetadataEditor
metadata={brew}
themeBundle={themeBundle}
onChange={onBrewChange("metadata")}
reportError={reportError}
userThemes={userThemes}
/>
</>
);
}
if (isSnip()) {
if (!brew.snippets) {
brew.snippets = DEFAULT_SNIPPET_TEXT;
}
return (
<>
<CodeEditor
key="codeEditor"
ref={codeEditor}
language="gfm"
tab="brewSnippets"
view={view}
value={brew.snippets}
onChange={onBrewChange("snippets")}
enableFolding={true}
editorTheme={currentEditorTheme}
renderer={brew.renderer}
rerenderParent={rerenderParent}
style={{ height: `calc(100% - 25px)` }}
/>
</>
);
}
};
const redo = () => {
return codeEditor.current?.redo();
};
const historySize = () => {
return codeEditor.current?.historySize();
};
const undo = () => {
return codeEditor.current?.undo();
};
const foldCode = () => {
return codeEditor.current?.foldAll();
};
const unfoldCode = () => {
return codeEditor.current?.unfoldAll();
};
const update = () => {};
useImperativeHandle(ref, () => ({
update,
undo,
redo,
foldCode,
unfoldCode,
historySize,
}));
return (
<div className="editor" ref={editor}>
<SnippetBar
brew={brew}
view={view}
onViewChange={handleViewChange}
onInject={handleInject}
showEditButtons={showEditButtons}
renderer={renderer}
theme={brew.theme}
undo={undo}
redo={redo}
foldCode={foldCode}
unfoldCode={unfoldCode}
historySize={historySize()}
currentEditorTheme={editorTheme}
updateEditorTheme={updateEditorTheme}
themeBundle={themeBundle}
cursorPos={codeEditor.current?.getCursorPosition() || {}}
updateBrew={updateBrew}
/>
{renderEditor()}
</div>
);
},
);
export default Editor; export default Editor;