diff --git a/play.pokemonshowdown.com/src/battle-team-editor.tsx b/play.pokemonshowdown.com/src/battle-team-editor.tsx index 3fecae8b2..f9ba7f20c 100644 --- a/play.pokemonshowdown.com/src/battle-team-editor.tsx +++ b/play.pokemonshowdown.com/src/battle-team-editor.tsx @@ -17,6 +17,7 @@ import { BattleNatures, BattleStatNames, type StatName } from "./battle-dex-data import { BattleStatGuesser, BattleStatOptimizer } from "./battle-tooltips"; import { PSModel } from "./client-core"; import { Net } from "./client-connection"; +import { PSIcon } from "./panels"; type SelectionType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details'; @@ -28,6 +29,18 @@ type SampleSets = { type SampleSetsTable = { dex?: SampleSets, stats?: SampleSets }; class TeamEditorState extends PSModel { + static clipboard: { + teams: { + [teamKey: string]: { + team: Team, + sets: { [index: number]: Dex.PokemonSet }, + /** whether to delete the team itself when moving it */ + entire: boolean, + }, + } | null, + otherSets: Dex.PokemonSet[] | null, + readonly: boolean, + } | null = null; team: Team; sets: Dex.PokemonSet[] = []; lastPackedTeam = ''; @@ -221,6 +234,79 @@ class TeamEditorState extends PSModel { this.sets.splice(this.deletedSet.index, 0, this.deletedSet.set); this.deletedSet = null; } + copySet(index: number) { + if (this.sets.length <= index) return; + + TeamEditorState.clipboard ||= { + teams: {}, + otherSets: null, + readonly: false, + }; + TeamEditorState.clipboard.teams ||= {}; + TeamEditorState.clipboard.teams[this.team.key] ||= { + team: this.team, sets: {}, entire: false, + }; + if (this.readonly) TeamEditorState.clipboard.readonly = true; + + if (TeamEditorState.clipboard.teams[this.team.key].sets[index] === this.sets[index]) { + // remove + delete TeamEditorState.clipboard.teams[this.team.key].sets[index]; + if (!Object.keys(TeamEditorState.clipboard.teams[this.team.key].sets).length) { + delete TeamEditorState.clipboard.teams[this.team.key]; + } + if (!Object.keys(TeamEditorState.clipboard.teams).length) { + TeamEditorState.clipboard.teams = null; + if (!TeamEditorState.clipboard.otherSets) { + TeamEditorState.clipboard = null; + } + } + return; + } + TeamEditorState.clipboard.teams[this.team.key].sets[index] = this.sets[index]; + } + pasteSet(index: number, isMove?: boolean) { + if (!TeamEditorState.clipboard) return; + if (this.readonly) return; + + if (isMove) { + if (TeamEditorState.clipboard.readonly) return; + + for (const key in TeamEditorState.clipboard.teams) { + const clipboardTeam = TeamEditorState.clipboard.teams[key]; + const sources = Object.keys(clipboardTeam.sets).map(Number); + // descending order, so splices won't affect future indices + sources.sort((a, b) => -(a - b)); + for (const source of sources) { + if (key === this.team.key) { + this.sets.splice(source, 1); + if (source < index) index--; + } else { + const team = clipboardTeam.team; + const sets = Teams.unpack(team.packedTeam); + sets.splice(source, 1); + team.packedTeam = Teams.pack(sets); + } + } + } + } + + const sets: Dex.PokemonSet[] = []; + for (const key in TeamEditorState.clipboard.teams) { + const clipboardTeam = TeamEditorState.clipboard.teams[key]; + for (const set of Object.values(clipboardTeam.sets)) { + sets.push(set); + } + } + sets.push(...TeamEditorState.clipboard.otherSets || []); + + for (const set of sets) { + // not the most efficient way to deepclone but we don't need efficiency here + const newSet = JSON.parse(JSON.stringify(set)) as Dex.PokemonSet; + this.sets.splice(index, 0, newSet); + index++; + } + TeamEditorState.clipboard = null; + } ignoreRows = ['header', 'sortpokemon', 'sortmove', 'html']; downSearchValue() { if (!this.search.results || this.searchIndex >= this.search.results.length - 1) return; @@ -743,9 +829,34 @@ export class TeamEditor extends preact.Component<{ {bad}{medium}{good}
; } + cancelClipboard = () => { + TeamEditorState.clipboard = null; + this.forceUpdate(); + }; update = () => { this.forceUpdate(); }; + renderClipboard() { + if (!TeamEditorState.clipboard) return null; + + const renderSet = (set: Dex.PokemonSet) =>
+ + {set.name || set.species} + {set.ability && ` [${set.ability}]`}{set.item && ` @ ${set.item}`} + {} - {set.moves.join(' / ') || '(No moves)'} + +
; + return
+ Clipboard + {Object.values(TeamEditorState.clipboard.teams || {})?.map(clipboardTeam => ( + Object.values(clipboardTeam.sets).map(set => renderSet(set)) + ))} + {TeamEditorState.clipboard.otherSets?.map(set => renderSet(set))} + +
; + } override render() { if (!this.editor) { this.editor = new TeamEditorState(this.props.team); @@ -770,6 +881,7 @@ export class TeamEditor extends preact.Component<{ Import/Export + {this.renderClipboard()} {this.wizard ? ( ) : ( @@ -933,7 +1045,7 @@ class TeamTextbox extends preact.Component<{ break; case 80: // p if (ev.metaKey) { - window.PS.alert(editor.export(this.compat)); + window.PS?.alert(editor.export(this.compat)); ev.stopImmediatePropagation(); ev.preventDefault(); break; @@ -1492,7 +1604,7 @@ class TeamTextbox extends preact.Component<{ document.execCommand('copy'); const button = ev?.currentTarget as HTMLButtonElement; if (button) { - button.innerHTML = ' Copied'; + button.innerHTML = ' Copied'; button.className += ' cur'; } }; @@ -1635,6 +1747,16 @@ class TeamWizard extends preact.Component<{ this.handleSetChange(); ev.preventDefault(); }; + copySet = (ev: Event) => { + const target = ev.currentTarget as HTMLButtonElement; + const i = parseInt(target.value); + const { editor } = this.props; + editor.copySet(i); + editor.innerFocus = null; + this.props.onUpdate(); + window.PS?.update(); + ev.preventDefault(); + }; undeleteSet = (ev: Event) => { const { editor } = this.props; const setIndex = editor.deletedSet?.index; @@ -1648,6 +1770,23 @@ class TeamWizard extends preact.Component<{ this.handleSetChange(); ev.preventDefault(); }; + pasteSet = (ev: Event) => { + const target = ev.currentTarget as HTMLButtonElement; + const i = parseInt(target.value); + const { editor } = this.props; + editor.pasteSet(i); + this.handleSetChange(); + window.PS?.update(); + ev.preventDefault(); + }; + moveSet = (ev: Event) => { + const target = ev.currentTarget as HTMLButtonElement; + const i = parseInt(target.value); + const { editor } = this.props; + editor.pasteSet(i, true); + this.handleSetChange(); + ev.preventDefault(); + }; changeFocus(focus: TeamEditorState['innerFocus']) { const { editor } = this.props; editor.innerFocus = focus; @@ -1710,11 +1849,20 @@ class TeamWizard extends preact.Component<{ editor.readonly || (editor.innerFocus?.type === t && editor.innerFocus.setIndex === i) ? ' cur' : '' ); const species = editor.dex.species.get(set.species); - return
+ const isCur = TeamEditorState.clipboard?.teams?.[editor.team.key]?.sets[i] ? ' cur' : ''; + return
- {} + {!(TeamEditorState.clipboard || editor.readonly) && + }
@@ -2112,17 +2260,32 @@ class TeamWizard extends preact.Component<{ return
Fetching Paste...
; } - const deletedSet = (i: number) => editor.deletedSet?.index === i ?

+ const clipboard = TeamEditorState.clipboard; + const willNotMove = (i: number) => ( + clipboard?.teams && !clipboard.otherSets && clipboard.teams[editor.team.key] && + Object.keys(clipboard.teams[editor.team.key]?.sets).length === 1 && + !!(clipboard.teams[editor.team.key]?.sets[i] || clipboard.teams[editor.team.key]?.sets[i - 1]) + ); + const pasteControls = (i: number) => editor.readonly ? ( + null + ) : clipboard ?

+ {} + {!willNotMove(i) && } +

: editor.deletedSet?.index === i ?

: null; return
{editor.sets.map((set, i) => [ - deletedSet(i), + pasteControls(i), this.renderSet(set, i), ])} - {deletedSet(editor.sets.length)} + {pasteControls(editor.sets.length)} {editor.canAdd() &&

} diff --git a/play.pokemonshowdown.com/src/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts index 6f39e3231..a96143691 100644 --- a/play.pokemonshowdown.com/src/client-main.ts +++ b/play.pokemonshowdown.com/src/client-main.ts @@ -2649,6 +2649,7 @@ export const PS = new class extends PSModel { this.closePopupsAbove(null, skipUpdate); } closePopupsAbove(room: PSRoom | null | undefined, skipUpdate?: boolean) { + if (!this.popups.length) return; // a while-loop may be simpler, but the loop invariant is very hard to prove // and any bugs (opening a popup while leaving a room) could lead to an infinite loop // a for-loop doesn't have that problem diff --git a/play.pokemonshowdown.com/style/teambuilder.css b/play.pokemonshowdown.com/style/teambuilder.css index b2c9bba11..25105d316 100644 --- a/play.pokemonshowdown.com/style/teambuilder.css +++ b/play.pokemonshowdown.com/style/teambuilder.css @@ -537,6 +537,9 @@ you can't delete it by pressing Backspace */ position: relative; max-width: 660px; } +.set-button.cur { + opacity: 0.5; +} .set-button table { border: 0; border-spacing: 0;