0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-08 03:12:40 +00:00

Merge branch 'master' of https://github.com/naturalcrit/homebrewery into adress-small-accessibility-concerns

This commit is contained in:
Víctor Losada Hernández
2024-12-09 21:52:25 +01:00
93 changed files with 3337 additions and 2398 deletions

View File

@@ -4,7 +4,7 @@ const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const dedent = require('dedent-tabs').default;
const Markdown = require('../../../shared/naturalcrit/markdown.js');
import Markdown from '../../../shared/naturalcrit/markdown.js';
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx');
@@ -314,7 +314,7 @@ const Editor = createClass({
},
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || isJumping)
if(!window || !this.isText() || isJumping)
return;
// Get current brewRenderer scroll position and calculate target position
@@ -355,7 +355,7 @@ const Editor = createClass({
},
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(!this.isText || isJumping)
if(!this.isText() || isJumping)
return;
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;

View File

@@ -2,6 +2,7 @@
.editor {
position : relative;
width : 100%;
container: editor / inline-size;
.codeEditor {
height : 100%;

View File

@@ -3,10 +3,10 @@ require('./metadataEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const request = require('../../utils/request-middleware.js');
import request from '../../utils/request-middleware.js';
const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx');
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const TagInput = require('../tagInput/tagInput.jsx');
const Themes = require('themes/themes.json');
@@ -341,10 +341,11 @@ const MetadataEditor = createClass({
{this.renderThumbnail()}
</div>
<StringArrayEditor label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
placeholder='add tag' unique={true}
values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)}/>
onChange={(e)=>this.handleFieldChange('tags', e)}
/>
<div className='field systems'>
<label>systems</label>
@@ -363,12 +364,13 @@ const MetadataEditor = createClass({
{this.renderAuthors()}
<StringArrayEditor label='invited authors' valuePatterns={[/.+/]}
<TagInput label='invited authors' valuePatterns={[/.+/]}
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
placeholder='invite author' unique={true}
values={this.props.metadata.invitedAuthors}
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}/>
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
/>
<h2>Privacy</h2>

View File

@@ -79,6 +79,7 @@
text-overflow : ellipsis;
}
button {
.colorButton();
padding : 0px 5px;
color : white;
background-color : black;
@@ -138,16 +139,16 @@
margin-bottom : 15px;
button { width : 100%; }
button.publish {
.button(@blueLight);
.colorButton(@blueLight);
}
button.unpublish {
.button(@silver);
.colorButton(@silver);
}
}
.delete.field .value {
button {
.button(@red);
.colorButton(@red);
}
}
.authors.field .value {
@@ -271,7 +272,7 @@
&:last-child { border-radius : 0 0.5em 0.5em 0; }
}
.badge {
.tag {
padding : 0.3em;
margin : 2px;
font-size : 0.9em;

View File

@@ -150,18 +150,22 @@ const Snippetbar = createClass({
renderSnippetGroups : function(){
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
if(snippets.length === 0) return null;
return _.map(snippets, (snippetGroup)=>{
return <SnippetGroup
brew={this.props.brew}
groupName={snippetGroup.groupName}
icon={snippetGroup.icon}
snippets={snippetGroup.snippets}
key={snippetGroup.groupName}
onSnippetClick={this.handleSnippetClick}
cursorPos={this.props.cursorPos}
/>;
});
return <div className='snippets'>
{_.map(snippets, (snippetGroup)=>{
return <SnippetGroup
brew={this.props.brew}
groupName={snippetGroup.groupName}
icon={snippetGroup.icon}
snippets={snippetGroup.snippets}
key={snippetGroup.groupName}
onSnippetClick={this.handleSnippetClick}
cursorPos={this.props.cursorPos}
/>;
})
}
</div>;
},
replaceContent : function(item){
@@ -203,57 +207,58 @@ const Snippetbar = createClass({
renderEditorButtons : function(){
if(!this.props.showEditButtons) return;
let foldButtons;
if(this.props.view == 'text'){
foldButtons =
<>
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
</>;
}
const foldButtons = <>
<div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
</>;
return <div className='editors'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() }
<div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() }
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
<div className='divider'></div>
{foldButtons}
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
<div className='codeTools'>
{foldButtons}
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div>
</div>
<div className='divider'></div>
<div className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
<i className='fa fa-beer' />
</div>
<div className={cx('style', { selected: this.props.view === 'style' })}
onClick={()=>this.props.onViewChange('style')}>
<i className='fa fa-paint-brush' />
</div>
<div className={cx('meta', { selected: this.props.view === 'meta' })}
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
<div className='tabs'>
<div className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
<i className='fa fa-beer' />
</div>
<div className={cx('style', { selected: this.props.view === 'style' })}
onClick={()=>this.props.onViewChange('style')}>
<i className='fa fa-paint-brush' />
</div>
<div className={cx('meta', { selected: this.props.view === 'meta' })}
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</div>
</div>
</div>;
},
@@ -291,8 +296,9 @@ const SnippetGroup = createClass({
return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
<i className={snippet.icon} />
<span className='name'title={snippet.name}>{snippet.name}</span>
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
{snippet.experimental && <span className='beta'>beta</span>}
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <>
<i className='fas fa-caret-right'></i>
<div className='dropdown side'>

View File

@@ -4,105 +4,114 @@
.snippetBar {
@menuHeight : 25px;
position : relative;
height : @menuHeight;
display : flex;
flex-wrap : wrap-reverse;
justify-content : space-between;
height : auto;
color : black;
background-color : #DDDDDD;
.editors {
position : absolute;
top : 0px;
right : 0px;
.snippets {
display : flex;
justify-content : space-between;
height : @menuHeight;
& > div {
width : @menuHeight;
height : @menuHeight;
line-height : @menuHeight;
text-align : center;
&:not(.editorTool, .divider) { cursor : pointer; }
justify-content : flex-start;
min-width : 327.58px;
}
&:hover,&.selected { background-color : #999999; }
.editors {
display : flex;
justify-content : flex-end;
min-width : 225px;
&.editorTool {
background-color : unset;
cursor:not-allowed;
&:only-child { margin-left : auto; }
&.active {
cursor : pointer;
&:hover { background-color : #999999; }
>div {
display : flex;
flex : 1;
justify-content : space-around;
&:first-child { border-left : none; }
& > div {
position : relative;
width : @menuHeight;
height : @menuHeight;
line-height : @menuHeight;
text-align : center;
cursor : pointer;
&:hover,&.selected { background-color : #999999; }
&.text {
.tooltipLeft('Brew Editor');
}
&.style {
.tooltipLeft('Style Editor');
}
&.meta {
.tooltipLeft('Properties');
}
&.undo {
.tooltipLeft('Undo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.redo {
.tooltipLeft('Redo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.foldAll {
.tooltipLeft('Fold All');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.unfoldAll {
.tooltipLeft('Unfold All');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.history {
.tooltipLeft('History');
position : relative;
font-size : 0.75em;
color : grey;
border : none;
&.active { color : inherit; }
& > .dropdown {
right : -1px;
& > .snippet { padding-right : 10px; }
}
}
&.editorTheme {
.tooltipLeft('Editor Themes');
font-size : 0.75em;
color : black;
&.active {
position : relative;
background-color : #999999;
}
}
&.divider {
width : 5px;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
&:hover { background-color : inherit; }
}
}
&.text {
.tooltipLeft('Brew Editor');
.themeSelector {
position : absolute;
top : 25px;
right : 0;
z-index : 10;
display : flex;
align-items : center;
justify-content : center;
width : 170px;
height : inherit;
background-color : inherit;
}
&.style {
.tooltipLeft('Style Editor');
}
&.meta {
.tooltipLeft('Properties');
}
&.undo {
.tooltipLeft('Undo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.redo {
.tooltipLeft('Redo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.foldAll {
.tooltipLeft('Fold All');
font-size : 0.75em;
color : inherit;
}
&.unfoldAll {
.tooltipLeft('Unfold All');
font-size : 0.75em;
color : inherit;
}
&.history {
.tooltipLeft('History');
position : relative;
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
& > .dropdown {
right : -1px;
& > .snippet { padding-right : 10px; }
}
}
&.editorTheme {
.tooltipLeft('Editor Themes');
font-size : 0.75em;
color : black;
&.active {
position : relative;
background-color : #999999;
}
}
&.divider {
width : 5px;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
&:hover { background-color : inherit; }
}
}
.themeSelector {
position : absolute;
top : 25px;
right : 0;
z-index : 10;
display : flex;
align-items : center;
justify-content : center;
width : 170px;
height : inherit;
background-color : inherit;
}
}
}
.snippetBarButton {
display : inline-block;
@@ -112,6 +121,7 @@
font-weight : 800;
line-height : @menuHeight;
text-transform : uppercase;
text-wrap : nowrap;
cursor : pointer;
&:hover, &.selected { background-color : #999999; }
i {
@@ -128,7 +138,7 @@
.tooltipLeft('Edit Brew Properties');
}
.snippetGroup {
border-right : 1px solid currentColor;
&:hover {
& > .dropdown { visibility : visible; }
}
@@ -187,6 +197,7 @@
}
}
.name { margin-right : auto; }
.disabled { text-decoration : line-through; }
.beta {
align-self : center;
padding : 4px 6px;
@@ -212,4 +223,19 @@
}
}
}
}
}
@container editor (width < 553px) {
.snippetBar {
.editors {
flex : 1;
justify-content : space-between;
border-bottom : 1px solid;
}
.snippets {
flex : 1;
justify-content : space-evenly;
}
.editors > div.history > .dropdown { right : unset; }
}
}

View File

@@ -1,149 +0,0 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const StringArrayEditor = createClass({
displayName : 'StringArrayEditor',
getDefaultProps : function() {
return {
label : '',
values : [],
valuePatterns : null,
validators : [],
placeholder : '',
notes : [],
unique : false,
cannotEdit : [],
onChange : ()=>{}
};
},
getInitialState : function() {
return {
valueContext : !!this.props.values ? this.props.values.map((value)=>({
value,
editing : false
})) : [],
temporaryValue : '',
updateValue : ''
};
},
componentDidUpdate : function(prevProps) {
if(!_.eq(this.props.values, prevProps.values)) {
this.setState({
valueContext : this.props.values ? this.props.values.map((newValue)=>({
value : newValue,
editing : this.state.valueContext.find(({ value })=>value === newValue)?.editing || false
})) : []
});
}
},
handleChange : function(value) {
this.props.onChange({
target : {
value
}
});
},
addValue : function(value){
this.handleChange(_.uniq([...this.props.values, value]));
this.setState({
temporaryValue : ''
});
},
removeValue : function(index){
this.handleChange(this.props.values.filter((_, i)=>i !== index));
},
updateValue : function(value, index){
const valueContext = this.state.valueContext;
valueContext[index].value = value;
valueContext[index].editing = false;
this.handleChange(valueContext.map((context)=>context.value));
this.setState({ valueContext, updateValue: '' });
},
editValue : function(index){
if(!!this.props.cannotEdit && this.props.cannotEdit.includes(this.props.values[index])) {
return;
}
const valueContext = this.state.valueContext.map((context, i)=>{
context.editing = index === i;
return context;
});
this.setState({ valueContext, updateValue: this.props.values[index] });
},
valueIsValid : function(value, index) {
const values = _.clone(this.props.values);
if(index !== undefined) {
values.splice(index, 1);
}
const matchesPatterns = !this.props.valuePatterns || this.props.valuePatterns.some((pattern)=>!!(value || '').match(pattern));
const uniqueIfSet = !this.props.unique || !values.includes(value);
const passesValidators = !this.props.validators || this.props.validators.every((validator)=>validator(value));
return matchesPatterns && uniqueIfSet && passesValidators;
},
handleValueInputKeyDown : function(event, index) {
if(event.key === 'Enter') {
if(this.valueIsValid(event.target.value, index)) {
if(index !== undefined) {
this.updateValue(event.target.value, index);
} else {
this.addValue(event.target.value);
}
}
} else if(event.key === 'Escape') {
this.closeEditInput(index);
}
},
closeEditInput : function(index) {
const valueContext = this.state.valueContext;
valueContext[index].editing = false;
this.setState({ valueContext, updateValue: '' });
},
render : function() {
const valueElements = Object.values(this.state.valueContext).map((context, i)=>context.editing
? <React.Fragment key={i}>
<div className='input-group'>
<input type='text' className={`value ${this.valueIsValid(this.state.updateValue, i) ? '' : 'invalid'}`} autoFocus placeholder={this.props.placeholder}
value={this.state.updateValue}
onKeyDown={(e)=>this.handleValueInputKeyDown(e, i)}
onChange={(e)=>this.setState({ updateValue: e.target.value })}/>
{<div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.closeEditInput(i); }}><i className='fa fa-undo fa-fw'/></div>}
{this.valueIsValid(this.state.updateValue, i) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.updateValue(this.state.updateValue, i); }}><i className='fa fa-check fa-fw'/></div> : null}
</div>
</React.Fragment>
: <div className='badge' key={i} onClick={()=>this.editValue(i)}>{context.value}
{!!this.props.cannotEdit && this.props.cannotEdit.includes(context.value) ? null : <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.removeValue(i); }}><i className='fa fa-times fa-fw'/></div>}
</div>
);
return <div className='field'>
<label>{this.props.label}</label>
<div style={{ flex: '1 0' }} className='value'>
<div className='list'>
{valueElements}
<div className='input-group'>
<input type='text' className={`value ${this.valueIsValid(this.state.temporaryValue) ? '' : 'invalid'}`} placeholder={this.props.placeholder}
value={this.state.temporaryValue}
onKeyDown={(e)=>this.handleValueInputKeyDown(e)}
onChange={(e)=>this.setState({ temporaryValue: e.target.value })}/>
{this.valueIsValid(this.state.temporaryValue) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.addValue(this.state.temporaryValue); }}><i className='fa fa-check fa-fw'/></div> : null}
</div>
</div>
{this.props.notes ? this.props.notes.map((n, index)=><p key={index}><small>{n}</small></p>) : null}
</div>
</div>;
}
});
module.exports = StringArrayEditor;

View File

@@ -0,0 +1,105 @@
require('./tagInput.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
const TagInput = ({ unique = true, values = [], ...props }) => {
const [tempInputText, setTempInputText] = useState('');
const [tagList, setTagList] = useState(values.map((value) => ({ value, editing: false })));
useEffect(()=>{
handleChange(tagList.map((context)=>context.value))
}, [tagList])
const handleChange = (value)=>{
props.onChange({
target : { value }
})
};
const handleInputKeyDown = ({ evt, value, index, options = {} }) => {
if (_.includes(['Enter', ','], evt.key)) {
evt.preventDefault();
submitTag(evt.target.value, value, index);
if (options.clear) {
setTempInputText('');
}
}
};
const submitTag = (newValue, originalValue, index) => {
setTagList((prevContext) => {
// remove existing tag
if(newValue === null){
return [...prevContext].filter((context, i)=>i !== index);
}
// add new tag
if(originalValue === null){
return [...prevContext, { value: newValue, editing: false }]
}
// update existing tag
return prevContext.map((context, i) => {
if (i === index) {
return { ...context, value: newValue, editing: false };
}
return context;
});
});
};
const editTag = (index) => {
setTagList((prevContext) => {
return prevContext.map((context, i) => {
if (i === index) {
return { ...context, editing: true };
}
return { ...context, editing: false };
});
});
};
const renderReadTag = (context, index) => {
return (
<li key={index}
data-value={context.value}
className='tag'
onClick={() => editTag(index)}>
{context.value}
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index)}}><i className='fa fa-times fa-fw'/></button>
</li>
);
};
const renderWriteTag = (context, index) => {
return (
<input type='text'
key={index}
defaultValue={context.value}
onKeyDown={(evt) => handleInputKeyDown({evt, value: context.value, index: index})}
autoFocus
/>
);
};
return (
<div className='field'>
<label>{props.label}</label>
<div className='value'>
<ul className='list'>
{tagList.map((context, index) => { return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
</ul>
<input
type='text'
className='value'
placeholder={props.placeholder}
value={tempInputText}
onChange={(e) => setTempInputText(e.target.value)}
onKeyDown={(evt) => handleInputKeyDown({ evt, value: null, options: { clear: true } })}
/>
</div>
</div>
);
};
module.exports = TagInput;