0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 06:47:38 +00:00

Merge branch 'master' into normalizeNodeVersions

This commit is contained in:
Trevor Buckner
2026-07-08 15:28:16 -04:00
committed by GitHub
15 changed files with 1395 additions and 1219 deletions
+2
View File
@@ -1,6 +1,8 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Admin from './admin.jsx'; import Admin from './admin.jsx';
import { bootstrapAnchorPositioningPolyfill } from '../components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {}; const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />); createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
bootstrapAnchorPositioningPolyfill();
+11 -4
View File
@@ -71,10 +71,14 @@ const Anchored = ({ children })=>{
// forward ref for AnchoredTrigger // forward ref for AnchoredTrigger
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>( const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
<button <button
ref={ref} ref={(el)=>{
// setAttribute bypasses React's style sanitization so the anchor polyfill can read it
el?.setAttribute('style', `anchor-name: --${props.id}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-trigger${visible ? ' active' : ''} ${className}`} className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
onClick={toggleVisibility} onClick={toggleVisibility}
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
{...props} {...props}
> >
{children} {children}
@@ -84,9 +88,12 @@ const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, class
// forward ref for AnchoredBox // forward ref for AnchoredBox
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>( const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
<div <div
ref={ref} ref={(el)=>{
el?.setAttribute('style', `position-anchor: --${anchorId}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-box${visible ? ' active' : ''} ${className}`} className={`anchored-box${visible ? ' active' : ''} ${className}`}
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
{...props} {...props}
> >
{children} {children}
+4 -6
View File
@@ -1,11 +1,9 @@
.anchored-box { .anchored-box {
position : absolute; position : absolute;
visibility : hidden; visibility : hidden;
justify-self : anchor-center; justify-self : anchor-center;
@supports (inset-block-start: anchor(bottom)) { inset-block-start : anchor(bottom);
inset-block-start : anchor(bottom);
}
&.active { visibility : visible; } &.active { visibility : visible; }
} }
@@ -0,0 +1,27 @@
/*
This file basically checks support for Anchor Positioning API in the browser,
and then loads the Oddbird polyfill if support is lacking.
*/
let polyfillPromise;
// look for `anchorName` in the computed styles
const supportsAnchorPositioning = ()=>'anchorName' in document.documentElement.style;
export const bootstrapAnchorPositioningPolyfill = ()=>{
if(supportsAnchorPositioning()) return Promise.resolve(false);
if(polyfillPromise) return polyfillPromise;
polyfillPromise = (async ()=>{
try {
const { default: polyfill } = await import('@oddbird/css-anchor-positioning/fn');
await polyfill();
return true;
} catch (error){
polyfillPromise = undefined;
throw error;
}
})();
return polyfillPromise;
};
+123
View File
@@ -0,0 +1,123 @@
/**
* A dropdown menu component that uses the Anchor Positioning API to position the elements. It supports nested submenus as well.
* Anchor Positioning is now supported in all major browsers. A polyfill is conditionally loaded for older browsers.
*
* As-is, the menus will always open down aligned on left to trigger, submenus open to the right initially.
* If no space, menus will still open down, but aligned to the right of the trigger. Submenus will flip to the other side of the top menu.
* This could be customized either in more specific CSS, or as a `direction` prop on the component (in future iterations).
*
* @param {string} props.groupName - Name of the menu. Appears as the trigger text.
* @param {string} [props.icon] - Icon to display in the trigger.
* @param {string} [props.color] - Color class to add to the trigger.
* @param {string} [props.className] - Additional classes for the menu wrapper.
* @param {React.ReactNode} [props.customTrigger] - Custom element to use as a trigger.
* @param {React.ReactNode} [props.children] - Child elements to render in the menu.
* @returns {React.JSX.Element}
*/
import './dropdown.less';
import React, { useEffect, useId, useRef } from 'react';
import _ from 'lodash';
// use react context to keep track of the menu depth (menus in menus)
const MenuDepthContext = React.createContext(0);
const Dropdown = ({ groupName, className = null, icon, children, color = null, customTrigger, ...props })=>{
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');
const menuId = `${_.kebabCase(groupName)}-${safeId}-menu`;
const anchorName = `--${menuId}`;
const depth = React.useContext(MenuDepthContext);
// A menu is a submenu if depth > 0
const isSubMenu = depth > 0;
const triggerRef = useRef(null);
const menuRef = useRef(null);
// use setAttribute instead of the React style prop because React strips unknown CSS
// properties (like anchor-name) from inline styles in browsers that don't support them.
// setAttribute writes raw CSS text that the anchor positioning polyfill can read
useEffect(()=>{
triggerRef.current?.setAttribute('style', `anchor-name: ${anchorName}`);
menuRef.current?.setAttribute('style', `position-anchor: ${anchorName}`);
}, [anchorName]);
// hide popover with click inside iframe (not supported by light dismiss)
useEffect(()=>{
const menuElement = document.getElementById(menuId);
if(!menuElement) return;
const handleClick = ()=>{
if(menuElement.matches(':popover-open')) {
menuElement.hidePopover();
}
};
// Listen for clicks from both the main document and the iframe
document.addEventListener('iframe-click', handleClick);
return ()=>{
document.removeEventListener('iframe-click', handleClick);
};
}, [menuId]);
// the trigger is the piece placed inside the opening button of the menu.
// This method allows for creating a generic span with the group name,
// or using a bespoke element (like a graphic) passed in from props to be used as the trigger
const trigger = (groupName = 'menu', icon = '')=>{
if(!customTrigger){
return <>
<i className={icon}></i><span className='menu-name'>{groupName}</span><i className={`caret fas fa-caret-${isSubMenu ? 'right' : 'down'}`}></i>
</>;
} else {
return customTrigger;
}
};
// handle clicks on menu items. By default, actions do dismiss.
const handleMenuActionClick = (event)=>{
const menuElement = menuRef.current;
if(!menuElement) return;
const menuAction = event.target.closest('button, a, [role="menuitem"]');
if(!menuAction || !menuElement.contains(menuAction)) return;
// don't dismiss if the target triggers a submenu
if(menuAction.hasAttribute('popovertarget')) return;
// don't dismiss if the target has `no-dismiss` attribute
const noDismissValue = menuAction.getAttribute('no-dismiss')?.toLowerCase();
if(noDismissValue === '' || noDismissValue === 'true') return;
document.querySelectorAll('.menu-list:popover-open').forEach((openMenu)=>openMenu.hidePopover());
};
return (
<div className={['menu-wrapper', className].join(' ')} role='none' >
<button
id={`${menuId}-trigger`}
className={['menu-item', color].join(' ')}
popoverTarget={menuId}
aria-haspopup='menu'
role='menuitem'
disabled={!React.Children.count(children)}
ref={triggerRef}
>
{trigger(groupName, icon)}
</button>
<MenuDepthContext.Provider value={depth + 1}>
<div
ref={menuRef}
id={menuId}
className='menu-list'
popover='auto'
role='menu'
onClick={handleMenuActionClick}
>
{children}
</div>
</MenuDepthContext.Provider>
</div>
);
};
export { Dropdown };
+24
View File
@@ -0,0 +1,24 @@
.menu-wrapper {
position: relative;
&:is(.menu-bar > .menu-section > .menu-wrapper){
display: inline-block;
}
}
.menu-list {
position : fixed;
z-index : 1000;
top : anchor(bottom);
left : anchor(left);
position-try: flip-inline flip-block;
color: inherit; // [popover] gets a `canvastext` color value from useragent.
> .menu-wrapper {
position:relative;
> .menu-list {
margin: 0 0px;
top : anchor(top);
left : anchor(right);
position-try: flip-inline;
}
}
}
@@ -2,6 +2,7 @@
import './snippetbar.less'; import './snippetbar.less';
import React from 'react'; import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import { Dropdown } from '../../../components/dropdown/dropdown.jsx';
import _ from 'lodash'; import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
@@ -320,36 +321,33 @@ const SnippetGroup = createReactClass({
}; };
}, },
handleSnippetClick : function(e, snippet){ handleSnippetClick : function(e, snippet){
e.stopPropagation();
this.props.onSnippetClick(execute(snippet.gen, this.props)); this.props.onSnippetClick(execute(snippet.gen, this.props));
}, },
renderSnippets : function(snippets){ renderSnippets : function(snippets){
return _.map(snippets, (snippet)=>{ return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}> if(!snippet.subsnippets){
<i className={snippet.icon} /> return (
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span> <button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
{snippet.experimental && <span className='beta'>beta</span>} <i className={snippet.icon} />
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>} <span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
{snippet.subsnippets && <> {snippet.experimental && <span className='beta'>beta</span>}
<i className='fas fa-caret-right'></i> {snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
<div className='dropdown side'> </button>
);
} else if(snippet.subsnippets){
return (
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
{this.renderSnippets(snippet.subsnippets)} {this.renderSnippets(snippet.subsnippets)}
</div></>} </Dropdown>
</div>; )
}
}); });
}, },
render : function(){ render : function(){
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`; return <Dropdown groupName={this.props.groupName} id={this.props.groupName} icon={this.props.icon}>
return <div className={snippetGroup}> {this.renderSnippets(this.props.snippets)}
<div className='text'> </Dropdown>;
<i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span>
</div>
<div className='dropdown'>
{this.renderSnippets(this.props.snippets)}
</div>
</div>;
}, },
}); });
@@ -11,6 +11,10 @@
height : auto; height : auto;
color : black; color : black;
background-color : #DDDDDD; background-color : #DDDDDD;
font-size : .65rem;
font-family: 'Open Sans', sans-serif;
text-transform: uppercase;
font-weight: 800;
.snippets { .snippets {
display : flex; display : flex;
@@ -22,7 +26,7 @@
display : flex; display : flex;
justify-content : flex-end; justify-content : flex-end;
min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
font-size: .85rem;
&:only-child {min-width : unset; margin-left : auto;} &:only-child {min-width : unset; margin-left : auto;}
>div { >div {
@@ -57,32 +61,27 @@
} }
&.undo { &.undo {
.tooltipLeft('Undo'); .tooltipLeft('Undo');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.redo { &.redo {
.tooltipLeft('Redo'); .tooltipLeft('Redo');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.foldAll { &.foldAll {
.tooltipLeft('Fold All'); .tooltipLeft('Fold All');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.unfoldAll { &.unfoldAll {
.tooltipLeft('Unfold All'); .tooltipLeft('Unfold All');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.history { &.history {
.tooltipLeft('History'); .tooltipLeft('History');
position : relative; position : relative;
font-size : 0.75em;
color : grey; color : grey;
border : none; border : none;
&.active { color : inherit; } &.active { color : inherit; }
@@ -93,7 +92,6 @@
} }
&.editorTheme { &.editorTheme {
.tooltipLeft('Editor Themes'); .tooltipLeft('Editor Themes');
font-size : 0.75em;
color : inherit; color : inherit;
&.active { &.active {
position : relative; position : relative;
@@ -144,91 +142,97 @@
border-left : 1px solid black; border-left : 1px solid black;
.tooltipLeft('Edit Brew Properties'); .tooltipLeft('Edit Brew Properties');
} }
.snippetGroup {
&:hover {
& > .dropdown { visibility : visible; } .menu-wrapper {
} .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child {
.dropdown { .caret {
position : absolute; display: none;
top : 100%;
z-index : 1000;
visibility : hidden;
padding : 0px;
margin-left : -5px;
background-color : #DDDDDD;
.snippet {
position : relative;
display : flex;
align-items : center;
min-width : max-content;
padding : 5px;
font-size : 10px;
cursor : pointer;
.animate(background-color);
i {
min-width : 25px;
height : 1.2em;
margin-right : 8px;
font-size : 1.2em;
text-align : center;
& ~ i {
margin-right : 0;
margin-left : 5px;
}
/* Fonts */
&.font {
height : auto;
&::before {
font-size : 1em;
content : 'ABC';
}
&.OpenSans {font-family : 'OpenSans';}
&.CodeBold {font-family : 'CodeBold';}
&.CodeLight {font-family : 'CodeLight';}
&.ScalySansRemake {font-family : 'ScalySansRemake';}
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
&.MrEavesRemake {font-family : 'MrEavesRemake';}
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
&.WalterTurncoat {font-family : 'WalterTurncoat';}
&.Lato {font-family : 'Lato';}
&.Courier {font-family : 'Courier';}
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
&.Overpass {font-family : 'Overpass';}
&.Davek {font-family : 'Davek';}
&.Iokharic {font-family : 'Iokharic';}
&.Rellanic {font-family : 'Rellanic';}
&.TimesNewRoman {font-family : 'Times New Roman';}
}
}
.name { margin-right : auto; }
.disabled { text-decoration : line-through; }
.beta {
align-self : center;
padding : 4px 6px;
margin-left : 5px;
font-family : monospace;
line-height : 1em;
color : white;
background : grey;
border-radius : 12px;
}
&:hover {
background-color : #999999;
& > .dropdown {
visibility : visible;
&.side {
top : 0%;
left : 100%;
margin-left : 0;
box-shadow : -1px 1px 2px 0px #999999;
}
}
}
} }
} }
.menu-list {
padding : 0px;
background-color : #DDDDDD;
}
}
.menu-item {
position : relative;
display : flex;
justify-content: space-between;
align-items : center;
min-width : max-content;
padding : 5px;
cursor : pointer;
width: 100%;
.animate(background-color);
&:is(.menu-list .menu-item) [class*="name"] {
padding-inline: 8px;
}
.menu-name {
flex: 1;
text-align: left;
text-box-trim: trim-end;
}
i {
min-width : 25px;
height : .85rem;
font-size : 1.2em;
text-align : center;
&.caret {
margin-right: 0;
}
&.caret:is(.menu-wrapper .menu-wrapper * ) {
text-align: right;
}
/* Fonts */
&.font {
height : auto;
&::before {
font-size : 1em;
content : 'ABC';
}
&.OpenSans {font-family : 'OpenSans';}
&.CodeBold {font-family : 'CodeBold';}
&.CodeLight {font-family : 'CodeLight';}
&.ScalySansRemake {font-family : 'ScalySansRemake';}
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
&.MrEavesRemake {font-family : 'MrEavesRemake';}
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
&.WalterTurncoat {font-family : 'WalterTurncoat';}
&.Lato {font-family : 'Lato';}
&.Courier {font-family : 'Courier';}
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
&.Overpass {font-family : 'Overpass';}
&.Davek {font-family : 'Davek';}
&.Iokharic {font-family : 'Iokharic';}
&.Rellanic {font-family : 'Rellanic';}
&.TimesNewRoman {font-family : 'Times New Roman';}
}
}
.name { margin-right : auto; }
.disabled { text-decoration : line-through; }
.beta {
align-self : center;
padding : 4px 6px;
margin-left : 5px;
font-family : monospace;
line-height : 1em;
color : white;
background : grey;
border-radius : 12px;
}
&:hover {
background-color : #999999;
}
&:disabled {
color: gray;
cursor: not-allowed;
&:hover { background-color: unset; }
}
} }
.disabledSnippets { .disabledSnippets {
color: grey; color: grey;
+1 -1
View File
@@ -44,7 +44,7 @@ const Homebrew = (props)=>{
global.account = account; global.account = account;
global.version = version; global.version = version;
global.config = config; global.config = config;
global.enablev4 = enablev4; global.enablev4 = enablev4;
const backgroundObject = ()=>{ const backgroundObject = ()=>{
if(config?.deployment || (config?.local && config?.development)) { if(config?.deployment || (config?.local && config?.development)) {
+2
View File
@@ -1,6 +1,8 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Homebrew from './homebrew.jsx'; import Homebrew from './homebrew.jsx';
import { bootstrapAnchorPositioningPolyfill } from '../components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {}; const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />); createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
bootstrapAnchorPositioningPolyfill();
+1062 -1068
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -105,6 +105,7 @@
"@dmsnell/diff-match-patch": "^1.1.0", "@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^20.2.0", "@googleapis/drive": "^20.2.0",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@oddbird/css-anchor-positioning": "^0.9.0",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
+2
View File
@@ -21,3 +21,5 @@
text-transform : unset; text-transform : unset;
background-color : unset; background-color : unset;
} }
:where(i){ text-box-trim: trim-both }
+16 -21
View File
@@ -109,28 +109,24 @@ export default [
gen : MonsterBlockGen.monster('monster,frame,wide', 4), gen : MonsterBlockGen.monster('monster,frame,wide', 4),
}, },
{ {
name : 'Front Cover Page', name : 'Front Cover Page',
icon : 'fac book-front-cover', icon : 'fac book-front-cover',
gen : CoverPageGen.front, gen : CoverPageGen.front,
experimental : true
}, },
{ {
name : 'Inside Cover Page', name : 'Inside Cover Page',
icon : 'fac book-inside-cover', icon : 'fac book-inside-cover',
gen : CoverPageGen.inside, gen : CoverPageGen.inside,
experimental : true
}, },
{ {
name : 'Part Cover Page', name : 'Part Cover Page',
icon : 'fac book-part-cover', icon : 'fac book-part-cover',
gen : CoverPageGen.part, gen : CoverPageGen.part,
experimental : true
}, },
{ {
name : 'Back Cover Page', name : 'Back Cover Page',
icon : 'fac book-back-cover', icon : 'fac book-back-cover',
gen : CoverPageGen.back, gen : CoverPageGen.back,
experimental : true
}, },
{ {
name : 'Magic Item', name : 'Magic Item',
@@ -209,11 +205,10 @@ export default [
] ]
}, },
{ {
name : 'Rune Table', name : 'Rune Table',
icon : 'fas fa-language', icon : 'fas fa-language',
gen : scriptGen.dwarvish, gen : scriptGen.dwarvish,
experimental : true, subsnippets : [
subsnippets : [
{ {
name : 'Dwarvish', name : 'Dwarvish',
icon : 'fac davek', icon : 'fac davek',
+8 -9
View File
@@ -195,10 +195,9 @@ export default [
] ]
}, },
{ {
name : 'Index', name : 'Index',
icon : 'fas fa-bars', icon : 'fas fa-bars',
gen : indexGen, gen : indexGen,
experimental : true
}, },
] ]
@@ -330,7 +329,7 @@ export default [
}, },
{ {
name : 'DTRPG Community Content', name : 'DTRPG Community Content',
incon : 'fab fa-dtrpg', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Chronicle System Guild Colophon', name : 'Chronicle System Guild Colophon',
@@ -520,13 +519,13 @@ export default [
{ {
name : 'MIT License', name : 'MIT License',
icon : 'fas fa-mit', icon : null,
gen : LicenseGen.mit, gen : LicenseGen.mit,
}, },
{ {
name : 'Mongoose Publishing Fair Use', name : 'Mongoose Publishing Fair Use',
icon : 'fas fa-mongoosepub', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Long Form Fair Use', name : 'Long Form Fair Use',
@@ -554,14 +553,14 @@ export default [
{ {
name : 'ORC Notice', name : 'ORC Notice',
icon : 'fas fa-Paizo', icon : null,
gen : LicenseGen.orc1, gen : LicenseGen.orc1,
}, },
{ {
name : 'Shadowdark', name : 'Shadowdark',
icon : 'fab fa-shadowdark', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Logos', name : 'Logos',