diff --git a/play.pokemonshowdown.com/js/lib/preact.d.ts b/play.pokemonshowdown.com/js/lib/preact.d.ts index 841a78a74..558a20877 100644 --- a/play.pokemonshowdown.com/js/lib/preact.d.ts +++ b/play.pokemonshowdown.com/js/lib/preact.d.ts @@ -4,8 +4,8 @@ export as namespace preact; declare namespace preact { type Key = string | number; type Ref = (instance: T) => void; - type ComponentChild = VNode | string | number | null; - type ComponentChildren = ComponentChild[] | ComponentChild | object | string | number | null; + type ComponentChild = VNode | string | number | null | false; + type ComponentChildren = ComponentChild[] | ComponentChild | object | string | number | null | false; /** * @deprecated @@ -716,6 +716,7 @@ declare global { type?: string; useMap?: string; value?: string | string[] | number; + defaultValue?: string | string[] | number; width?: number | string; wmode?: string; wrap?: string; @@ -821,6 +822,7 @@ declare global { script: HTMLAttributes; section: HTMLAttributes; select: HTMLAttributes; + selectedcontent: {}; slot: HTMLAttributes; small: HTMLAttributes; source: HTMLAttributes; diff --git a/play.pokemonshowdown.com/src/battle-team-editor.tsx b/play.pokemonshowdown.com/src/battle-team-editor.tsx index 6f7e220bc..6745b2e9a 100644 --- a/play.pokemonshowdown.com/src/battle-team-editor.tsx +++ b/play.pokemonshowdown.com/src/battle-team-editor.tsx @@ -18,8 +18,18 @@ import { PSModel } from "./client-core"; import { Net } from "./client-connection"; import { PSIcon } from "./panels"; -type SelectionType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details'; +type InnerFocusType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details' | 'import'; +type TeamEditorMode = 'wizard' | 'form' | 'import'; +interface FocusState { + setIndex: number; + type: InnerFocusType | 'nickname'; + /** -1 means no specific slot is focused; other values used only for move */ + typeIndex: number; +}; +interface InnerFocusState extends FocusState { + type: InnerFocusType; +} type SampleSets = { [speciesName: string]: { [setName: string]: Dex.PokemonSet, @@ -55,14 +65,7 @@ export class TeamEditorState extends PSModel { searchIndex = 0; originalSpecies: string | null = null; narrow = false; - selectionTypeOrder: readonly SelectionType[] = [ - 'pokemon', 'ability', 'item', 'move', 'stats', 'details', - ]; - innerFocus: { - setIndex: number, - type: SelectionType, - typeIndex?: number, - } | null = null; + innerFocus: InnerFocusState | null = null; isLetsGo = false; isNatDex = false; isBDSP = false; @@ -72,6 +75,7 @@ export class TeamEditorState extends PSModel { defaultLevel = 100; readonly = false; fetching = false; + handleParentKeyDown?: (ev: KeyboardEvent) => boolean | void; private userSetsCache: Record = {}; constructor(team: Team) { super(); @@ -127,13 +131,30 @@ export class TeamEditorState extends PSModel { this.defaultLevel = 5; } } - setSearchType(type: SearchType, i: number, value?: string) { + stringifyFocus(focus: FocusState | null): string { + if (!focus) return ''; + return `set-${focus.setIndex}-${focus.type}${focus.typeIndex >= 0 ? `-${focus.typeIndex}` : ''}`; + } + parseFocus(value: null): null; + parseFocus(value: string): FocusState; + parseFocus(value: string | null): FocusState | null; + parseFocus(value: string | null): FocusState | null { + if (!value) return null; + const match = value.split('-'); + const type = match[2] as InnerFocusType; + return { + setIndex: parseInt(match[1]), + type, + typeIndex: match[3] ? parseInt(match[3]) : -1, + }; + } + setSearchType(type: SearchType, i: number, value?: string, typeIndex = -1) { const set = this.sets[i]; this.search.setType(type, this.format, set); this.originalSpecies = null; this.search.prependResults = null; if (type === 'move') { - this.search.prependResults = this.getSearchMoves(set); + this.search.prependResults = this.getSearchMoves(set, typeIndex); if (value && this.search.prependResults.some(row => row[1].split('_')[2] === toID(value))) { value = ''; } @@ -168,9 +189,9 @@ export class TeamEditorState extends PSModel { this.search.find(value || ''); this.searchIndex = this.search.results?.[0]?.[0] === 'header' ? 1 : 0; } - updateSearchMoves(set: Dex.PokemonSet) { + updateSearchMoves(set: Dex.PokemonSet, typeIndex = -1) { let oldResultsLength = this.search.prependResults?.length || 0; - this.search.prependResults = this.getSearchMoves(set); + this.search.prependResults = this.getSearchMoves(set, typeIndex); this.searchIndex += this.search.prependResults.length - oldResultsLength; if (this.searchIndex < 0) this.searchIndex = 0; this.search.results = null; @@ -180,9 +201,11 @@ export class TeamEditorState extends PSModel { this.search.find(''); } } - getSearchMoves(set: Dex.PokemonSet) { + getSearchMoves(set: Dex.PokemonSet, typeIndex = -1) { const out: SearchRow[] = []; - for (let i = 0; i < Math.max(set.moves.length, 4); i++) { + const start = typeIndex >= 0 ? typeIndex : 0; + const end = typeIndex >= 0 ? typeIndex + 1 : Math.max(set.moves.length, 4); + for (let i = start; i < end; i++) { out.push(['move', `_${i + 1}_${toID(set.moves[i] || '')}` as ID]); } return out; @@ -878,6 +901,39 @@ export class TeamEditorState extends PSModel { if (Object.keys(cachedSets).length === 0) return null; return cachedSets[set.species] || {}; } + loadSampleSet(setIndex: number, setName: string) { + if (this.readonly) return false; + const set = this.sets[setIndex]; + if (!set?.species) return false; + + const data = TeamEditorState.sampleSets?.[this.format]; + const sid = toID(set.species); + const setTemplate = data?.dex?.[set.species]?.[setName] ?? data?.dex?.[sid]?.[setName] ?? + data?.stats?.[set.species]?.[setName] ?? data?.stats?.[sid]?.[setName]; + if (!setTemplate) return false; + + const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); + Object.assign(set, applied); + + this.save(); + return true; + } + loadUserSet(setIndex: number, setName: string) { + if (this.readonly) return false; + const set = this.sets[setIndex]; + if (!set?.species) return false; + + const userSets = this.getUserSets(set); + const setTemplate = userSets?.[setName]; + if (!setTemplate) return false; + + const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); + delete applied.name; + Object.assign(set, applied); + + this.save(); + return true; + } static renderClipboard(cancelClipboard: () => void) { if (!TeamEditorState.clipboard) return null; @@ -914,13 +970,13 @@ export class TeamEditorState extends PSModel { export class TeamEditor extends preact.Component<{ team: Team, narrow?: boolean, onChange?: () => void, readOnly?: boolean, children?: preact.ComponentChildren, resources?: preact.ComponentChildren, + editorRef?: (editor: TeamEditorState) => void, }> { - wizard = true; + mode: TeamEditorMode = 'form'; editor!: TeamEditorState; setTab = (ev: Event) => { const target = ev.currentTarget as HTMLButtonElement; - const wizard = target.value === 'wizard'; - this.wizard = wizard; + this.mode = target.value as TeamEditorMode; this.forceUpdate(); }; static probablyMobile() { @@ -974,27 +1030,33 @@ export class TeamEditor extends preact.Component<{ this.editor.subscribe(() => { this.forceUpdate(); }); + this.props.editorRef?.(this.editor); } const editor = this.editor; window.editor = editor; // debug editor.updateTeam(!!this.props.readOnly); - editor.narrow = this.props.narrow ?? document.body.offsetWidth < 500; + editor.narrow = this.props.narrow ?? window.innerWidth < 500; if (this.props.team.format !== editor.format) { editor.setFormat(this.props.team.format); } return
    -
  • -
  • */} +
{TeamEditorState.renderClipboard(this.cancelClipboard)} - {this.wizard ? ( + {this.mode === 'wizard' ? ( + ) : this.mode === 'form' ? ( + ) : ( )} @@ -1032,14 +1094,14 @@ class TeamTextbox extends preact.Component<{ windowing = true; selection: { setIndex: number, - type: SelectionType | null, + type: InnerFocusType | null, typeIndex: number, lineRange: [number, number] | null, } | null = null; innerFocus: { offsetY: number | null, setIndex: number, - type: SelectionType, + type: InnerFocusType, /** i.e. which move is this */ typeIndex: number, range: [number, number], @@ -1231,7 +1293,7 @@ class TeamTextbox extends preact.Component<{ } return false; }; - updateText = (noTextChange?: boolean, autoSelect?: boolean | SelectionType) => { + updateText = (noTextChange?: boolean, autoSelect?: boolean | InnerFocusType) => { const textbox = this.textbox; let value = textbox.value; let selectionStart = textbox.selectionStart || 0; @@ -1272,6 +1334,7 @@ class TeamTextbox extends preact.Component<{ let index = 0; /** for the set we're currently parsing */ let setIndex: number | null = null; + let moveIndex = 0; let nextSetIndex = 0; if (!noTextChange) this.setInfo = []; this.selection = null; @@ -1283,6 +1346,7 @@ class TeamTextbox extends preact.Component<{ if (!line.trim()) { setIndex = null; + moveIndex = 0; index = nlIndex + 1; continue; } @@ -1311,6 +1375,7 @@ class TeamTextbox extends preact.Component<{ }); } setIndex = nextSetIndex; + moveIndex = 0; nextSetIndex++; } @@ -1318,13 +1383,15 @@ class TeamTextbox extends preact.Component<{ let start = index, end = index + line.length; if (index <= selectionStart && selectionEnd <= selectionEndCutoff) { // both ends within range - let type: SelectionType | null = null; + let type: InnerFocusType | null = null; const lcLine = line.toLowerCase().trim(); + let typeIndex = -1; if (lcLine.startsWith('ability:')) { type = 'ability'; } else if (lcLine.startsWith('-')) { type = 'move'; + typeIndex = moveIndex; } else if ( !lcLine || lcLine.startsWith('level:') || lcLine.startsWith('gender:') || (lcLine + ':').startsWith('shiny:') || (lcLine + ':').startsWith('gigantamax:') || @@ -1354,11 +1421,12 @@ class TeamTextbox extends preact.Component<{ if (typeof autoSelect === 'string') autoSelect = autoSelect === type; this.selection = { - setIndex, type, lineRange: [start, end], typeIndex: 0, + setIndex, type, lineRange: [start, end], typeIndex, }; if (autoSelect) this.engageFocus(); } + if (line.trim().startsWith('-')) moveIndex++; index = nlIndex + 1; } if (!noTextChange) { @@ -1397,13 +1465,13 @@ class TeamTextbox extends preact.Component<{ } this.innerFocus = focus; - if (focus.type === 'details' || focus.type === 'stats') { + if (focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') { this.forceUpdate(); return; } const value = this.textbox.value.slice(focus.range[0], focus.range[1]); - editor.setSearchType(focus.type, focus.setIndex, value); + editor.setSearchType(focus.type, focus.setIndex, value, focus.typeIndex); this.resetScroll(); this.textbox.setSelectionRange(focus.range[0], focus.range[1]); this.forceUpdate(); @@ -1425,7 +1493,7 @@ class TeamTextbox extends preact.Component<{ } else if (!type) { this.changeSet(this.innerFocus!.type, ''); } else { - this.changeSet(type as SelectionType, name, moveSlot); + this.changeSet(type as InnerFocusType, name, moveSlot); } }; getSelectionTypeRange(): [number, number] | null { @@ -1501,7 +1569,7 @@ class TeamTextbox extends preact.Component<{ } return [start, end]; } - changeSet(type: SelectionType, name: string, moveSlot?: string) { + changeSet(type: InnerFocusType, name: string, moveSlot?: string) { const focus = this.innerFocus; if (!focus) return; @@ -1621,8 +1689,8 @@ class TeamTextbox extends preact.Component<{ this.engageFocus({ offsetY: null, setIndex: i, - type: target.name as SelectionType, - typeIndex: 0, + type: target.name as InnerFocusType, + typeIndex: -1, range: [0, 0], rangeEndChar: '', }); @@ -1638,7 +1706,7 @@ class TeamTextbox extends preact.Component<{ offsetY: this.getYAt(end, true), setIndex: this.setInfo.length, type: 'pokemon', - typeIndex: 0, + typeIndex: -1, range: [end, end], rangeEndChar: '@', }); @@ -1829,23 +1897,55 @@ class TeamTextbox extends preact.Component<{ class TeamWizard extends preact.Component<{ editor: TeamEditorState, onChange?: () => void, onUpdate: () => void, }> { + readonly isForm: boolean = false; setSearchBox: string | null = null; + focusAnimationStartLocation: { + rect: { left: number, top: number }, + } | null = null; windowing = true; + closeInnerFocus = (ev?: Event) => { + this.changeFocus(null); + ev?.preventDefault(); + }; + startFocusAnimation(source: Element | null) { + if (this.props.editor.innerFocus) return; + const setButton = source?.closest('.set-button'); + if (!setButton) return; + const rect = setButton.getBoundingClientRect(); + this.focusAnimationStartLocation = { + rect: { left: rect.left, top: rect.top }, + }; + } + finishFocusAnimation() { + const start = this.focusAnimationStartLocation; + if (!start) return; + this.focusAnimationStartLocation = null; + if (window.PS?.prefs.noanim || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return; + const setButton = this.base!.querySelector('.team-focus-editor .set-button'); + if (!setButton) return; + const rect = setButton.getBoundingClientRect(); + const dx = start.rect.left - rect.left; + const dy = start.rect.top - rect.top; + if (!dx && !dy) return; + setButton.animate?.([ + { transform: `translate(${dx}px, ${dy}px)` }, + { transform: 'translate(0, 0)' }, + ], { + duration: 250, + easing: 'cubic-bezier(.2, 0, .2, 1)', + }); + } setFocus = (ev: Event) => { const { editor } = this.props; if (editor.readonly) return; const target = ev.currentTarget as HTMLButtonElement; - const [rawType, i] = (target.value || '').split('|'); - const setIndex = parseInt(i); - const type = rawType as SelectionType; - if (!target.value || editor.innerFocus?.setIndex === setIndex && editor.innerFocus.type === type) { - this.changeFocus(null); + if (!target.value || editor.stringifyFocus(editor.innerFocus) === target.value) { + this.closeInnerFocus(ev); return; } - this.changeFocus({ - setIndex, - type, - }); + const focus = editor.parseFocus(target.value) as InnerFocusState; + this.startFocusAnimation(target); + this.changeFocus(focus); }; deleteSet = (ev: Event) => { const target = ev.currentTarget as HTMLButtonElement; @@ -1856,6 +1956,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex: editor.sets.length, type: 'pokemon', + typeIndex: -1, }); } this.handleSetChange(); @@ -1879,6 +1980,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: 'pokemon', + typeIndex: -1, }); } this.handleSetChange(); @@ -1912,12 +2014,12 @@ class TeamWizard extends preact.Component<{ const set = editor.sets[focus.setIndex]; if (focus.type === 'details') { this.setSearchBox = set.name || ''; - } else if (focus.type !== 'stats') { + } else if (focus.type !== 'stats' && focus.type !== 'import') { let value; if (focus.type === 'pokemon') value = set?.species || ''; else if (focus.type === 'item') value = set.item; else if (focus.type === 'ability') value = set.ability; - editor.setSearchType(focus.type, focus.setIndex, value); + editor.setSearchType(focus.type, focus.setIndex, value, focus.typeIndex); this.resetScroll(); this.setSearchBox = value || ''; } @@ -1928,7 +2030,7 @@ class TeamWizard extends preact.Component<{ const sprite = Dex.getTeambuilderSprite(set, editor.dex); const spriteClass = set && Dex.getTeambuilderSpriteData(set, editor.dex).pixelated ? ' pixelated' : ''; if (!set) { - return
+ return
{editor.deletedSet ? ( @@ -1939,7 +2041,7 @@ class TeamWizard extends preact.Component<{ {!editor.isChampions && } )} @@ -3068,7 +4169,7 @@ class DetailsForm extends preact.Component<{ const genderTable = { 'M': "Male", 'F': "Female" }; if (gender === 'N') return 'Unknown'; return <> - {} + {} {genderTable[gender]} ; } @@ -3171,11 +4272,12 @@ class DetailsForm extends preact.Component<{ ) : ( )} diff --git a/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx b/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx index c64db5183..616b7bcf9 100644 --- a/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx +++ b/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx @@ -9,7 +9,7 @@ import { PS, PSRoom, type RoomOptions, type Team } from "./client-main"; import { PSPanelWrapper, PSRoomPanel } from "./panels"; import { toID, type ID } from "./battle-dex"; import { BattleLog } from "./battle-log"; -import { TeamEditor } from "./battle-team-editor"; +import { TeamEditor, type TeamEditorState } from "./battle-team-editor"; import { Net, PSLoginServer } from "./client-connection"; import { Teams } from "./battle-teams"; import { CopyableURLBox } from "./panel-chat"; @@ -20,6 +20,7 @@ class TeamRoom extends PSRoom { team!: Team; teamDeleted = false; forceReload = false; + editor?: TeamEditorState; override clientCommands = this.parseClientCommands({ 'validate'(target) { if (this.team.format.length <= 4) { @@ -37,6 +38,9 @@ class TeamRoom extends PSRoom { if (team) this.setFormat(team.format); this.load(); } + override onParentKeyDown = (e?: Event) => { + return this.editor?.handleParentKeyDown?.(e as KeyboardEvent); + }; getTeam() { const team = PS.teams.byKey[this.id.slice(5)] || null; this.teamDeleted = !team && (!!this.team || this.teamDeleted); @@ -265,6 +269,7 @@ class TeamPanel extends PSRoomPanel { { this.props.room.editor = editor; }} > {!!(team.packedTeam && team.format.length > 4) &&

diff --git a/play.pokemonshowdown.com/src/panels.tsx b/play.pokemonshowdown.com/src/panels.tsx index f0eb34b2e..a1b4130b9 100644 --- a/play.pokemonshowdown.com/src/panels.tsx +++ b/play.pokemonshowdown.com/src/panels.tsx @@ -1178,7 +1178,7 @@ export class ReconnectTimer extends preact.Component { export function PSIcon( props: { pokemon: string | Pokemon | ServerPokemon | Dex.PokemonSet | null } | { item: string | null } | { type: string, b?: boolean, new?: boolean, tera?: boolean } | - { category: string } + { category: string } | { gender: string } ) { if ('pokemon' in props) { return ; @@ -1216,5 +1216,11 @@ export function PSIcon( height="14" width="32" class="pixelated" style="vertical-align:middle" />; } + if ('gender' in props) { + return {props.gender}; + } return null!; } diff --git a/play.pokemonshowdown.com/style/battle-log.css b/play.pokemonshowdown.com/style/battle-log.css index 69105dc25..a2521e504 100644 --- a/play.pokemonshowdown.com/style/battle-log.css +++ b/play.pokemonshowdown.com/style/battle-log.css @@ -148,6 +148,34 @@ button:disabled { select.button { text-align: left; } +select.base-select, select.base-select::picker(select) { + appearance: base-select; +} +select.base-select { + width: fit-content; +} +select.base-select::picker-icon { + margin-left: 2px; + display: inline; +} +.base-select::picker(select) { + text-align: left; + background: #E1E8E8; + color: black; + border: 1px solid #999999; + + padding: 2px 4px; + border-radius: 6px; + box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #DDDDDD, 2px 2px 3px rgba(0,0,0,.2); +} +.dark .base-select::picker(select) { + background: #0D151E; + color: #DDD; + border-color: #34373b; + + box-shadow: 2px 2px 3px rgba(0,0,0,.5), inset 0.5px 1px 1px rgba(255, 255, 255, 0.5); +} + .button option, .button optgroup { background: #f6f6f6; @@ -483,7 +511,7 @@ pre.textbox { box-shadow: none; } -.option { +.option, .base-select option { background-color: transparent; border: 1px solid transparent; border-radius: 4px; @@ -494,24 +522,33 @@ pre.textbox { text-align: left; overflow: hidden; } -.option.sel, .option.cur { +.base-select option { + padding: 0 4px; +} +.option.sel, .option.cur, .base-select option:checked { border-color: #999999; } .option:hover, .option.sel:hover, -.option.cur:hover { +.option.cur:hover, +.base-select option:hover { border-color: #888888; background-color: #D5D5D5; color: black; } +.base-select option::checkmark { + display: none; +} -.dark .option { +.dark .option, .dark .base-select option { + background: transparent; color: #DDD; box-shadow: none; } .dark .option:hover, .dark .option.sel:hover, -.dark .option.cur:hover { +.dark .option.cur:hover, +.dark .base-select option:hover { border-color: #777777; background-color: rgba(100, 100, 100, 0.5); color: #FFFFFF; diff --git a/play.pokemonshowdown.com/style/sim-types.css b/play.pokemonshowdown.com/style/sim-types.css index 0b79dc97c..c75e108d1 100644 --- a/play.pokemonshowdown.com/style/sim-types.css +++ b/play.pokemonshowdown.com/style/sim-types.css @@ -505,6 +505,158 @@ .typetile-Stellar { background: linear-gradient(90deg, hsl(330, 66%, 58%), hsl(126, 79%, 45%) 40%, hsl(231, 98%, 65%)); } +.tint-Normal.tint-Normal { + background-color: #d3d7d8; + color: #535a58; +} +.tint-Fire.tint-Fire { + background-color: #ecc4c8; + color: #8a2730; +} +.tint-Fighting.tint-Fighting { + background-color: #f0cfad; + color: #8a4f00; +} +.tint-Water.tint-Water { + background-color: #c1d4f2; + color: #1d4f9c; +} +.tint-Flying.tint-Flying { + background-color: #c9dcf1; + color: #38618a; +} +.tint-Grass.tint-Grass { + background-color: #c4dcbd; + color: #2a6b21; +} +.tint-Poison.tint-Poison { + background-color: #d3c1e6; + color: #653090; +} +.tint-Electric.tint-Electric { + background-color: #ece1ac; + color: #735800; +} +.tint-Ground.tint-Ground { + background-color: #dbc7b7; + color: #704016; +} +.tint-Psychic.tint-Psychic { + background-color: #edc4d6; + color: #9b2f58; +} +.tint-Rock.tint-Rock { + background-color: #d8d4bd; + color: #6b633c; +} +.tint-Ice.tint-Ice { + background-color: #c2e4ed; + color: #247083; +} +.tint-Bug.tint-Bug { + background-color: #d2d9b1; + color: #5b6615; +} +.tint-Dragon.tint-Dragon { + background-color: #c5caee; + color: #3544a2; +} +.tint-Ghost.tint-Ghost { + background-color: #cec2d8; + color: #4f2e5f; +} +.tint-Dark.tint-Dark { + background-color: #cac3c9; + color: #49393a; +} +.tint-Steel.tint-Steel { + background-color: #c5dbe3; + color: #2f6b7a; +} +.tint-Fairy.tint-Fairy { + background-color: #ebc7eb; + color: #9a409a; +} +.tint-Stellar.tint-Stellar { + background-color: #d7d0e8; + color: #594b83; +} +.dark .tint-Normal.tint-Normal { + background-color: #575d5c; + color: #e4e8e8; +} +.dark .tint-Fire.tint-Fire { + background-color: #66383d; + color: #ffd6da; +} +.dark .tint-Fighting.tint-Fighting { + background-color: #674a2f; + color: #ffe0bd; +} +.dark .tint-Water.tint-Water { + background-color: #344f75; + color: #d8e6ff; +} +.dark .tint-Flying.tint-Flying { + background-color: #4d6070; + color: #deedff; +} +.dark .tint-Grass.tint-Grass { + background-color: #365536; + color: #dcf0d6; +} +.dark .tint-Poison.tint-Poison { + background-color: #513d68; + color: #eadcff; +} +.dark .tint-Electric.tint-Electric { + background-color: #615838; + color: #fff0b8; +} +.dark .tint-Ground.tint-Ground { + background-color: #5d4535; + color: #f0d8c7; +} +.dark .tint-Psychic.tint-Psychic { + background-color: #653b50; + color: #ffd9e8; +} +.dark .tint-Rock.tint-Rock { + background-color: #5b5747; + color: #eee8cf; +} +.dark .tint-Ice.tint-Ice { + background-color: #3f6470; + color: #d8f5ff; +} +.dark .tint-Bug.tint-Bug { + background-color: #555a34; + color: #eef5c9; +} +.dark .tint-Dragon.tint-Dragon { + background-color: #3f4771; + color: #dce0ff; +} +.dark .tint-Ghost.tint-Ghost { + background-color: #4c3a59; + color: #eadcff; +} +.dark .tint-Dark.tint-Dark { + background-color: #4a4148; + color: #eee2eb; +} +.dark .tint-Steel.tint-Steel { + background-color: #3f5d66; + color: #dceff5; +} +.dark .tint-Fairy.tint-Fairy { + background-color: #643f64; + color: #ffdfff; +} +.dark .tint-Stellar.tint-Stellar { + background-color: #514b62; + color: #e7ddff; +} .typeicon-Normal { background: scroll #9FA19F url(https://play.pokemonshowdown.com/sprites/typeicons/Normal.png) no-repeat 2px center / 16px; } diff --git a/play.pokemonshowdown.com/style/teambuilder.css b/play.pokemonshowdown.com/style/teambuilder.css index aa3327975..eb3c9c797 100644 --- a/play.pokemonshowdown.com/style/teambuilder.css +++ b/play.pokemonshowdown.com/style/teambuilder.css @@ -435,19 +435,22 @@ width: 52px; height: 100px; } */ -.setdetails .detailcell { +.setdetails .detailcell, .set-form .detailcell { padding: 2px 4px; display: block; font-size: 9pt; text-align: center; } +.set-form .detailcell { + padding-top: 1px; +} .setdetails .detailcell + .detailcell { border-top: 1px solid #CCC; } .dark .setdetails .detailcell + .detailcell { border-color: #555; } -.setdetails label { +.setdetails label, .set-form .detailcell label { float: none; display: block; font-weight: normal; @@ -468,11 +471,12 @@ display: block; height: 15px; } -.statrow label { +.statrow em { float: left; width: 20px; height: 5px; text-align: right; + font-style: normal; font-weight: normal; font-size: 7pt; padding: 0; @@ -491,17 +495,21 @@ border: 1px solid #888888; border-width: 0 1px 2px 0; } -.statrow em { +/* more specificity to outweigh `.label strong` */ +.statrow.statrow strong { float: left; font-size: 7pt; font-style: normal; + font-weight: normal; width: 22px; height: 5px; text-align: right; + margin: 0; } .statrow small { float: left; font-size: 7pt; + height: 5px; } .statrow .statrow-head em { color: #555555; @@ -534,7 +542,7 @@ you can't delete it by pressing Backspace */ color: transparent; } -/* button editor */ +/* wizard editor */ .set-button { position: relative; @@ -558,7 +566,7 @@ you can't delete it by pressing Backspace */ /* (there's no box-sizing:margin-box) */ margin: -1px 0 0 -1px; } -.set-button .button { +.set-wizard .button { display: block; margin: 0; width: 100%; @@ -568,7 +576,7 @@ you can't delete it by pressing Backspace */ overflow: hidden; padding: 3px; } -.tiny-layout .set-button .button, +.tiny-layout .set-wizard .button, .tiny-layout .set-button .label { font-size: 11px; } @@ -576,7 +584,10 @@ you can't delete it by pressing Backspace */ color: #2f4f6e; } .dark .set-button .label { - color: #a1b0be; + color: #adc0d2; +} +.set-button.set-form .label { + color: inherit; } .set-button .set-nickname { @@ -588,10 +599,127 @@ you can't delete it by pressing Backspace */ padding: 0 3px; } .tiny-layout .set-button .set-nickname { - width: 90px; + width: 23%; } -.set-button .sprite { +/* table editor mode */ + +.set-form table, .set-form :where(.set-nickname) { + background-color: #c8d3e9; + border-radius: 5px; +} +.dark .set-form table, .dark .set-form :where(.set-nickname) { + background-color: #3f4654; +} +.set-form table { + height: 105px; +} +.set-form .border-collapse { + margin: 0 3px 1px 0; +} +.set-form .moverow + .moverow { + margin-top: 3px; +} +.set-form .overfull .moverow + .moverow { + margin-top: -2px; +} +.set-form .overfull .moverow + .moverow .textbox { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.set-form .overfull .moverow:not(:last-child) .textbox { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.set-form .overfull .moverow .textbox, .set-form .overfull .moverow { + padding-top: 0; + padding-bottom: 0; + height: 18px; +} +.set-form .overfull5 .moverow .textbox, .set-form .overfull5 .moverow { + height: 19.4px; +} +.set-form td { + vertical-align: top; +} +.set-button.set-form .textbox { + height: 20px; + box-sizing: border-box; + width: 100%; +} +.set-button.set-form .set-details .textbox { + height: 34px; + padding: 1px; + overflow: hidden; +} +.set-button.set-form .set-stats .textbox { + height: 89px; +} + +.set-button .set-field.incomplete { + color: #CC3311; + border-color: #CC3311; +} +.set-button button.label { + display: block; + font-family: inherit; + margin: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} +.set-form .set-moves .label.cur, +.set-form .set-moves .label:focus-visible { + text-decoration: underline; + outline: 0; +} +.set-form .set-moves .button { + display: none; + padding: 0 3px; + font-size: 10px; + vertical-align: top; + margin-top: 0; +} +.set-form .set-moves .button.cur, +.set-form .set-moves .label:hover .button { + display: inline-block; +} +.set-button.set-form .label .textbox { + margin-top: 0; +} +.set-button.set-form .set-nickname .label { + font-size: 10px; +} +.set-button.set-form .set-nickname { + padding: 0; +} +.set-button.set-form .set-nickname span { + padding-left: 3px; +} +.set-button.set-form .set-details { + height: 69px; +} +.set-button.set-form .itemicon { + float: right; + margin-top: -12px; + margin-right: 2px; +} +/* .set-button.set-form .set-pokemon .label { + text-shadow: #c8d3e9 1px 1px 0, #c8d3e9 1px -1px 0, #c8d3e9 -1px 1px 0, #c8d3e9 -1px -1px 0; +} +.dark .set-button.set-form .set-pokemon .label { + text-shadow: #3f4654 1px 1px 0, #3f4654 1px -1px 0, #3f4654 -1px 1px 0, #3f4654 -1px -1px 0; +} */ +.set-button.set-form .set-pokemon .label span { + visibility: hidden; +} +.set-button.set-form .statrow { + height: 14px; +} + +.set-wizard .sprite { display: block; width: 100%; height: 97px; @@ -604,65 +732,86 @@ you can't delete it by pressing Backspace */ border-bottom-left-radius: 3px; background: linear-gradient(to bottom, transparent 0%, transparent 70%, #d3d3d3 100%); } -.set-button .cur .sprite-inner { +.set-wizard .cur .sprite-inner { background: linear-gradient(to bottom, transparent 0%, transparent 70%, #f8f8f8 100%); } -.dark .set-button .sprite-inner { +.dark .set-wizard .sprite-inner { background: linear-gradient(to bottom, transparent 0%, transparent 70%, #2b2c31 100%); } -.dark .set-button .cur .sprite-inner { +.dark .set-wizard .cur .sprite-inner { background: linear-gradient(to bottom, transparent 0%, transparent 70%, #555555 100%); } -.set-button .detailcell { +.set-form .sprite-inner { + padding-top: 69px; + padding-left: 0; + height: auto; + background: none; +} +.set-wizard .detailcell { float: left; height: 50px; } -.dark .set-button .button-first.cur, -.dark .set-button .button-middle.cur, -.dark .set-button .button-last.cur { +.set-form .detailcell { + float: left; + height: 27px; + margin-bottom: 1px; +} +.dark .set-wizard .button-first.cur, +.dark .set-wizard .button-middle.cur, +.dark .set-wizard .button-last.cur { box-shadow: none; } -.set-button .detailcell + .detailcell { +.set-wizard .detailcell + .detailcell, +.set-form .detailcell + .detailcell { border-left: 1px solid #888; margin-left: 20px; - padding-left: 3px; + padding-left: 4px; } -.tiny-layout .set-button .detailcell + .detailcell { - margin-left: 3px; +.set-form .detailcell + .detailcell { + margin-left: 2px; + padding-left: 6px; } -.set-button .set-pokemon .button { +.tiny-layout .detailcell + .detailcell { + margin-left: 0; + padding-left: 4px; +} +.set-wizard .set-pokemon .button { padding: 0; } -.set-button .set-ability .button, .set-button .set-item .button { +.set-wizard .set-ability .button, .set-wizard .set-item .button { height: 40px; } .set-item .itemicon { float: right; } -.set-button .set-moves .button { +.set-wizard .set-moves .button { line-height: 18px; } -.set-button .set-moves .button.overfull { +.set-wizard .set-moves .button.overfull { line-height: normal; } -.set-button .set-details .button { +.set-wizard .set-details .button { height: 61px; } -.set-button .set-stats, .set-button .set-stats .button { +.set-button .set-stats, .set-wizard .set-stats .button { width: 138px; } -.set-button .set-stats .button { +.set-wizard .set-stats .button { padding: 3px; } -.tiny-layout .set-button .set-stats, .tiny-layout .set-button .set-stats .button { +.tiny-layout .set-button .set-stats, .tiny-layout .set-button .set-stats button { width: 40px; } -.tiny-layout .set-button .set-stats label { +.tiny-layout .set-button .set-stats button { + padding-top: 1px; +} +.tiny-layout .set-button .set-stats em { display: none; } .tiny-layout .set-button .set-stats .statgraph { transform: scale(0.5); transform-origin: 0 0; + height: 4px; } .team-focus-editor { @@ -674,6 +823,9 @@ you can't delete it by pressing Backspace */ overflow: auto; -webkit-overflow-scrolling: touch; } +.team-focus-editor .set-button { + z-index: 1; +} .team-focus-editor .tabbar { /* note to self: make this scrollable later */ overflow: auto;

- @@ -2095,6 +2212,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: reverse ? 'details' : 'ability', + typeIndex: -1, }); break; case 'ability': @@ -2103,6 +2221,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: reverse ? 'pokemon' : 'item', + typeIndex: -1, }); break; case 'item': @@ -2110,46 +2229,11 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: reverse ? 'ability' : 'move', + typeIndex: -1, }); break; case 'move': - if (slot) { - // intentional; we're _removing_ from the slot - const i = parseInt(slot) - 1; - if (set.moves[i]) { - set.moves[i] = ''; - // remove empty slots at the end - if (i === set.moves.length - 1) { - while (set.moves.length > 4 && !set.moves[set.moves.length - 1]) { - set.moves.pop(); - } - } - // if we have more than 4 moves, move the last move into the newly-cleared slot - if (set.moves.length > 4 && i < set.moves.length - 1) { - set.moves[i] = set.moves.pop()!; - } - } - } else if (set.moves.includes(name)) { - set.moves.splice(set.moves.indexOf(name), 1); - } else { - for (let i = 0; i < set.moves.length + 1; i++) { - if (!set.moves[i]) { - set.moves[i] = name; - break; - } - } - } - if (set.moves.length === 4 && set.moves.every(Boolean)) { - this.changeFocus({ - setIndex, - type: reverse ? 'item' : 'stats', - }); - } else { - if (editor.search.query) { - this.resetScroll(); - } - editor.updateSearchMoves(set); - } + this.selectMoveResult(name, slot, reverse); break; } editor.save(); @@ -2157,44 +2241,64 @@ class TeamWizard extends preact.Component<{ this.forceUpdate(); } }; - loadSampleSet = (setName: string) => { + selectMoveResult(name: string, slot?: string, reverse?: boolean) { const { editor } = this.props; const setIndex = editor.innerFocus!.setIndex; - const set = editor.sets[setIndex]; - if (!set?.species) return; - - const data = TeamEditorState.sampleSets?.[editor.format]; - const sid = toID(set.species); - const setTemplate = data?.dex?.[set.species]?.[setName] ?? data?.dex?.[sid]?.[setName] ?? - data?.stats?.[set.species]?.[setName] ?? data?.stats?.[sid]?.[setName]; - if (!setTemplate) return; - - const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); - Object.assign(set, applied); - - editor.save(); + const set = (editor.sets[setIndex] ||= { species: '', moves: [] }); + if (slot) { + // intentional; we're _removing_ from the slot + const i = parseInt(slot) - 1; + if (set.moves[i]) { + set.moves[i] = ''; + // remove empty slots at the end + if (i === set.moves.length - 1) { + while (set.moves.length > 4 && !set.moves[set.moves.length - 1]) { + set.moves.pop(); + } + } + // if we have more than 4 moves, move the last move into the newly-cleared slot + if (set.moves.length > 4 && i < set.moves.length - 1) { + set.moves[i] = set.moves.pop()!; + } + } + } else if (set.moves.includes(name)) { + set.moves.splice(set.moves.indexOf(name), 1); + } else { + for (let i = 0; i < set.moves.length + 1; i++) { + if (!set.moves[i]) { + set.moves[i] = name; + break; + } + } + } + if (set.moves.length === 4 && set.moves.every(Boolean) && !this.isForm) { + this.changeFocus({ + setIndex, + type: reverse ? 'item' : 'stats', + typeIndex: -1, + }); + } else if (reverse) { + this.changeFocus({ + setIndex, + type: 'item', + typeIndex: -1, + }); + } else { + if (editor.search.query) { + this.resetScroll(); + } + editor.updateSearchMoves(set); + } + } + handleLoadSampleSet = (setName: string) => { + const { editor } = this.props; + if (!editor.innerFocus || !editor.loadSampleSet(editor.innerFocus.setIndex, setName)) return; this.props.onUpdate?.(); this.forceUpdate(); }; - handleLoadUserSet = (ev: Event) => { - const setName = (ev.target as HTMLButtonElement).value; - this.loadUserSet(setName); - }; - loadUserSet = (setName: string) => { + handleLoadUserSet = (setName: string) => { const { editor } = this.props; - const setIndex = editor.innerFocus!.setIndex; - const set = editor.sets[setIndex]; - if (!set?.species) return; - - const userSets = editor.getUserSets(set); - const setTemplate = userSets?.[setName]; - if (!setTemplate) return; - - const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); - delete applied.name; - Object.assign(set, applied); - - editor.save(); + if (!editor.innerFocus || !editor.loadUserSet(editor.innerFocus.setIndex, setName)) return; this.props.onUpdate?.(); this.forceUpdate(); }; @@ -2272,6 +2376,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex: editor.innerFocus.setIndex, type: ev.shiftKey ? 'item' : 'stats', + typeIndex: -1, }); } else { const [name, moveSlot] = value.split('|'); @@ -2308,6 +2413,7 @@ class TeamWizard extends preact.Component<{ } override componentDidUpdate() { + this.finishFocusAnimation(); const searchBox = this.base!.querySelector('input[name=value], input[name=nickname]'); if (this.setSearchBox !== null) { if (searchBox) { @@ -2332,77 +2438,63 @@ class TeamWizard extends preact.Component<{ const { type, setIndex } = editor.innerFocus; const set = this.props.editor.sets[setIndex] as Dex.PokemonSet | undefined; const cur = (i: number) => setIndex === i ? ' cur' : ''; - const sampleSets = type === 'ability' ? editor.getSampleSets(set!) : []; - const userSets = type === 'ability' ? editor.getUserSets(set!) : null; - return
-
    -
  • - {editor.sets.map((curSet, i) =>
  • )} - {editor.canAdd() &&
  • } -
-
{this.renderSet(set, setIndex)}
+ const isSearchMode = type !== 'stats' && type !== 'details' && type !== 'import'; + const SEARCH_PLACEHOLDERS = { + 'pokemon': 'Search species or filter by type, learnable moves, ability, or egg group', + 'ability': 'Search abilities', + 'item': 'Search items', + 'move': 'Search moves or filter by type or category', + }; + return
+
+
    +
  • + {editor.sets.map((curSet, i) =>
  • )} + {editor.canAdd() &&
  • } +
+
{this.renderSet(set, setIndex)}
+ {isSearchMode &&
+ + {PSSearchResults.renderFilters(editor.search)} +
} +
{type === 'stats' ? ( ) : type === 'details' ? ( + ) : type === 'import' ? ( + ) : ( -
-
- - {PSSearchResults.renderFilters(editor.search)} -
-
- - {sampleSets?.length !== 0 && ( -
-

Sample sets

- {sampleSets ? ( -
- {sampleSets.map(setName => <> - {} - )} -
- ) : ( -
Loading...
- )} -
- )} - {userSets !== null && ( -
-

Box sets

- {Object.keys(userSets).length > 0 ? ( -
- {Object.keys(userSets).map(setName => <> - {} - )} -
- ) : ( -
No {set!.species} sets found in boxes
- )} -
- )} -
+
+ + {type === 'ability' && }
)}
; @@ -2440,13 +2532,1003 @@ class TeamWizard extends preact.Component<{ this.renderSet(set, i), ])} {pasteControls(editor.sets.length)} - {editor.canAdd() &&

}
; } } +class TeamEditorForm extends TeamWizard { + override readonly isForm = true; + /** where to focus after next render */ + pendingFocus: TeamEditorState['innerFocus'] = null; + pendingFocusValue: string | null = null; + pendingFocusSelection: [number | null, number | null, 'forward' | 'backward' | 'none' | undefined] | null = null; + /** whether to focus the details/stats button or their panel contents */ + pendingFocusButton = false; + mouseDownTextbox: HTMLInputElement | null = null; + openFocusTextbox(target: HTMLInputElement) { + const { editor } = this.props; + if (editor.readonly) return; + const focus = editor.parseFocus(target.getAttribute('data-focus')) as InnerFocusState; + if (!focus) return; + + // calling .focus() to focus after innerfocusing does trigger this listener + if (this.pendingFocus) return; + + this.pendingFocusValue = target.value; + this.pendingFocusSelection = [ + target.selectionStart, target.selectionEnd, target.selectionDirection || undefined, + ]; + target.classList.remove('incomplete'); + this.startFocusAnimation(target); + this.changeFocus(focus); + } + setFocusTextbox = (ev: FocusEvent) => { + const target = ev.currentTarget as HTMLInputElement; + if (this.mouseDownTextbox === target) return; + this.openFocusTextbox(target); + }; + mouseDownField = (ev: MouseEvent) => { + if (ev.button !== 0) return; + const target = ev.currentTarget as HTMLInputElement; + if (document.activeElement === target) return; + this.mouseDownTextbox = target; + document.addEventListener('mouseup', this.mouseUpField, { once: true }); + target.focus(); + target.select(); + ev.preventDefault(); + }; + mouseUpField = (ev: MouseEvent) => { + const target = this.mouseDownTextbox; + this.mouseDownTextbox = null; + if (!target || ev.target !== target) return; + this.openFocusTextbox(target); + }; + override changeFocus(focus: TeamEditorState['innerFocus'], focusButton = false) { + const { editor } = this.props; + editor.innerFocus = focus; + this.pendingFocus = focus; + this.pendingFocusButton = focusButton; + if (!focus) { + this.props.onUpdate(); + return; + } + + const set = editor.sets[focus.setIndex]; + if (focus.type !== 'details' && focus.type !== 'stats' && focus.type !== 'import') { + let value = ''; + if (focus.type === 'pokemon') value = set?.species || ''; + else if (focus.type === 'item') value = set?.item || ''; + else if (focus.type === 'ability') value = set?.ability || ''; + else if (focus.type === 'move' && focus.typeIndex >= 0) value = set?.moves?.[focus.typeIndex] || ''; + editor.setSearchType(focus.type, focus.setIndex, value, focus.typeIndex); + this.resetScroll(); + } + this.props.onUpdate(); + } + override componentDidMount(): void { + this.props.editor.handleParentKeyDown = this.handleKeyDown; + } + override componentWillUnmount(): void { + this.props.editor.handleParentKeyDown = undefined; + } + override componentDidUpdate() { + this.finishFocusAnimation(); + const { editor } = this.props; + const focus = this.pendingFocus; + if (focus) { + const focusValue = editor.stringifyFocus(focus); + const input = this.base!.querySelector( + (focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') && this.pendingFocusButton ? + `button[name="${focus.type}"][value="${focusValue}"]` : + focus.type === 'details' ? `div[aria-label=Details] input:not([name=nickname]), div[aria-label=Details] select` : + focus.type === 'stats' ? `div[aria-label=Stats] input` : + focus.type === 'import' ? `div[aria-label="Import/Export"] textarea` : + focus.type === 'move' && focus.typeIndex === -1 ? `input[name=value]` : + `input.set-field[data-focus="${focusValue}"]` + ); + if (input) { + if ( + focus.type !== 'details' && focus.type !== 'stats' && + focus.type !== 'import' && !(focus.type === 'move' && focus.typeIndex === -1) + ) { + input.value = this.pendingFocusValue ?? + this.getInputValue(focus.setIndex, focus.type, focus.typeIndex); + input.classList.remove('incomplete'); + } + input.focus(); + if (this.pendingFocusSelection && input instanceof HTMLInputElement) { + input.setSelectionRange?.(...this.pendingFocusSelection); + } else { + (input as HTMLInputElement).select?.(); + } + this.pendingFocus = null; + this.pendingFocusValue = null; + this.pendingFocusSelection = null; + } + } + const activeElement = document.activeElement; + for (const input of this.base!.querySelectorAll('input.set-field')) { + if (input === activeElement) continue; + const curFocus = editor.parseFocus(input.getAttribute('data-focus')!); + input.value = this.getInputValue(curFocus.setIndex, curFocus.type, curFocus.typeIndex); + } + const searchBox = this.base!.querySelector('input[name=value]'); + const filters = this.base!.querySelector('.dexlist-filters'); + if (searchBox) { + if (filters) { + const { width } = filters.getBoundingClientRect(); + searchBox.style.paddingLeft = `${width + 5}px`; + } else { + searchBox.style.paddingLeft = `3px`; + } + } + } + getInputValue(setIndex: number, type: InnerFocusType | 'nickname', typeIndex: number) { + const set = this.props.editor.sets[setIndex]; + if (!set) return ''; + switch (type) { + case 'pokemon': + return set.species || ''; + case 'item': + return set.item || ''; + case 'ability': + return set.ability || ''; + case 'move': + return set.moves[typeIndex] || ''; + case 'nickname': + return set.name || ''; + default: + return ''; + } + } + canonicalValue(type: InnerFocusType, value: string): string | null { + const { editor } = this.props; + if (!value.trim()) return ''; + + switch (type) { + case 'pokemon': { + const species = editor.dex.species.get(value); + return species.exists ? species.name : null; + } + case 'item': { + if (toID(value) === 'noitem') return ''; + const item = editor.dex.items.get(value); + return item.exists ? item.name : null; + } + case 'ability': { + if (toID(value) === 'noability') return ''; + const ability = editor.dex.abilities.get(value); + return ability.exists ? ability.name : null; + } + case 'move': { + const move = editor.dex.moves.get(value); + return move.exists ? move.name : null; + } + default: + return value; + } + } + commitField(target: HTMLInputElement, selectNext?: boolean, reverse?: boolean) { + const { editor } = this.props; + const focus = editor.parseFocus(target.getAttribute('data-focus')!); + if (!focus) return true; + + if (focus.type === 'nickname') { + const set = editor.sets[focus.setIndex]; + if (!set) return true; + const name = target.value.trim(); + if (name) { + set.name = name; + } else { + delete set.name; + } + editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + return true; + } + + let canonical = this.canonicalValue(focus.type, target.value); + if (canonical === null) { + target.classList.add('incomplete'); + canonical = target.value; + } else { + target.classList.remove('incomplete'); + } + + if (focus.type === 'pokemon') { + if (!canonical) return true; + const set = (editor.sets[focus.setIndex] ||= { species: '', moves: [] }); + editor.changeSpecies(set, canonical); + target.value = set.species; + } else { + const set = editor.sets[focus.setIndex]; + if (!set) return true; + switch (focus.type) { + case 'item': + if (canonical) set.item = canonical; + else delete set.item; + target.value = canonical; + break; + case 'ability': + if (canonical) set.ability = canonical; + else delete set.ability; + target.value = canonical; + break; + case 'move': + if (focus.typeIndex >= set.moves.length && !canonical) return true; + while (set.moves.length <= focus.typeIndex) set.moves.push(''); + set.moves[focus.typeIndex] = canonical; + target.value = canonical; + break; + } + } + + editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + if (selectNext) this.focusAdjacentField(focus, !!reverse); + return true; + } + inputField = (ev: Event) => { + const target = ev.currentTarget as HTMLInputElement; + target.classList.remove('incomplete'); + const type = target.name as InnerFocusType | 'nickname'; + if (type === 'nickname') { + this.commitField(target); + return; + } + let focus = this.props.editor.innerFocus; + if (!focus) { + this.openFocusTextbox(target); + focus = this.pendingFocus; + } + if (focus?.type === 'move' && focus.typeIndex >= 0 && !target.value) { + // blank out move + this.props.editor.search.prependResults = [['move', `_${focus.typeIndex + 1}_` as ID]]; + this.props.editor.search.results = null; + } else if (focus?.type === 'item' && !target.value) { + // blank out item + this.props.editor.search.prependResults = [['item', '' as ID]]; + this.props.editor.search.results = null; + } + this.props.editor.setSearchValue(target.value); + this.resetScroll(); + this.forceUpdate(); + }; + blurField = (ev: Event) => { + this.commitField(ev.currentTarget as HTMLInputElement); + }; + getFocusedSetField() { + const { editor } = this.props; + const focus = editor.innerFocus; + if (!focus || focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') { + return null; + } + if (focus.type === 'move' && focus.typeIndex === -1) return null; + return this.base!.querySelector( + `input.set-field[data-focus="${editor.stringifyFocus(focus)}"]` + ); + } + focusFocusedSetField() { + const input = this.getFocusedSetField(); + if (!input) return false; + const focus = this.props.editor.parseFocus(input.getAttribute('data-focus')); + if (!focus) return false; + input.value = this.getInputValue(focus.setIndex, focus.type, focus.typeIndex); + input.classList.remove('incomplete'); + input.focus(); + input.select(); + return true; + } + clearSearchFilters() { + while (true) { + if (!this.props.editor.search.removeFilter()) return; + } + } + keyDownSearchInput(ev: KeyboardEvent, inSearchBox: boolean) { + const { editor } = this.props; + const input = ev.currentTarget as HTMLInputElement; + switch (ev.keyCode) { + case 8: // backspace + if (input.selectionStart === 0 && input.selectionEnd === 0) { + if (!editor.search.removeFilter() && inSearchBox && !input.value) { + if (this.focusFocusedSetField()) ev.preventDefault(); + break; + } + editor.setSearchValue(input.value); + this.resetScroll(); + this.forceUpdate(); + } + break; + case 27: // escape + if (inSearchBox) { + input.value = ''; + this.clearSearchFilters(); + editor.setSearchValue(''); + this.resetScroll(); + if (!this.focusFocusedSetField()) break; + this.forceUpdate(); + ev.preventDefault(); + ev.stopImmediatePropagation(); + } + break; + case 38: // up + editor.upSearchValue(); + this.scrollSelectedResult(); + this.forceUpdate(); + ev.preventDefault(); + break; + case 40: // down + editor.downSearchValue(); + this.scrollSelectedResult(); + this.forceUpdate(); + ev.preventDefault(); + break; + case 37: // left + case 39: // right + ev.stopImmediatePropagation(); + break; + case 13: // enter + case 9: { // tab + if (ev.keyCode === 9 && ev.shiftKey) { + this.commitField(input, true, true); + this.tryDeleteEmptyMoveSlot(input); + ev.preventDefault(); + return; + } + const value = editor.selectSearchValue(); + if (value === '' && input.value) { + // value not found + this.commitField(input, true); + } else if (value !== null) { + // selected a value + const [name, moveSlot] = value.split('|'); + if (editor.innerFocus?.type === 'move' && editor.innerFocus.typeIndex === -1) { + this.setMoveResult(name, moveSlot); + } else { + this.setFocusedValue(name, ev.shiftKey); + } + this.tryDeleteEmptyMoveSlot(input); + if (inSearchBox) input.value = ''; + } else { + // added a filter + if (inSearchBox) { + input.value = ''; + } else { + // restore focused input + const focus = editor.parseFocus(input.getAttribute('data-focus')); + if (focus) input.value = this.getInputValue(focus.setIndex, focus.type, focus.typeIndex); + input.classList.remove('incomplete'); + + // clear and focus search box + const searchBox = this.base!.querySelector('input[name=value]'); + if (searchBox) { + searchBox.value = ''; + searchBox.focus(); + } + } + editor.setSearchValue(''); + this.resetScroll(); + this.forceUpdate(); + } + ev.preventDefault(); + break; + } + } + } + tryDeleteEmptyMoveSlot(input: HTMLInputElement) { + if (input.value) return false; + const { editor } = this.props; + const focus = editor.parseFocus(input.getAttribute('data-focus')); + if (focus?.type !== 'move' || focus.typeIndex < 0) return false; + + const moves = editor.sets[focus.setIndex]?.moves; + if (!moves) return false; + if (moves[focus.typeIndex]) return false; + moves.splice(focus.typeIndex, 1); + if (editor?.innerFocus?.type === 'move' && editor.innerFocus.typeIndex > focus.typeIndex) { + editor.innerFocus.typeIndex--; + } + // easier than guarding against the blur handler clobbering moves + input.value = moves[focus.typeIndex] || ''; + this.forceUpdate(); + return true; + } + keyDownField = (ev: KeyboardEvent) => { + if (!this.props.editor.innerFocus && ev.keyCode === 9) { + const target = ev.currentTarget as HTMLInputElement; + const focus = this.props.editor.parseFocus(target.getAttribute('data-focus')!); + if (!this.commitField(target)) { + ev.preventDefault(); + return; + } + if (this.focusAdjacentField(focus, ev.shiftKey)) { + ev.preventDefault(); + } + return; + } + this.keyDownSearchInput(ev, false); + }; + keyDownNickname = (ev: KeyboardEvent) => { + if (ev.keyCode !== 9) return; + const target = ev.currentTarget as HTMLInputElement; + this.commitField(target); + const focus = this.props.editor.parseFocus(target.getAttribute('data-focus')!); + if (this.focusAdjacentField(focus, ev.shiftKey)) { + ev.preventDefault(); + } + }; + override keyDownSearch = (ev: KeyboardEvent) => { + this.keyDownSearchInput(ev, true); + }; + clickPanelButton = (ev: Event) => { + const { editor } = this.props; + if (editor.readonly) return; + const target = ev.currentTarget as HTMLButtonElement; + const focus = editor.parseFocus(target.value) as InnerFocusState; + if (editor.stringifyFocus(editor.innerFocus) === target.value) { + this.pendingFocus = focus; + this.pendingFocusButton = false; + this.forceUpdate(); + return; + } + this.startFocusAnimation(target); + this.changeFocus(focus, false); + }; + keyDownPanelButton = (ev: KeyboardEvent) => { + if (ev.keyCode !== 9) return; + const target = ev.currentTarget as HTMLButtonElement; + const focus = this.props.editor.parseFocus(target.value); + if (!focus) return; + this.focusAdjacentField(focus, ev.shiftKey); + ev.preventDefault(); + }; + override closeInnerFocus = (ev?: Event) => { + const focus = this.props.editor.innerFocus; + if (!focus) return; + const expectedTop = this.base!.querySelector( + '.team-focus-editor .set-button' + )?.getBoundingClientRect().top ?? null; + const restoreNickname = document.activeElement?.getAttribute('name') === 'nickname'; + this.props.editor.innerFocus = null; + this.pendingFocus = null; + this.forceUpdate(() => { + const target = this.getOuterFocusTarget(focus, restoreNickname); + const setButton = this.getOuterSetButton(focus); + if (target && ((target as HTMLInputElement).name === 'nickname' || !target.classList.contains('set-field'))) { + target.focus({ preventScroll: true }); + (target as HTMLInputElement).select?.(); + } + this.restoreOuterSetScroll(setButton || target || null, expectedTop); + }); + this.props.onUpdate(); + ev?.stopImmediatePropagation(); + ev?.preventDefault(); + }; + handleKeyDown = (ev: KeyboardEvent) => { + if (ev.keyCode !== 27) return; + this.closeInnerFocus(ev); + return false; + }; + restoreOuterSetScroll(target: HTMLElement | null, expectedTop: number | null) { + if (!target || expectedTop === null) return; + const setButton = target.closest('.set-button') || target; + const dy = setButton.getBoundingClientRect().top - expectedTop; + if (!dy) return; + const scrollParent = this.getScrollParent(setButton); + if (scrollParent) { + scrollParent.scrollTop += dy; + } else { + window.scrollBy(0, dy); + } + } + getScrollParent(elem: HTMLElement) { + for (let parent = elem.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent); + if (!/(auto|scroll)/.test(style.overflowY)) continue; + if (parent.scrollHeight <= parent.clientHeight) continue; + return parent; + } + return null; + } + getOuterFocusTarget(focus: NonNullable, restoreNickname = false) { + if (restoreNickname) { + return this.base!.querySelector( + `input[data-focus="set-${focus.setIndex}-nickname"]` + ); + } + if (focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') { + return this.base!.querySelector( + `button[name="${focus.type}"][value="${this.props.editor.stringifyFocus(focus)}"]` + ); + } + if (focus.type === 'move' && focus.typeIndex === -1) { + return this.base!.querySelector( + `button[value="${this.props.editor.stringifyFocus(focus)}"]` + ); + } + // return this.base!.querySelector( + // `button[name=addpokemon][value="set-${focus.setIndex}-pokemon"]` + // ); + } + getOuterSetButton(focus: NonNullable) { + if (focus.setIndex >= this.props.editor.sets.length) return null; + return this.base!.querySelectorAll('.teameditor > .set-button.set-form')[focus.setIndex] || null; + } + scrollSelectedResult() { + const results = this.base!.querySelector('.wizardsearchresults'); + if (results) { + results.scrollTop = Math.max(0, this.props.editor.searchIndex * 33 - Math.trunc((window.innerHeight - 300) / 2)); + } + } + removeDuplicateMove(name: string) { + const { editor } = this.props; + const focus = editor.innerFocus; + if (!name) return false; + if (focus?.type !== 'move') return false; + const set = editor.sets[focus.setIndex]; + if (!set) return false; + const moveIndex = set.moves.indexOf(name); + if (moveIndex < 0 || moveIndex === focus.typeIndex) return false; + + set.moves.splice(moveIndex, 1); + let emptyIndex = 0; + while (emptyIndex < 4 && set.moves[emptyIndex]) emptyIndex++; + if (emptyIndex >= 4) emptyIndex = focus.typeIndex; + this.changeFocus({ + setIndex: focus.setIndex, + type: 'move', + typeIndex: emptyIndex, + }); + editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + return true; + } + setFocusedValue(name: string, reverse?: boolean) { + const focus = this.props.editor.innerFocus; + if (!focus) return; + if (this.removeDuplicateMove(name)) return; + const input = this.base!.querySelector( + `input.set-field[data-focus="${this.props.editor.stringifyFocus(focus)}"]` + ); + if (!input) return; + input.value = name; + this.commitField(input, true, reverse); + } + setMoveResult(name: string, slot?: string, reverse?: boolean) { + this.selectMoveResult(name, slot, reverse); + this.props.editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + } + override selectResult = (type: string | null, name: string, slot?: string, reverse?: boolean) => { + if (type === null) { + this.resetScroll(); + this.forceUpdate(); + } else if (!type) { + const searchBox = this.base!.querySelector('input[name=value]'); + if (searchBox) { + searchBox.value = ''; + searchBox.focus(); + } + this.props.editor.setSearchValue(''); + this.resetScroll(); + this.forceUpdate(); + } else if (type === 'move' && this.props.editor.innerFocus?.typeIndex === -1) { + this.setMoveResult(name, slot, reverse); + } else { + this.setFocusedValue(name); + } + }; + focusAdjacentField(focus: FocusState, reverse: boolean): boolean { + const set = this.props.editor.sets[focus.setIndex]; + const curField = `${focus.type}${focus.typeIndex === -1 ? '' : focus.typeIndex}`; + const fields: string[] = ['pokemon']; + if (set) { + if (this.showAbility(set)) fields.push('ability'); + if (this.showItem(set)) fields.push('item'); + for (let i = 0; i < Math.max(4, set.moves.length); i++) fields.push(`move${i}`); + fields.push('stats'); + fields.push('details'); + fields.push('nickname'); + } + const fieldIndex = fields.indexOf(curField); + if (fieldIndex < 0) return false; + + const next = fields[fieldIndex + (reverse ? -1 : 1)]; + if (!next && reverse && focus.type === 'pokemon') { + const prevButton = this.base!.querySelector( + `.team-focus-editor .set-button.set-form button[name=delete][value="${focus.setIndex}"]` + ) || this.base!.querySelector( + `.team-focus-editor .tabbar button[name=addpokemon]` + ) || this.base!.querySelector( + `.teameditor > .set-button.set-form button[name=delete][value="${focus.setIndex}"]` + ) || this.base!.querySelector( + `.teameditor button[name=addpokemon]` + ); + prevButton?.focus(); + return !!prevButton; + } + if (!next) return false; + if (next === 'nickname') { + const input = this.base!.querySelector( + `input[data-focus="set-${focus.setIndex}-nickname"]` + ); + input?.focus(); + input?.select(); + return !!input; + } + const nextType = next.startsWith('move') ? 'move' : next as InnerFocusType; + const nextTypeIndex = parseInt(next.slice(nextType.length) || '-1'); + this.changeFocus({ setIndex: focus.setIndex, type: nextType, typeIndex: nextTypeIndex }, true); + return true; + } + showItem(set: Dex.PokemonSet) { + const { editor } = this.props; + return !!(editor.gen > 1 && !editor.isLetsGo || set.item); + } + showAbility(set: Dex.PokemonSet) { + const { editor } = this.props; + return !!(editor.gen > 2 && !editor.isLetsGo || set.ability); + } + cur(type: InnerFocusType, setIndex: number, typeIndex = -1) { + const focus = this.props.editor.innerFocus; + return this.props.editor.readonly || ( + focus?.type === type && focus.setIndex === setIndex && focus.typeIndex === typeIndex + ) ? ' cur' : ''; + } + renderInput( + setIndex: number, type: InnerFocusType, value: string | undefined, + typeIndex = -1, placeholder = '' + ) { + const { editor } = this.props; + return ; + } + renderNicknameInput(setIndex: number) { + const { editor } = this.props; + const set = editor.sets[setIndex]; + const species = editor.dex.species.get(set.species); + return ; + } + override renderSet(set: Dex.PokemonSet | undefined, i: number) { + const { editor } = this.props; + const sprite = Dex.getTeambuilderSprite(set, editor.dex); + const spriteClass = set && Dex.getTeambuilderSpriteData(set, editor.dex).pixelated ? ' pixelated' : ''; + if (!set) { + return
+
+ {editor.deletedSet ? ( + + ) : ( + + )} +
+ + + + + + + + + + + +
+ + Pokemon {} + {this.renderInput(i, 'pokemon', '')} + +
+
; + } + while (set.moves.length < 4) set.moves.push(''); + + const species = editor.dex.species.get(set.species); + const tintClass = ` tint-${species.types[0]}`; + const isCur = TeamEditorState.clipboard?.teams?.[editor.team.key]?.sets[i] ? ' cur' : ''; + const overfull = set.moves.length > 5 ? ' overfull' : set.moves.length > 4 ? ' overfull overfull5' : ''; + return
+
+ {} + {} + {!(TeamEditorState.clipboard || editor.readonly) && } +
+ + + + + + + + + + + +
+ + + +
+ +
+ {species.types.map(type => <> )} +
+
+ {} + {[...set.moves, ...['', '', '', ''].slice(set.moves.length)].map((move, moveIndex) => ( +
{this.renderInput(i, 'move', move, moveIndex)}
+ ))} +
+ +
+ {this.showAbility(set) && } +
+ {this.showItem(set) && <> + {set.item && } + + } +
+
+ +
+
; + } +} + +function SetSourceButtons(props: { + editor: TeamEditorState, + set: Dex.PokemonSet, + onLoadSampleSet: (setName: string) => void, + onLoadUserSet: (setName: string) => void, +}) { + const { editor, set } = props; + const sampleSets = editor.getSampleSets(set); + const userSets = editor.getUserSets(set); + return <> + {sampleSets?.length !== 0 && ( +
+

Sample sets

+ {sampleSets ? ( +
+ {sampleSets.map(setName => <> + {} + )} +
+ ) : ( +
Loading...
+ )} +
+ )} + {userSets !== null && ( +
+

Box sets

+ {Object.keys(userSets).length > 0 ? ( +
+ {Object.keys(userSets).map(setName => <> + {} + )} +
+ ) : ( +
No {set.species} sets found in boxes
+ )} +
+ )} + ; +} + +class SetImportForm extends preact.Component<{ + editor: TeamEditorState, + set: Dex.PokemonSet, + setIndex: number, + onChange: () => void, +}, { + error: string, + copied: boolean, + dirty: boolean, + }> { + override state = { + error: '', + copied: false, + dirty: false, + }; + textbox: HTMLTextAreaElement | null = null; + revertText = ''; + getExportText() { + return Teams.exportSet(this.props.set, this.props.editor.dex, true).trim(); + } + override componentDidMount() { + this.setRevertPoint(); + } + override componentDidUpdate(prevProps: this['props']) { + if (prevProps.setIndex === this.props.setIndex) return; + this.setRevertPoint(); + } + setTextbox = (el: HTMLTextAreaElement | null) => { + this.textbox = el; + }; + setRevertPoint() { + if (!this.textbox) return; + this.revertText = this.getExportText(); + this.refreshText(this.revertText, false); + } + refreshText(text = this.getExportText(), dirty = text !== this.revertText) { + if (!this.textbox) return; + this.textbox.value = text; + this.textbox.focus(); + this.textbox.select(); + this.setState({ error: '', copied: false, dirty }); + } + revertTextToLastOpenedSet = () => { + const { editor, setIndex } = this.props; + if (editor.readonly || !this.textbox) return; + this.textbox.value = this.revertText; + const set = Teams.import(this.revertText)[0]; + if (!set) return; + editor.sets[setIndex] = set; + editor.save(); + this.props.onChange(); + this.textbox.focus(); + this.textbox.select(); + this.setState({ error: '', copied: false, dirty: false }); + }; + copyText = () => { + if (!this.textbox) return; + this.textbox.select(); + document.execCommand('copy'); + this.setState({ copied: true }); + }; + loadSampleSet = (setName: string) => { + const { editor, setIndex } = this.props; + if (!editor.loadSampleSet(setIndex, setName)) return; + this.refreshText(); + this.props.onChange(); + }; + loadUserSet = (setName: string) => { + const { editor, setIndex } = this.props; + if (!editor.loadUserSet(setIndex, setName)) return; + this.refreshText(); + this.props.onChange(); + }; + inputText = () => { + const { editor, setIndex } = this.props; + if (editor.readonly || !this.textbox) return; + const dirty = this.textbox.value !== this.revertText; + const set = Teams.import(this.textbox.value)[0]; + if (!set) { + this.setState({ error: 'No Pokemon set found.', copied: false, dirty }); + return; + } + editor.sets[setIndex] = set; + editor.save(); + this.props.onChange(); + this.setState({ error: '', copied: false, dirty }); + }; + override render() { + const { editor } = this.props; + return ; + } +} + class StatForm extends preact.Component<{ editor: TeamEditorState, set: Dex.PokemonSet, @@ -2468,12 +3550,12 @@ class StatForm extends preact.Component<{ const statName = editor.gen === 1 && statID === 'spa' ? 'Spc' : BattleStatNames[statID]; if (evs && !ev && !set.evs && statID === 'hp') ev = 'EVs'; return - {} + {statName} {} {} - {!evs && {stat}} - {evs && {ev || ''}} + {!evs && {stat}} + {evs && {ev || ''}} {evs && (BattleNatures[set.nature!]?.plus === statID ? ( + ) : BattleNatures[set.nature!]?.minus === statID ? ( @@ -2787,6 +3869,25 @@ class StatForm extends preact.Component<{ this.props.onChange(); }; + keyDownStatInput = (ev: KeyboardEvent) => { + // rearranges tab order to be all EVs, then all IVs + // (column-major instead of row-major) + if (ev.keyCode !== 9) return; + const target = ev.currentTarget as HTMLInputElement; + + const unsortedInputs = Array.from(this.base!.querySelectorAll('.stat-input')); + const evInputs = unsortedInputs.filter(input => input.name.startsWith('ev-')); + const ivInputs = unsortedInputs.filter(input => input.name.startsWith('iv-')); + const inputs = [...evInputs, ...ivInputs]; + + const inputIndex = inputs.indexOf(target); + if (inputIndex < 0) return; + const nextInput = inputs[inputIndex + (ev.shiftKey ? -1 : 1)]; + if (!nextInput) return; + nextInput.focus(); + nextInput.select(); + ev.preventDefault(); + }; updateNatureFromPlusMinus = () => { const { set } = this.props; set.nature = Teams.getNatureFromPlusMinus(this.plus, this.minus) || undefined; @@ -2914,8 +4015,8 @@ class StatForm extends preact.Component<{
{this.renderStatbar(stat, statID)} {stat}