mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-03-25 14:58:13 +00:00
Merge branch 'master' of https://github.com/naturalcrit/homebrewery into vitreum-to-vite
This commit is contained in:
@@ -86,9 +86,9 @@ const Editor = createReactClass({
|
||||
});
|
||||
}
|
||||
const snippetBar = document.querySelector('.editor > .snippetBar');
|
||||
if (!snippetBar) return;
|
||||
if(!snippetBar) return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(entries => {
|
||||
this.resizeObserver = new ResizeObserver(entries=>{
|
||||
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
|
||||
this.setState({ snippetBarHeight: height });
|
||||
});
|
||||
@@ -117,7 +117,7 @@ const Editor = createReactClass({
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
if(this.resizeObserver) this.resizeObserver.disconnect();
|
||||
},
|
||||
|
||||
handleControlKeys : function(e){
|
||||
@@ -337,7 +337,7 @@ const Editor = createReactClass({
|
||||
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
||||
const currentPos = brewRenderer.scrollTop;
|
||||
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
||||
|
||||
|
||||
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
|
||||
@@ -392,7 +392,7 @@ const Editor = createReactClass({
|
||||
|
||||
isJumping = true;
|
||||
checkIfScrollComplete();
|
||||
if (this.codeEditor.current?.codeMirror) {
|
||||
if(this.codeEditor.current?.codeMirror) {
|
||||
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
|
||||
}
|
||||
|
||||
@@ -442,6 +442,7 @@ const Editor = createReactClass({
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='gfm'
|
||||
tab='brewText'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.text}
|
||||
onChange={this.props.onBrewChange('text')}
|
||||
@@ -455,6 +456,7 @@ const Editor = createReactClass({
|
||||
<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')}
|
||||
@@ -484,6 +486,7 @@ const Editor = createReactClass({
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='gfm'
|
||||
tab='brewSnippets'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.snippets}
|
||||
onChange={this.props.onBrewChange('snippets')}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
container : editor / inline-size;
|
||||
background:white;
|
||||
.codeEditor {
|
||||
height : calc(100% - 25px);
|
||||
.CodeMirror { height : 100%; }
|
||||
|
||||
@@ -11,8 +11,6 @@ import TagInput from '../tagInput/tagInput.jsx';
|
||||
import Themes from '../../../../themes/themes.json';
|
||||
import validations from './validations.js';
|
||||
|
||||
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
|
||||
|
||||
import homebreweryThumbnail from '../../thumbnail.png';
|
||||
|
||||
const callIfExists = (val, fn, ...args)=>{
|
||||
@@ -34,7 +32,6 @@ const MetadataEditor = createReactClass({
|
||||
tags : [],
|
||||
published : false,
|
||||
authors : [],
|
||||
systems : [],
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : 'en'
|
||||
@@ -92,15 +89,6 @@ const MetadataEditor = createReactClass({
|
||||
}
|
||||
},
|
||||
|
||||
handleSystem : function(system, e){
|
||||
if(e.target.checked){
|
||||
this.props.metadata.systems.push(system);
|
||||
} else {
|
||||
this.props.metadata.systems = _.without(this.props.metadata.systems, system);
|
||||
}
|
||||
this.props.onChange(this.props.metadata);
|
||||
},
|
||||
|
||||
handleRenderer : function(renderer, e){
|
||||
if(e.target.checked){
|
||||
this.props.metadata.renderer = renderer;
|
||||
@@ -156,18 +144,6 @@ const MetadataEditor = createReactClass({
|
||||
});
|
||||
},
|
||||
|
||||
renderSystems : function(){
|
||||
return _.map(SYSTEMS, (val)=>{
|
||||
return <label key={val}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={_.includes(this.props.metadata.systems, val)}
|
||||
onChange={(e)=>this.handleSystem(val, e)} />
|
||||
{val}
|
||||
</label>;
|
||||
});
|
||||
},
|
||||
|
||||
renderPublish : function(){
|
||||
if(this.props.metadata.published){
|
||||
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
|
||||
@@ -238,7 +214,7 @@ const MetadataEditor = createReactClass({
|
||||
</div>;
|
||||
} else {
|
||||
dropdown =
|
||||
<div className='value'>
|
||||
<div className='value' data-tooltip-top='Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.'>
|
||||
<Combobox trigger='click'
|
||||
className='themes-dropdown'
|
||||
default={currentThemeDisplay}
|
||||
@@ -256,7 +232,6 @@ const MetadataEditor = createReactClass({
|
||||
filterOn : ['value', 'title']
|
||||
}}
|
||||
/>
|
||||
<small>Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.</small>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -281,7 +256,7 @@ const MetadataEditor = createReactClass({
|
||||
|
||||
return <div className='field language'>
|
||||
<label>language</label>
|
||||
<div className='value'>
|
||||
<div className='value' data-tooltip-right='Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.'>
|
||||
<Combobox trigger='click'
|
||||
className='language-dropdown'
|
||||
default={this.props.metadata.lang || ''}
|
||||
@@ -298,14 +273,13 @@ const MetadataEditor = createReactClass({
|
||||
filterOn : ['value', 'detail', 'title']
|
||||
}}
|
||||
/>
|
||||
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderRenderOptions : function(){
|
||||
return <div className='field systems'>
|
||||
return <div className='field renderers'>
|
||||
<label>Renderer</label>
|
||||
<div className='value'>
|
||||
<label key='legacy'>
|
||||
@@ -364,18 +338,20 @@ const MetadataEditor = createReactClass({
|
||||
{this.renderThumbnail()}
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
<div className='field systems'>
|
||||
<label>systems</label>
|
||||
<div className='value'>
|
||||
{this.renderSystems()}
|
||||
<div className="field tags">
|
||||
<label>Tags</label>
|
||||
<div className="value" >
|
||||
<TagInput
|
||||
label='tags'
|
||||
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
|
||||
placeholder='add tag' unique={true}
|
||||
values={this.props.metadata.tags}
|
||||
onChange={(e)=>this.handleFieldChange('tags', e)}
|
||||
tooltip='You may start tags with "type", "system", "group" or "meta" followed by a colon ":", these will be colored in your userpage.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{this.renderLanguageDropdown()}
|
||||
|
||||
@@ -387,13 +363,22 @@ const MetadataEditor = createReactClass({
|
||||
|
||||
{this.renderAuthors()}
|
||||
|
||||
<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)}
|
||||
/>
|
||||
<div className="field invitedAuthors">
|
||||
<label>Invited authors</label>
|
||||
<div className="value">
|
||||
<TagInput
|
||||
label='invited authors'
|
||||
valuePatterns={/.+/}
|
||||
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
|
||||
placeholder='invite author' unique={true}
|
||||
tooltip={`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.`}
|
||||
values={this.props.metadata.invitedAuthors}
|
||||
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h2>Privacy</h2>
|
||||
|
||||
|
||||
@@ -44,8 +44,6 @@
|
||||
gap : 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.field {
|
||||
position : relative;
|
||||
display : flex;
|
||||
@@ -62,6 +60,7 @@
|
||||
& > .value {
|
||||
flex : 1 1 auto;
|
||||
width : 50px;
|
||||
&[data-tooltip-right] { max-width : 380px; }
|
||||
&:invalid { background : #FFB9B9; }
|
||||
small {
|
||||
display : block;
|
||||
@@ -74,6 +73,16 @@
|
||||
border : 1px solid gray;
|
||||
&:focus { outline : 1px solid #444444; }
|
||||
}
|
||||
|
||||
&.description {
|
||||
flex : 1;
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
|
||||
&.thumbnail, &.themes {
|
||||
label { line-height : 2.0em; }
|
||||
.value {
|
||||
@@ -90,6 +99,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.tags .tagInput-dropdown {
|
||||
z-index : 400;
|
||||
max-width : 200px;
|
||||
}
|
||||
&.language .value {
|
||||
z-index : 300;
|
||||
max-width : 150px;
|
||||
}
|
||||
|
||||
&.themes {
|
||||
.value {
|
||||
overflow : visible;
|
||||
@@ -101,22 +119,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.description {
|
||||
flex : 1;
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
|
||||
&.language .language-dropdown {
|
||||
z-index : 200;
|
||||
max-width : 150px;
|
||||
&.invitedAuthors .value {
|
||||
z-index : 100;
|
||||
|
||||
.tagInput-dropdown { max-width : 200px; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.thumbnail-preview {
|
||||
position : relative;
|
||||
flex : 1 1;
|
||||
@@ -129,7 +138,7 @@
|
||||
background-color : #AAAAAA;
|
||||
}
|
||||
|
||||
.systems.field .value {
|
||||
.renderers.field .value {
|
||||
label {
|
||||
display : inline-flex;
|
||||
align-items : center;
|
||||
@@ -169,7 +178,7 @@
|
||||
.themes.field {
|
||||
& .dropdown-container {
|
||||
position : relative;
|
||||
z-index : 100;
|
||||
z-index : 200;
|
||||
background-color : white;
|
||||
}
|
||||
& .dropdown-options { overflow-y : visible; }
|
||||
|
||||
210
client/homebrew/editor/tagInput/curatedTagSuggestionList.js
Normal file
210
client/homebrew/editor/tagInput/curatedTagSuggestionList.js
Normal file
@@ -0,0 +1,210 @@
|
||||
export default [
|
||||
// ############################## Systems
|
||||
// D&D
|
||||
"system:D&D Original",
|
||||
"system:D&D Basic",
|
||||
"system:AD&D 1e",
|
||||
"system:AD&D 2e",
|
||||
"system:D&D 3e",
|
||||
"system:D&D 3.5e",
|
||||
"system:D&D 4e",
|
||||
"system:D&D 5e",
|
||||
"system:D&D 5e 2024",
|
||||
"system:BD&D (B/X)",
|
||||
"system:D&D Essentials",
|
||||
|
||||
// Other Famous RPGs
|
||||
"system:Pathfinder 1e",
|
||||
"system:Pathfinder 2e",
|
||||
"system:Vampire: The Masquerade",
|
||||
"system:Werewolf: The Apocalypse",
|
||||
"system:Mage: The Ascension",
|
||||
"system:Call of Cthulhu",
|
||||
"system:Shadowrun",
|
||||
"system:Star Wars RPG (D6/D20/Edge of the Empire)",
|
||||
"system:Warhammer Fantasy Roleplay",
|
||||
"system:Cyberpunk 2020",
|
||||
"system:Blades in the Dark",
|
||||
"system:Daggerheart",
|
||||
"system:Draw Steel",
|
||||
"system:Mutants and Masterminds",
|
||||
|
||||
// Meta
|
||||
"meta:V3",
|
||||
"meta:Legacy",
|
||||
"meta:Template",
|
||||
"meta:Theme",
|
||||
"meta:free",
|
||||
"meta:Character Sheet",
|
||||
"meta:Documentation",
|
||||
"meta:NPC",
|
||||
"meta:Guide",
|
||||
"meta:Resource",
|
||||
"meta:Notes",
|
||||
"meta:Example",
|
||||
|
||||
// Book type
|
||||
"type:Campaign",
|
||||
"type:Campaign Setting",
|
||||
"type:Adventure",
|
||||
"type:One-Shot",
|
||||
"type:Setting",
|
||||
"type:World",
|
||||
"type:Lore",
|
||||
"type:History",
|
||||
"type:Dungeon Master",
|
||||
"type:Encounter Pack",
|
||||
"type:Encounter",
|
||||
"type:Session Notes",
|
||||
"type:reference",
|
||||
"type:Handbook",
|
||||
"type:Manual",
|
||||
"type:Manuals",
|
||||
"type:Compendium",
|
||||
"type:Bestiary",
|
||||
|
||||
// ###################################### RPG Keywords
|
||||
|
||||
// Classes / Subclasses / Archetypes
|
||||
"Class",
|
||||
"Subclass",
|
||||
"Archetype",
|
||||
"Martial",
|
||||
"Half-Caster",
|
||||
"Full Caster",
|
||||
"Artificer",
|
||||
"Barbarian",
|
||||
"Bard",
|
||||
"Cleric",
|
||||
"Druid",
|
||||
"Fighter",
|
||||
"Monk",
|
||||
"Paladin",
|
||||
"Rogue",
|
||||
"Sorcerer",
|
||||
"Warlock",
|
||||
"Wizard",
|
||||
|
||||
// Races / Species / Lineages
|
||||
"Race",
|
||||
"Ancestry",
|
||||
"Lineage",
|
||||
"Aasimar",
|
||||
"Beastfolk",
|
||||
"Dragonborn",
|
||||
"Dwarf",
|
||||
"Elf",
|
||||
"Goblin",
|
||||
"Half-Elf",
|
||||
"Half-Orc",
|
||||
"Human",
|
||||
"Kobold",
|
||||
"Lizardfolk",
|
||||
"Lycan",
|
||||
"Orc",
|
||||
"Tiefling",
|
||||
"Vampire",
|
||||
"Yuan-Ti",
|
||||
|
||||
// Magic / Spells / Items
|
||||
"Magic",
|
||||
"Magic Item",
|
||||
"Magic Items",
|
||||
"Wondrous Item",
|
||||
"Magic Weapon",
|
||||
"Artifact",
|
||||
"Spell",
|
||||
"Spells",
|
||||
"Cantrip",
|
||||
"Cantrips",
|
||||
"Eldritch",
|
||||
"Eldritch Invocation",
|
||||
"Invocation",
|
||||
"Invocations",
|
||||
"Pact boon",
|
||||
"Pact Boon",
|
||||
"Spellcaster",
|
||||
"Spellblade",
|
||||
"Magical Tattoos",
|
||||
"Enchantment",
|
||||
"Enchanted",
|
||||
"Attunement",
|
||||
"Requires Attunement",
|
||||
"Rune",
|
||||
"Runes",
|
||||
"Wand",
|
||||
"Rod",
|
||||
"Scroll",
|
||||
"Potion",
|
||||
"Potions",
|
||||
"Item",
|
||||
"Items",
|
||||
"Bag of Holding",
|
||||
|
||||
// Monsters / Creatures / Enemies
|
||||
"Monster",
|
||||
"Creatures",
|
||||
"Creature",
|
||||
"Beast",
|
||||
"Beasts",
|
||||
"Humanoid",
|
||||
"Undead",
|
||||
"Fiend",
|
||||
"Aberration",
|
||||
"Ooze",
|
||||
"Giant",
|
||||
"Dragon",
|
||||
"Monstrosity",
|
||||
"Demon",
|
||||
"Devil",
|
||||
"Elemental",
|
||||
"Construct",
|
||||
"Constructs",
|
||||
"Boss",
|
||||
"BBEG",
|
||||
|
||||
// ############################# Media / Pop Culture
|
||||
"One Piece",
|
||||
"Dragon Ball",
|
||||
"Dragon Ball Z",
|
||||
"Naruto",
|
||||
"Jujutsu Kaisen",
|
||||
"Fairy Tail",
|
||||
"Final Fantasy",
|
||||
"Kingdom Hearts",
|
||||
"Elder Scrolls",
|
||||
"Skyrim",
|
||||
"WoW",
|
||||
"World of Warcraft",
|
||||
"Marvel Comics",
|
||||
"DC Comics",
|
||||
"Pokemon",
|
||||
"League of Legends",
|
||||
"Runeterra",
|
||||
"Arcane",
|
||||
"Yu-Gi-Oh",
|
||||
"Minecraft",
|
||||
"Don't Starve",
|
||||
"Witcher",
|
||||
"Witcher 3",
|
||||
"Cyberpunk",
|
||||
"Cyberpunk 2077",
|
||||
"Fallout",
|
||||
"Divinity Original Sin 2",
|
||||
"Fullmetal Alchemist",
|
||||
"Fullmetal Alchemist Brotherhood",
|
||||
"Lobotomy Corporation",
|
||||
"Bloodborne",
|
||||
"Dragonlance",
|
||||
"Shackled City Adventure Path",
|
||||
"Baldurs Gate 3",
|
||||
"Library of Ruina",
|
||||
"Radiant Citadel",
|
||||
"Ravenloft",
|
||||
"Forgotten Realms",
|
||||
"Exandria",
|
||||
"Critical Role",
|
||||
"Star Wars",
|
||||
"SW5e",
|
||||
"Star Wars 5e",
|
||||
];
|
||||
@@ -1,102 +1,209 @@
|
||||
import './tagInput.less';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
import "./tagInput.less";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Combobox from "../../../components/combobox.jsx";
|
||||
|
||||
const TagInput = ({ unique = true, values = [], ...props })=>{
|
||||
const [tempInputText, setTempInputText] = useState('');
|
||||
const [tagList, setTagList] = useState(values.map((value)=>({ value, editing: false })));
|
||||
import tagSuggestionList from "./curatedTagSuggestionList.js";
|
||||
|
||||
useEffect(()=>{
|
||||
handleChange(tagList.map((context)=>context.value));
|
||||
const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, placeholder = "", smallText = "", onChange }) => {
|
||||
const [tagList, setTagList] = useState(
|
||||
values.map((value) => ({
|
||||
value,
|
||||
editing: false,
|
||||
draft: "",
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const incoming = values || [];
|
||||
const current = tagList.map((t) => t.value);
|
||||
|
||||
const changed = incoming.length !== current.length || incoming.some((v, i) => v !== current[i]);
|
||||
|
||||
if (changed) {
|
||||
setTagList(
|
||||
incoming.map((value) => ({
|
||||
value,
|
||||
editing: false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.({
|
||||
target: { value: tagList.map((t) => t.value) },
|
||||
});
|
||||
}, [tagList]);
|
||||
|
||||
const handleChange = (value)=>{
|
||||
props.onChange({
|
||||
target : { value }
|
||||
});
|
||||
};
|
||||
// substrings to be normalized to the first value on the array
|
||||
const duplicateGroups = [
|
||||
["5e 2024", "5.5e", "5e'24", "5.24", "5e24", "5.5"],
|
||||
["5e", "5th Edition"],
|
||||
["Dungeons & Dragons", "Dungeons and Dragons", "Dungeons n dragons"],
|
||||
["D&D", "DnD", "dnd", "Dnd", "dnD", "d&d", "d&D", "D&d"],
|
||||
["P2e", "p2e", "P2E", "Pathfinder 2e"],
|
||||
];
|
||||
|
||||
const handleInputKeyDown = ({ evt, value, index, options = {} })=>{
|
||||
if(_.includes(['Enter', ','], evt.key)) {
|
||||
evt.preventDefault();
|
||||
submitTag(evt.target.value, value, index);
|
||||
if(options.clear) {
|
||||
setTempInputText('');
|
||||
const normalizeValue = (input) => {
|
||||
const lowerInput = input.toLowerCase();
|
||||
let normalizedTag = input;
|
||||
|
||||
for (const group of duplicateGroups) {
|
||||
for (const tag of group) {
|
||||
if (!tag) continue;
|
||||
|
||||
const index = lowerInput.indexOf(tag.toLowerCase());
|
||||
if (index !== -1) {
|
||||
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTag.includes(":")) {
|
||||
const [rawType, rawValue = ""] = normalizedTag.split(":");
|
||||
const tagType = rawType.trim().toLowerCase();
|
||||
const tagValue = rawValue.trim();
|
||||
|
||||
if (tagValue.length > 0) {
|
||||
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
|
||||
}
|
||||
//trims spaces around colon and capitalizes the first word after the colon
|
||||
//this is preferred to users not understanding they can't put spaces in
|
||||
}
|
||||
|
||||
return normalizedTag;
|
||||
};
|
||||
|
||||
const submitTag = (newValue, originalValue, index)=>{
|
||||
setTagList((prevContext)=>{
|
||||
// remove existing tag
|
||||
if(newValue === null){
|
||||
return [...prevContext].filter((context, i)=>i !== index);
|
||||
const submitTag = (newValue, index = null) => {
|
||||
const trimmed = newValue?.trim();
|
||||
if (!trimmed) return;
|
||||
if (!valuePatterns.test(trimmed)) return;
|
||||
|
||||
const normalizedTag = normalizeValue(trimmed);
|
||||
|
||||
setTagList((prev) => {
|
||||
const existsIndex = prev.findIndex((t) => t.value.toLowerCase() === normalizedTag.toLowerCase());
|
||||
if (unique && existsIndex !== -1) return prev;
|
||||
if (index !== null) {
|
||||
return prev.map((t, i) => (i === index ? { ...t, value: normalizedTag, editing: false } : t));
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
|
||||
return [...prev, { value: normalizedTag, editing: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const editTag = (index)=>{
|
||||
setTagList((prevContext)=>{
|
||||
return prevContext.map((context, i)=>{
|
||||
if(i === index) {
|
||||
return { ...context, editing: true };
|
||||
}
|
||||
return { ...context, editing: false };
|
||||
});
|
||||
});
|
||||
const removeTag = (index) => {
|
||||
setTagList((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
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 editTag = (index) => {
|
||||
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: true, draft: t.value } : t)));
|
||||
};
|
||||
|
||||
const renderWriteTag = (context, index)=>{
|
||||
return (
|
||||
<input type='text'
|
||||
key={index}
|
||||
defaultValue={context.value}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
const stopEditing = (index) => {
|
||||
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: false, draft: "" } : t)));
|
||||
};
|
||||
|
||||
const suggestionOptions = tagSuggestionList.map((tag) => {
|
||||
const tagType = tag.split(":");
|
||||
|
||||
let classes = "item";
|
||||
switch (tagType[0]) {
|
||||
case "type":
|
||||
classes = "item type";
|
||||
break;
|
||||
case "group":
|
||||
classes = "item group";
|
||||
break;
|
||||
case "meta":
|
||||
classes = "item meta";
|
||||
break;
|
||||
case "system":
|
||||
classes = "item system";
|
||||
break;
|
||||
default:
|
||||
classes = "item";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes} key={`tag-${tag}`} value={tag} data={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 className="tagInputWrap">
|
||||
<Combobox
|
||||
trigger="click"
|
||||
className="tagInput-dropdown"
|
||||
default=""
|
||||
placeholder={placeholder}
|
||||
options={label === "tags" ? suggestionOptions : []}
|
||||
tooltip={tooltip}
|
||||
autoSuggest={
|
||||
label === "tags"
|
||||
? {
|
||||
suggestMethod: "startsWith",
|
||||
clearAutoSuggestOnClick: true,
|
||||
filterOn: ["value", "title"],
|
||||
}
|
||||
: { suggestMethod: "includes", clearAutoSuggestOnClick: true, filterOn: [] }
|
||||
}
|
||||
valuePatterns={valuePatterns.source}
|
||||
onSelect={(value) => submitTag(value)}
|
||||
onEntry={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitTag(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ul className="list">
|
||||
{tagList.map((t, i) =>
|
||||
t.editing ? (
|
||||
<input
|
||||
key={i}
|
||||
type="text"
|
||||
value={t.draft} // always use draft
|
||||
pattern={valuePatterns.source}
|
||||
onChange={(e) =>
|
||||
setTagList((prev) =>
|
||||
prev.map((tag, idx) => (idx === i ? { ...tag, draft: e.target.value } : tag)),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitTag(t.draft, i); // submit draft
|
||||
setTagList((prev) =>
|
||||
prev.map((tag, idx) => (idx === i ? { ...tag, draft: "" } : tag)),
|
||||
);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
stopEditing(i);
|
||||
e.target.blur();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<li key={i} className="tag" onClick={() => editTag(i)}>
|
||||
{t.value}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(i);
|
||||
}}>
|
||||
<i className="fa fa-times fa-fw" />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.tags {
|
||||
|
||||
.tagInputWrap {
|
||||
display:grid;
|
||||
grid-template-columns: 200px 3fr;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.list input {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.tagInput-dropdown {
|
||||
.dropdown-options {
|
||||
.item {
|
||||
&.type {
|
||||
background-color: #00800035;
|
||||
}
|
||||
&.group {
|
||||
background-color: #50505035;
|
||||
}
|
||||
&.meta {
|
||||
background-color: #00008035;
|
||||
}
|
||||
&.system {
|
||||
background-color: #80000035;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1980
client/homebrew/editor/tagInput/tagSuggestionList.js
Normal file
1980
client/homebrew/editor/tagInput/tagSuggestionList.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user