0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 08:57: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}
-2
View File
@@ -4,8 +4,6 @@
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){
return (
<button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
<i className={snippet.icon} /> <i className={snippet.icon} />
<span className={`name${snippet.disabled ? ' disabled' : ''}`} 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.experimental && <span className='beta'>beta</span>}
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>} {snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <> </button>
<i className='fas fa-caret-right'></i> );
<div className='dropdown side'> } 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}>
<div className='text'>
<i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span>
</div>
<div className='dropdown'>
{this.renderSnippets(this.props.snippets)} {this.renderSnippets(this.props.snippets)}
</div> </Dropdown>;
</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,37 +142,49 @@
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 {
.caret {
display: none;
} }
.dropdown { }
position : absolute; .menu-list {
top : 100%;
z-index : 1000;
visibility : hidden;
padding : 0px; padding : 0px;
margin-left : -5px;
background-color : #DDDDDD; background-color : #DDDDDD;
.snippet {
}
}
.menu-item {
position : relative; position : relative;
display : flex; display : flex;
justify-content: space-between;
align-items : center; align-items : center;
min-width : max-content; min-width : max-content;
padding : 5px; padding : 5px;
font-size : 10px;
cursor : pointer; cursor : pointer;
width: 100%;
.animate(background-color); .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 { i {
min-width : 25px; min-width : 25px;
height : 1.2em; height : .85rem;
margin-right : 8px;
font-size : 1.2em; font-size : 1.2em;
text-align : center; text-align : center;
& ~ i { &.caret {
margin-right: 0; margin-right: 0;
margin-left : 5px; }
&.caret:is(.menu-wrapper .menu-wrapper * ) {
text-align: right;
} }
/* Fonts */ /* Fonts */
&.font { &.font {
@@ -217,17 +227,11 @@
} }
&:hover { &:hover {
background-color : #999999; background-color : #999999;
& > .dropdown {
visibility : visible;
&.side {
top : 0%;
left : 100%;
margin-left : 0;
box-shadow : -1px 1px 2px 0px #999999;
}
}
}
} }
&:disabled {
color: gray;
cursor: not-allowed;
&:hover { background-color: unset; }
} }
} }
.disabledSnippets { .disabledSnippets {
+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();
+1060 -1066
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 }
-5
View File
@@ -112,25 +112,21 @@ export default [
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',
@@ -212,7 +208,6 @@ 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',
+5 -6
View File
@@ -198,7 +198,6 @@ 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',