mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-24 21:55:52 +02:00
Archive commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
.action-bar(
|
||||
[class.has-scroller]="hasScroller" [class.is-mobile]="mobile" [class.is-blurred]="blurred"
|
||||
#scroller (mousewheel)="scroll($event)" (scroll)="true")
|
||||
action-button(
|
||||
*ngFor="let a of actions; let i = index"
|
||||
[class.blur-me]="i < blurCount"
|
||||
[action]="a.action"
|
||||
[active]="a.action && activeAction === a"
|
||||
[editable]="editable"
|
||||
[shortcut]="shortcuts[i] || ''"
|
||||
(use)="use(a.action)"
|
||||
[draggableItem]="a.action"
|
||||
[draggableDisabled]="!editable || !a.action"
|
||||
[draggablePad]="10"
|
||||
(draggableDrag)="drag(i)"
|
||||
(draggableDrop)="drop($event, i)")
|
||||
.action-button-padding
|
||||
.scroller-label
|
||||
| scroll using this bar
|
||||
@@ -0,0 +1,72 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
overflow: hidden;
|
||||
padding-bottom: 7px;
|
||||
padding-top: 7px;
|
||||
padding-right: 5px;
|
||||
max-width: calc(100vw - 50px);
|
||||
display: flex;
|
||||
|
||||
&.is-mobile {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.is-blurred > .blur-me {
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
// &.is-blurred:not(.is-mobile) > :nth-child(-n+11) {
|
||||
// filter: blur(3px);
|
||||
// }
|
||||
|
||||
// &.is-blurred.is-mobile > :nth-child(-n+9) {
|
||||
// filter: blur(3px);
|
||||
// }
|
||||
|
||||
&.has-scroller {
|
||||
padding-top: 25px;
|
||||
min-width: calc(100vw - 50px);
|
||||
}
|
||||
}
|
||||
|
||||
.action-button-padding {
|
||||
min-width: 1px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.scroller-label {
|
||||
display: none;
|
||||
|
||||
.has-scroller + & {
|
||||
display: block;
|
||||
background: #111;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 1px;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
action-button {
|
||||
pointer-events: auto;
|
||||
margin: 3px 6px;
|
||||
|
||||
.is-mobile & {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component, Input, ViewChild, ElementRef } from '@angular/core';
|
||||
import { ButtonAction } from '../../../common/interfaces';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { isMobile } from '../../../client/data';
|
||||
import { useAction, serializeActions } from '../../../client/buttonActions';
|
||||
import { SettingsService } from '../../services/settingsService';
|
||||
import { ACTIONS_LIMIT } from '../../../common/constants';
|
||||
import { last } from '../../../common/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'action-bar',
|
||||
templateUrl: 'action-bar.pug',
|
||||
styleUrls: ['action-bar.scss'],
|
||||
})
|
||||
export class ActionBar {
|
||||
@ViewChild('scroller', { static: true }) scroller!: ElementRef;
|
||||
@Input() blurred = false;
|
||||
activeAction: ButtonAction | undefined = undefined;
|
||||
shortcuts = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='];
|
||||
private _editable = false;
|
||||
constructor(private game: PonyTownGame, private settings: SettingsService) {
|
||||
}
|
||||
@Input() get editable() {
|
||||
return this._editable;
|
||||
}
|
||||
set editable(value) {
|
||||
if (this._editable !== value) {
|
||||
this._editable = value;
|
||||
this.updateFreeSlots();
|
||||
|
||||
if (!value) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
get actions() {
|
||||
return this.game.actions;
|
||||
}
|
||||
get mobile() {
|
||||
return isMobile;
|
||||
}
|
||||
get hasScroller() {
|
||||
return this.editable && isMobile;
|
||||
}
|
||||
get blurCount() {
|
||||
const boxWidth = isMobile ? 50 : 40;
|
||||
const width = 450 + this.scroller.nativeElement.scrollLeft;
|
||||
return Math.floor(width / boxWidth);
|
||||
}
|
||||
use(action: ButtonAction | undefined) {
|
||||
useAction(this.game, action);
|
||||
}
|
||||
drag(index: number) {
|
||||
this.actions[index].action = undefined;
|
||||
this.updateFreeSlots();
|
||||
}
|
||||
drop(action: ButtonAction | undefined, index: number) {
|
||||
this.actions[index].action = action;
|
||||
this.updateFreeSlots();
|
||||
}
|
||||
save() {
|
||||
const settings = { ...this.settings.account, actions: serializeActions(this.actions) };
|
||||
this.settings.saveAccountSettings(settings);
|
||||
}
|
||||
scroll(e: MouseWheelEvent) {
|
||||
if (e.deltaY) {
|
||||
const delta = e.deltaY > 0 ? 1 : -1;
|
||||
this.scroller.nativeElement.scrollLeft += delta * 20;
|
||||
}
|
||||
}
|
||||
private updateFreeSlots() {
|
||||
const actions = this.actions;
|
||||
|
||||
if (this.editable) {
|
||||
while (actions.length < 5 || (last(actions)!.action !== undefined && actions.length < ACTIONS_LIMIT)) {
|
||||
actions.push({ action: undefined });
|
||||
}
|
||||
} else {
|
||||
while (actions.length > 0 && last(actions)!.action === undefined) {
|
||||
actions.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
button.action-button(
|
||||
(click)="click()" [class.active]="active" [class.no-shadow]="!shadow" [title]="action && action.title || ''")
|
||||
canvas(#canvas width="29" height="29")
|
||||
.shortcut {{shortcut}}
|
||||
.count(*ngIf="action && action.type === 'item'") {{action.count}}
|
||||
.cover
|
||||
@@ -0,0 +1,95 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
$button-size: 29px;
|
||||
$border-radius: 8px;
|
||||
$default-box-shadow: 0 0 3px rgba(0, 0, 0, 0.7) !important;
|
||||
$hover-box-shadow: 0 0 7px rgba(0, 0, 0, 0.8) !important;
|
||||
$pressed-box-shadow: 0 0 7px rgba(0, 0, 0, 1) !important;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
|
||||
&.draggable-hover {
|
||||
box-shadow: 0 0 5px 1px white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
box-shadow: $default-box-shadow;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
|
||||
&:hover, &:focus:hover {
|
||||
box-shadow: $hover-box-shadow;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: $default-box-shadow;
|
||||
}
|
||||
|
||||
&:active, &:hover:active, &:focus:hover:active {
|
||||
box-shadow: $pressed-box-shadow, inset 0 0 0 black;
|
||||
|
||||
> canvas {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
&.no-shadow, &:focus.no-shadow, &:focus:hover.no-shadow {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -2px;
|
||||
font-size: 9px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-shadow: 0px 0px 1px black, 0px 0px 1px black, 0px 0px 1px black,
|
||||
0px 0px 1px black, 0px 0px 1px black;
|
||||
}
|
||||
|
||||
.count {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
bottom: -7px;
|
||||
right: -2px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-shadow: 0px 0px 1px black, 0px 0px 1px black, 0px 0px 1px black,
|
||||
0px 0px 1px black, 0px 0px 1px black;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, Input, ViewChild, ElementRef, ChangeDetectionStrategy, Output, EventEmitter } from '@angular/core';
|
||||
import { ButtonAction } from '../../../common/interfaces';
|
||||
import { PonyTownGame, actionButtons } from '../../../client/game';
|
||||
import { drawAction } from '../../../client/buttonActions';
|
||||
import { removeItem } from '../../../common/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'action-button',
|
||||
templateUrl: 'action-button.pug',
|
||||
styleUrls: ['action-button.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.empty]': '!editable && !action',
|
||||
},
|
||||
})
|
||||
export class ActionButton {
|
||||
@Input() action?: ButtonAction;
|
||||
@Input() editable = false;
|
||||
@Input() active = false;
|
||||
@Input() shadow = true;
|
||||
@Input() shortcut = '';
|
||||
@Output() use = new EventEmitter<ButtonAction>();
|
||||
@ViewChild('canvas', { static: true }) canvas!: ElementRef;
|
||||
dirty = true;
|
||||
private state: any = {};
|
||||
constructor(private game: PonyTownGame) {
|
||||
}
|
||||
ngOnInit() {
|
||||
actionButtons.push(this);
|
||||
}
|
||||
ngOnDestroy() {
|
||||
removeItem(actionButtons, this);
|
||||
}
|
||||
ngOnChanges() {
|
||||
this.dirty = true;
|
||||
}
|
||||
click() {
|
||||
if (this.action) {
|
||||
this.use.emit(this.action);
|
||||
}
|
||||
}
|
||||
draw() {
|
||||
drawAction(this.canvas.nativeElement, this.action, this.state, this.game);
|
||||
this.dirty = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
.modal-header.d-none.d-sm-block.d-none-for-low-height
|
||||
h4.modal-title(labelledBy=".modal")
|
||||
| Actions
|
||||
|
||||
.modal-body.modal-checkboxes
|
||||
tabset.fixed-height(
|
||||
type="pills" saveActiveTab="testing-save-tab" [destroyOnHide]="false"
|
||||
(activeIndexChange)="tabIndex = $event")
|
||||
tab(title="Actions" [icon]="actionsIcon")
|
||||
.action-columns(*tabContent)
|
||||
.d-flex.align-items-center.mb-2(*ngFor="let a of actions")
|
||||
action-button.mb-0.mr-2([draggableItem]="a" [action]="a" [shadow]="false")
|
||||
b {{a.title}}
|
||||
|
||||
tab(title="Expressions" [icon]="expressionsIcon")
|
||||
div(*tabContent)
|
||||
.d-flex.mb-3
|
||||
action-button.mb-0.mr-sm-4(
|
||||
[draggableItem]="emoteAction" [action]="emoteAction" [shadow]="false" (draggableDrop)="drop($event)")
|
||||
.sub-tabset.nav.nav-pills.mb-0
|
||||
button.btn-unstyled.nav-link([class.active]="activeTab === 'right-eye'" (click)="activeTab = 'right-eye'") Right eye
|
||||
button.btn-unstyled.nav-link([class.active]="activeTab === 'left-eye'" (click)="activeTab = 'left-eye'") Left eye
|
||||
button.btn-unstyled.nav-link([class.active]="activeTab === 'mouth'" (click)="activeTab = 'mouth'") Mouth
|
||||
button.btn-unstyled.nav-link([class.active]="activeTab === 'extra'" (click)="activeTab = 'extra'") Extra
|
||||
.fixed-height-tabset
|
||||
div([class.inactive-tab]="activeTab !== 'right-eye'")
|
||||
sprite-selection(
|
||||
[(selected)]="eyeRight" [sprites]="eyesRight" [fill]="eyeColor"
|
||||
[circle]="coatFill" [reverseExtra]="true" [skip]="1"
|
||||
[invisible]="tabIndex !== 1 || activeTab !== 'right-eye'"
|
||||
(selectedChange)="changed(lockEyes)")
|
||||
|
||||
div([class.inactive-tab]="activeTab !== 'left-eye'")
|
||||
custom-checkbox.mb-2([(checked)]="lockEyes" (checkedChange)="changed($event)")
|
||||
| Use the same as right one
|
||||
sprite-selection(
|
||||
[(selected)]="eyeLeft" [sprites]="eyesLeft" [fill]="eyeColor" [disabled]="lockEyes"
|
||||
[circle]="coatFill" [reverseExtra]="true" [skip]="1"
|
||||
[invisible]="tabIndex !== 1 || activeTab !== 'left-eye'"
|
||||
(selectedChange)="changed(lockEyes)")
|
||||
|
||||
div([class.inactive-tab]="activeTab !== 'mouth'")
|
||||
sprite-selection#expression-selection(
|
||||
[(selected)]="muzzle" [sprites]="muzzles" [fill]="noseFills"
|
||||
[outline]="noseOutlines" [circle]="coatFill"
|
||||
[invisible]="tabIndex !== 1 || activeTab !== 'mouth'"
|
||||
(selectedChange)="changed(lockEyes)")
|
||||
|
||||
div([class.inactive-tab]="activeTab !== 'extra'")
|
||||
sprite-selection.mb-2(
|
||||
[(selected)]="irisRight" [sprites]="irisesRight" [fill]="eyeColor"
|
||||
[invisible]="tabIndex !== 1 || activeTab !== 'extra'"
|
||||
[circle]="coatFill" [reverseExtra]="true" (selectedChange)="changed(lockEyes)")
|
||||
custom-checkbox([(checked)]="lockIrises" (checkedChange)="changed(lockEyes)")
|
||||
| Use the same as right one
|
||||
sprite-selection.mt-2(
|
||||
[(selected)]="irisLeft" [sprites]="irisesLeft" [fill]="eyeColor" [disabled]="lockIrises"
|
||||
[invisible]="tabIndex !== 1 || activeTab !== 'extra'"
|
||||
[circle]="coatFill" [reverseExtra]="true" (selectedChange)="changed(lockEyes)")
|
||||
.d-flex.flex-wrap.mt-3
|
||||
label.text-muted.mr-3 Other options
|
||||
custom-checkbox([(checked)]="blush" (checkedChange)="changed(lockEyes)") Blush
|
||||
custom-checkbox.ml-4([(checked)]="sleeping" (checkedChange)="changed(lockEyes)") Sleeping
|
||||
custom-checkbox.ml-4([(checked)]="tears" (checkedChange)="changed(lockEyes)") Tears
|
||||
custom-checkbox.ml-4([(checked)]="crying" (checkedChange)="changed(lockEyes)") Crying
|
||||
custom-checkbox.ml-4([(checked)]="hearts" (checkedChange)="changed(lockEyes)") Hearts
|
||||
|
||||
tab(title="Chat" [icon]="chatIcon")
|
||||
.action-columns(*tabContent)
|
||||
.d-flex.align-items-center.mb-2(*ngFor="let a of commands")
|
||||
action-button.mb-0.mr-2([draggableItem]="a" [action]="a" [shadow]="false")
|
||||
b {{a.title}}
|
||||
|
||||
tab(title="Options" [icon]="optionsIcon")
|
||||
div(*tabContent)
|
||||
button.btn.btn-outline-secondary.mr-2.mb-2((click)="resetToDefault()")
|
||||
| Reset to default actions
|
||||
button.btn.btn-outline-secondary.mr-2.mb-2((click)="clearActionBar()")
|
||||
| Clear action bar
|
||||
button.btn.btn-outline-secondary.mr-2.mb-2((click)="undo()")
|
||||
| Undo
|
||||
|
||||
tab(*ngIf="dev" title="Dev" [icon]="devIcon")
|
||||
div(*tabContent)
|
||||
.d-flex.mb-3
|
||||
action-button.mb-0.mr-4(
|
||||
[draggableItem]="entityAction" [action]="entityAction" [shadow]="false" (draggableDrop)="drop($event)")
|
||||
input.form-control(
|
||||
placeholder="entity name" [(ngModel)]="entityName" (ngModelChange)="updateEntity()" style="max-width: 200px;")
|
||||
.d-flex.flex-wrap(style="height: 250px; overflow-y: scroll")
|
||||
action-button(*ngFor="let a of entityActions" [draggableItem]="a" [action]="a")
|
||||
|
||||
.modal-footer.d-flex
|
||||
.flex-grow-1
|
||||
em.text-muted Drag actions to the action bar
|
||||
button.btn.btn-outline-secondary((click)="ok()")
|
||||
| Close
|
||||
@@ -0,0 +1,40 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
action-button {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.fixed-height-tabset {
|
||||
display: grid;
|
||||
|
||||
> div {
|
||||
grid-row: 1 / 2;
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-tabset {
|
||||
font-size: 13px;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
.nav-link {
|
||||
margin-right: 0.1rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> div {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.inactive-tab {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
createButtionActionActions, expressionButtonAction, createButtonCommandActions,
|
||||
createDefaultButtonActions, actionExpressionDefaultPalette, entityButtonAction
|
||||
} from '../../../client/buttonActions';
|
||||
import * as sprites from '../../../generated/sprites';
|
||||
import {
|
||||
Eye, Muzzle, ColorExtraSet, ColorExtra, Iris, ExpressionExtra, PonyEye, ButtonAction, Action,
|
||||
ButtonActionSlot, EntityButtonAction
|
||||
} from '../../../common/interfaces';
|
||||
import { createExpression } from '../../../client/clientUtils';
|
||||
import { ACTION_EXPRESSION_BG, ACTION_EXPRESSION_EYE_COLOR, fillToOutline } from '../../../common/colors';
|
||||
import { faLock, faApple, faLaughBeam, faComment, faCog, faCogs } from '../../../client/icons';
|
||||
import { createEyeSprite } from '../../../client/spriteUtils';
|
||||
import { times, hasFlag } from '../../../common/utils';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { getEntityNames } from '../../services/model';
|
||||
|
||||
function eyeSprite(e: PonyEye | undefined) {
|
||||
return createEyeSprite(e, 0, sprites.defaultPalette);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'actions-modal',
|
||||
templateUrl: 'actions-modal.pug',
|
||||
styleUrls: ['actions-modal.scss'],
|
||||
})
|
||||
export class ActionsModal implements OnInit, OnDestroy {
|
||||
readonly lockIcon = faLock;
|
||||
readonly actionsIcon = faApple;
|
||||
readonly expressionsIcon = faLaughBeam;
|
||||
readonly chatIcon = faComment;
|
||||
readonly optionsIcon = faCog;
|
||||
readonly devIcon = faCogs;
|
||||
readonly dev = BETA;
|
||||
@Output() close = new EventEmitter();
|
||||
actions = createButtionActionActions();
|
||||
commands = createButtonCommandActions();
|
||||
emoteAction = expressionButtonAction(createExpression(Eye.Neutral, Eye.Neutral, Muzzle.Smile));
|
||||
entityAction = entityButtonAction('apple');
|
||||
entityActions: EntityButtonAction[] = [];
|
||||
entityName = 'apple';
|
||||
lockEyes = true;
|
||||
lockIrises = true;
|
||||
eyesLeft: ColorExtraSet = sprites.eyeLeft.map(e => e && e[0]).map(eyeSprite);
|
||||
eyesRight: ColorExtraSet = sprites.eyeRight.map(e => e && e[0]).map(eyeSprite);
|
||||
irisesLeft: ColorExtraSet = times(Iris.COUNT, i => createEyeSprite(sprites.eyeLeft[1]![0]!, i, sprites.defaultPalette));
|
||||
irisesRight: ColorExtraSet = times(Iris.COUNT, i => createEyeSprite(sprites.eyeRight[1]![0]!, i, sprites.defaultPalette));
|
||||
muzzles: ColorExtraSet = sprites.noses
|
||||
.map(n => n[0][0])
|
||||
.map(({ color, colors, mouth }) => ({
|
||||
color, colors, extra: mouth, palettes: [actionExpressionDefaultPalette.colors]
|
||||
} as ColorExtra));
|
||||
noseFills = [ACTION_EXPRESSION_BG];
|
||||
noseOutlines = [fillToOutline(ACTION_EXPRESSION_BG)];
|
||||
coatFill = ACTION_EXPRESSION_BG;
|
||||
eyeColor = ACTION_EXPRESSION_EYE_COLOR;
|
||||
muzzle: Muzzle = 0;
|
||||
eyeLeft: Eye = 1;
|
||||
eyeRight: Eye = 1;
|
||||
irisLeft: Iris = 0;
|
||||
irisRight: Iris = 0;
|
||||
tabIndex = 0;
|
||||
blush = false;
|
||||
sleeping = false;
|
||||
tears = false;
|
||||
crying = false;
|
||||
hearts = false;
|
||||
activeTab = 'right-eye';
|
||||
private interval: any = 0;
|
||||
private subscription?: Subscription;
|
||||
private actionsToUndo: ButtonActionSlot[][] = [];
|
||||
constructor(private game: PonyTownGame) {
|
||||
this.updateEmoteAction();
|
||||
}
|
||||
ngOnInit() {
|
||||
document.body.classList.add('actions-modal-opened');
|
||||
this.game.editingActions = true;
|
||||
this.interval = setInterval(() => this.game.send(server => server.action(Action.KeepAlive)), 10000);
|
||||
this.subscription = this.game.onLeft.subscribe(() => this.ok());
|
||||
|
||||
if (BETA) {
|
||||
this.entityActions = getEntityNames().map(name => entityButtonAction(name));
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
document.body.classList.remove('actions-modal-opened');
|
||||
this.game.editingActions = false;
|
||||
clearInterval(this.interval);
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
ok() {
|
||||
this.close.emit();
|
||||
}
|
||||
changed(locked: boolean) {
|
||||
if (locked) {
|
||||
this.eyeLeft = this.eyeRight;
|
||||
}
|
||||
|
||||
if (this.lockIrises) {
|
||||
this.irisLeft = this.irisRight;
|
||||
}
|
||||
|
||||
this.updateEmoteAction();
|
||||
}
|
||||
drop(action: ButtonAction) {
|
||||
if (action.type === 'expression' && action.expression) {
|
||||
const e = action.expression;
|
||||
this.lockEyes = e.right === e.left;
|
||||
this.lockIrises = e.rightIris === e.leftIris;
|
||||
this.eyeRight = e.right;
|
||||
this.eyeLeft = e.left;
|
||||
this.muzzle = e.muzzle;
|
||||
this.irisRight = e.rightIris;
|
||||
this.irisLeft = e.leftIris;
|
||||
this.blush = hasFlag(e.extra, ExpressionExtra.Blush);
|
||||
this.sleeping = hasFlag(e.extra, ExpressionExtra.Zzz);
|
||||
this.tears = hasFlag(e.extra, ExpressionExtra.Tears);
|
||||
this.crying = hasFlag(e.extra, ExpressionExtra.Cry);
|
||||
this.hearts = hasFlag(e.extra, ExpressionExtra.Hearts);
|
||||
this.changed(this.lockEyes);
|
||||
}
|
||||
}
|
||||
updateEmoteAction() {
|
||||
const extra =
|
||||
(this.blush ? ExpressionExtra.Blush : 0) |
|
||||
(this.sleeping ? ExpressionExtra.Zzz : 0) |
|
||||
(this.tears ? ExpressionExtra.Tears : 0) |
|
||||
(this.crying ? ExpressionExtra.Cry : 0) |
|
||||
(this.hearts ? ExpressionExtra.Hearts : 0);
|
||||
|
||||
const expression = createExpression(this.eyeRight, this.eyeLeft, this.muzzle, this.irisRight, this.irisLeft, extra);
|
||||
this.emoteAction = expressionButtonAction(expression);
|
||||
}
|
||||
resetToDefault() {
|
||||
this.actionsToUndo.push(this.game.actions);
|
||||
this.game.actions = [...createDefaultButtonActions(), { action: undefined }];
|
||||
}
|
||||
clearActionBar() {
|
||||
this.actionsToUndo.push(this.game.actions);
|
||||
this.game.actions = this.game.actions.map(() => ({ action: undefined }));
|
||||
}
|
||||
undo() {
|
||||
if (this.actionsToUndo.length) {
|
||||
this.game.actions = this.actionsToUndo.pop()!;
|
||||
}
|
||||
}
|
||||
updateEntity() {
|
||||
if (BETA) {
|
||||
this.entityAction = entityButtonAction(this.entityName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.bitmap-box-row(*ngFor="let row of rows")
|
||||
.bitmap-box-cell(*ngFor="let cell of row" [style.background]="colorAt(cell)" (mousedown)="draw(cell)")
|
||||
@@ -0,0 +1,27 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
:host {
|
||||
border: solid 1px $border-color;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bitmap-box-row {
|
||||
display: flex;
|
||||
|
||||
&:last-child > .bitmap-box-cell {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bitmap-box-cell {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-right: solid 1px $border-color;
|
||||
border-bottom: solid 1px $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { parseColor, colorToCSS } from '../../../common/color';
|
||||
|
||||
@Component({
|
||||
selector: 'bitmap-box',
|
||||
templateUrl: 'bitmap-box.pug',
|
||||
styleUrls: ['bitmap-box.scss'],
|
||||
})
|
||||
export class BitmapBox implements OnChanges {
|
||||
@Input() width = 5;
|
||||
@Input() height = 5;
|
||||
@Input() bitmap?: string[];
|
||||
@Input() tool?: string;
|
||||
@Input() color = 'red';
|
||||
@Output() colorChange = new EventEmitter<string>();
|
||||
rows?: number[][];
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.width || changes.height) {
|
||||
this.rows = [];
|
||||
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
this.rows[y] = [];
|
||||
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
this.rows[y][x] = x + this.width * y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
draw(index: number) {
|
||||
if (this.bitmap) {
|
||||
if (this.tool === 'eraser') {
|
||||
this.bitmap[index] = '';
|
||||
} else if (this.tool === 'brush') {
|
||||
this.bitmap[index] = parseColor(this.bitmap[index]) === parseColor(this.color) ? '' : this.color;
|
||||
} else if (this.tool === 'eyedropper') {
|
||||
this.color = this.bitmap[index];
|
||||
this.colorChange.emit(this.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
colorAt(index: number) {
|
||||
return this.bitmap && this.bitmap[index] ? colorToCSS(parseColor(this.bitmap[index])) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.form-group.text-center
|
||||
label#butt-mark-label.text-muted Butt mark
|
||||
.row.form-group(role="group" aria-labelledby="butt-mark-label")
|
||||
.col-sm-7.mb-1
|
||||
button.btn.btn-primary((click)="clearCM()" title="Clear all")
|
||||
fa-icon([icon]="trashIcon" [fixedWidth]="true")
|
||||
.btn-group.ml-1(btnRadioGroup [(ngModel)]="state.brushType")
|
||||
button.btn.btn-primary(btnRadio="eraser" title="Eraser")
|
||||
fa-icon([icon]="eraserIcon" [fixedWidth]="true")
|
||||
button.btn.btn-primary(btnRadio="eyedropper" title="Eyedropper")
|
||||
fa-icon([icon]="eyeDropperIcon" [fixedWidth]="true")
|
||||
button.btn.btn-primary(btnRadio="brush" title="Brush")
|
||||
fa-icon([icon]="paintBrushIcon" [fixedWidth]="true")
|
||||
.col-sm-5.mb-1
|
||||
color-picker([(color)]="state.brush" label="Brush color")
|
||||
.form-group.text-center
|
||||
bitmap-box([bitmap]="info.cm" [tool]="state.brushType" [(color)]="state.brush" [width]="cmSize" [height]="cmSize")
|
||||
.form-group.d-flex.justify-content-center.align-items-center.text-muted
|
||||
custom-checkbox([(checked)]="info.cmFlip")
|
||||
| don't flip mark on the right side
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { fill } from 'lodash';
|
||||
import { PonyInfo } from '../../../common/interfaces';
|
||||
import { CM_SIZE } from '../../../common/constants';
|
||||
import { faTrash, faEraser, faPaintBrush, faEyeDropper } from '../../../client/icons';
|
||||
|
||||
export interface ButtMarkEditorState {
|
||||
brushType: string;
|
||||
brush: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'butt-mark-editor',
|
||||
templateUrl: 'butt-mark-editor.pug',
|
||||
})
|
||||
export class ButtMarkEditor {
|
||||
readonly trashIcon = faTrash;
|
||||
readonly eraserIcon = faEraser;
|
||||
readonly eyeDropperIcon = faEyeDropper;
|
||||
readonly paintBrushIcon = faPaintBrush;
|
||||
readonly cmSize = CM_SIZE;
|
||||
@Input() info!: PonyInfo;
|
||||
@Input() state = {
|
||||
brushType: 'brush',
|
||||
brush: 'orange',
|
||||
};
|
||||
clearCM() {
|
||||
fill(this.info.cm!, '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.sr-only(#ariaAnnounce aria-live="assertive")
|
||||
|
||||
.character-list([class.character-list-searchable]="searchable" [class.in-game]="inGame")
|
||||
|
||||
.dropdown.character-select-search.input-group(
|
||||
(mousedown)="$event.stopPropagation()" (mouseup)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation()" (keydown)="keydown($event)")
|
||||
input.form-control(
|
||||
#searchInput type="search" [placeholder]="placeholder" [(ngModel)]="search" (input)="updatePonies()"
|
||||
autocomplete="nope")
|
||||
.input-group-append(*ngIf="tags.length" #tagsDropdown="ag-dropdown" dropdown)
|
||||
button.btn.btn-secondary.rounded-right(dropdownToggle title="Search by tags")
|
||||
fa-icon([icon]="hashIcon")
|
||||
.dropdown-menu.dropdown-menu-right.shadow.tag-list(*dropdownMenu)
|
||||
button.dropdown-item(*ngFor="let tag of tags" (click)="search = tag; tagsDropdown.close(); updatePonies()")
|
||||
| {{tag}}
|
||||
|
||||
ul.character-select-list(role="listbox" [attr.aria-activedescendant]="'pony-item-' + selectedIndex")
|
||||
li(
|
||||
*ngFor="let p of ponies; let i = index"
|
||||
[id]="'pony-item-' + i"
|
||||
[class.active]="i === selectedIndex"
|
||||
[class.selected]="p?.id === selectedPony?.id")
|
||||
a.d-flex(role="option" (click)="select(p)" (mouseenter)="setPreview(p)" (mouseleave)="unsetPreview(p)")
|
||||
span.flex-grow-1.character-name {{p.name}}
|
||||
span.character-desc.text-muted {{p.desc}}
|
||||
li(*ngIf="canNew")
|
||||
a.text-center(role="option" (click)="createNew()")
|
||||
em new pony
|
||||
@@ -0,0 +1,108 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.character-list {
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 70px);
|
||||
}
|
||||
|
||||
.character-list.in-game {
|
||||
max-width: calc(100vw - 90px);
|
||||
}
|
||||
|
||||
.character-select-search {
|
||||
height: 30px;
|
||||
padding: 0 5px;
|
||||
display: none;
|
||||
position: relative;
|
||||
|
||||
> .form-control {
|
||||
background: white;
|
||||
color: #222;
|
||||
border-color: #eee !important;
|
||||
border-radius: 3px !important;
|
||||
box-shadow: none !important;
|
||||
// width: 100%;
|
||||
|
||||
&:active, &:focus {
|
||||
border-color: #ddd !important;
|
||||
}
|
||||
}
|
||||
|
||||
.character-list-searchable > & {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.hash-button {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 5px;
|
||||
font-size: 16px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.character-select-list {
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
font-size: $font-size-base;
|
||||
text-align: left;
|
||||
padding-top: 1px;
|
||||
max-height: 400px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
> li {
|
||||
&.active {
|
||||
background: $nav-tabs-link-active-color;
|
||||
}
|
||||
|
||||
&.selected > a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
padding: 3px 20px;
|
||||
clear: both;
|
||||
font-weight: normal;
|
||||
line-height: $line-height-base;
|
||||
color: $dropdown-link-color;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: none;
|
||||
color: $dropdown-link-hover-color;
|
||||
background-color: $dropdown-link-hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.character-list-searchable > & {
|
||||
border-top: solid 1px #eee;
|
||||
margin-top: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.character-desc {
|
||||
font-size: smaller;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 4px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Component, Output, EventEmitter, ViewChild, ElementRef, OnInit, Input, NgZone } from '@angular/core';
|
||||
import { uniq } from 'lodash';
|
||||
import { Key } from '../../../client/input/input';
|
||||
import { PonyObject } from '../../../common/interfaces';
|
||||
import { clamp, flatten } from '../../../common/utils';
|
||||
import { isMobile } from '../../../client/data';
|
||||
import { Model } from '../../services/model';
|
||||
import { LATEST_CHARACTER_LIMIT } from '../../../common/constants';
|
||||
import { faHashtag } from '../../../client/icons';
|
||||
|
||||
function getSortTag(pony: PonyObject) {
|
||||
const match = pony.desc && /(?:^| )@(top|end|\d+)(?:$| )/.exec(pony.desc);
|
||||
return match && match[1];
|
||||
}
|
||||
|
||||
function sortTagToNumber(tag: string) {
|
||||
if (tag === 'top') {
|
||||
return -1;
|
||||
} else if (tag === 'end') {
|
||||
return 999999999;
|
||||
} else {
|
||||
return +tag;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackComparePonies(a: PonyObject, b: PonyObject) {
|
||||
return a.name.localeCompare(b.name) || (a.desc || '').localeCompare(b.desc || '');
|
||||
}
|
||||
|
||||
function comparePonies(a: PonyObject, b: PonyObject) {
|
||||
const aTag = getSortTag(a);
|
||||
const bTag = getSortTag(b);
|
||||
|
||||
if (aTag && bTag) {
|
||||
return (sortTagToNumber(aTag) - sortTagToNumber(bTag)) || fallbackComparePonies(a, b);
|
||||
} else if (aTag) {
|
||||
return aTag === 'end' ? 1 : -1;
|
||||
} else if (bTag) {
|
||||
return bTag === 'end' ? -1 : 1;
|
||||
} else {
|
||||
return fallbackComparePonies(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'character-list',
|
||||
templateUrl: 'character-list.pug',
|
||||
styleUrls: ['character-list.scss'],
|
||||
})
|
||||
export class CharacterList implements OnInit {
|
||||
readonly hashIcon = faHashtag;
|
||||
@Input() inGame = false;
|
||||
@Input() canNew = false;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() newCharacter = new EventEmitter<void>();
|
||||
@Output() selectCharacter = new EventEmitter<PonyObject>();
|
||||
@Output() previewCharacter = new EventEmitter<PonyObject | undefined>();
|
||||
@ViewChild('ariaAnnounce', { static: true }) ariaAnnounce!: ElementRef;
|
||||
@ViewChild('searchInput', { static: true }) searchInput!: ElementRef;
|
||||
search?: string;
|
||||
selectedIndex = -1;
|
||||
ponies: PonyObject[] = [];
|
||||
tags: string[] = [];
|
||||
private previewPony: PonyObject | undefined = undefined;
|
||||
constructor(private model: Model, private zone: NgZone) {
|
||||
}
|
||||
get selectedPony() {
|
||||
return this.model.pony;
|
||||
}
|
||||
get searchable() {
|
||||
return this.model.ponies.length > LATEST_CHARACTER_LIMIT;
|
||||
}
|
||||
get placeholder() {
|
||||
return `search (${this.model.ponies.length} / ${this.model.characterLimit} ponies)`;
|
||||
}
|
||||
ngOnInit() {
|
||||
this.updatePonies();
|
||||
|
||||
this.tags = uniq(flatten(this.ponies.map(p => (p.desc || '').split(/ /g).map(x => x.trim())))
|
||||
.filter(x => /^#/.test(x)))
|
||||
.sort();
|
||||
|
||||
if (!isMobile) {
|
||||
setTimeout(() => this.searchInput.nativeElement.focus());
|
||||
}
|
||||
}
|
||||
keydown(e: KeyboardEvent) {
|
||||
if (e.keyCode === Key.ESCAPE) {
|
||||
if (this.search) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.search = '';
|
||||
this.updatePonies();
|
||||
} else {
|
||||
this.closed();
|
||||
}
|
||||
} else if (e.keyCode === Key.ENTER) {
|
||||
const pony = this.ponies[this.selectedIndex];
|
||||
|
||||
if (pony) {
|
||||
this.select(pony);
|
||||
} else {
|
||||
this.closed();
|
||||
}
|
||||
} else if (e.keyCode === Key.UP) {
|
||||
this.setSelectedIndex(this.selectedIndex <= 0 ? (this.ponies.length - 1) : (this.selectedIndex - 1));
|
||||
} else if (e.keyCode === Key.DOWN) {
|
||||
this.setSelectedIndex(this.selectedIndex === (this.ponies.length - 1) ? 0 : (this.selectedIndex + 1));
|
||||
}
|
||||
}
|
||||
setPreview(pony: PonyObject) {
|
||||
this.previewPony = pony;
|
||||
this.previewCharacter.emit(this.model.parsePonyObject(pony));
|
||||
}
|
||||
unsetPreview(pony: PonyObject) {
|
||||
if (this.previewPony && pony && this.previewPony.id === pony.id) {
|
||||
this.previewPony = undefined;
|
||||
this.previewCharacter.emit(undefined);
|
||||
}
|
||||
}
|
||||
updatePonies() {
|
||||
this.zone.run(() => {
|
||||
const query = this.search && this.search.toLowerCase().trim();
|
||||
|
||||
function matchesWords(text: string, words: string[]) {
|
||||
for (const word of words) {
|
||||
if (text.indexOf(word) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const words = query.split(/ /g).map(x => x.trim());
|
||||
|
||||
this.ponies = this.model.ponies.filter(pony => {
|
||||
const text = `${pony.name} ${pony.desc || ''}`.toLowerCase();
|
||||
return matchesWords(text, words);
|
||||
}).sort(comparePonies);
|
||||
} else {
|
||||
this.ponies = this.model.ponies.slice().sort(comparePonies);
|
||||
}
|
||||
|
||||
this.setSelectedIndex(this.selectedIndex);
|
||||
this.previewCharacter.emit(undefined);
|
||||
});
|
||||
}
|
||||
select(pony: PonyObject) {
|
||||
this.selectCharacter.emit(pony);
|
||||
}
|
||||
createNew() {
|
||||
this.newCharacter.emit();
|
||||
}
|
||||
private closed() {
|
||||
this.zone.run(() => this.close.emit());
|
||||
}
|
||||
private setSelectedIndex(index: number) {
|
||||
this.zone.run(() => {
|
||||
this.selectedIndex = clamp(index, -1, this.ponies.length - 1);
|
||||
const pony = this.ponies[index];
|
||||
this.ariaAnnounce.nativeElement.textContent = pony ? pony.name : '';
|
||||
|
||||
if (pony) {
|
||||
this.setPreview(pony);
|
||||
} else if (this.previewPony) {
|
||||
this.unsetPreview(this.previewPony);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Component, Input, ElementRef, AfterViewInit, OnDestroy, NgZone, ViewChild, OnChanges, HostListener
|
||||
} from '@angular/core';
|
||||
import { PonyInfo, PonyState } from '../../../common/interfaces';
|
||||
import { toPalette } from '../../../common/ponyInfo';
|
||||
import { GRASS_COLOR, TRANSPARENT } from '../../../common/colors';
|
||||
import {
|
||||
createCanvas, disableImageSmoothing, getPixelRatio, resizeCanvas, resizeCanvasWithRatio
|
||||
} from '../../../client/canvasUtils';
|
||||
import { BLINK_FRAMES } from '../../../client/ponyUtils';
|
||||
import { defaultPonyState, defaultDrawPonyOptions } from '../../../client/ponyHelpers';
|
||||
import { ContextSpriteBatch } from '../../../graphics/contextSpriteBatch';
|
||||
import { colorToCSS } from '../../../common/color';
|
||||
import { loadAndInitSpriteSheets } from '../../../client/spriteUtils';
|
||||
import { drawNamePlate, commonPalettes, DrawNameFlags } from '../../../graphics/graphicsUtils';
|
||||
import { drawPony } from '../../../client/ponyDraw';
|
||||
import { paletteSpriteSheet } from '../../../generated/sprites';
|
||||
import { replaceEmojis } from '../../../client/emoji';
|
||||
|
||||
const DEFAULT_STATE = defaultPonyState();
|
||||
const DEFAULT_OPTIONS = defaultDrawPonyOptions();
|
||||
|
||||
@Component({
|
||||
selector: 'character-preview',
|
||||
template: '<canvas class="rounded" #canvas></canvas>',
|
||||
styles: [`:host { display: block; } canvas { width: 100%; height: 100%; }`],
|
||||
})
|
||||
export class CharacterPreview implements OnDestroy, OnChanges, AfterViewInit {
|
||||
@Input() scale = 3;
|
||||
@Input() name?: string;
|
||||
@Input() tag?: string;
|
||||
@Input() pony?: PonyInfo;
|
||||
@Input() state?: PonyState = defaultPonyState();
|
||||
@Input() noBackground = false;
|
||||
@Input() noOutline = false;
|
||||
@Input() noShadow = false;
|
||||
@Input() extra = false;
|
||||
@Input() passive = false;
|
||||
@Input() blinks = true;
|
||||
@ViewChild('canvas', { static: true }) canvas!: ElementRef;
|
||||
private batch?: ContextSpriteBatch;
|
||||
private nameBatch?: ContextSpriteBatch;
|
||||
private frame = 0;
|
||||
private lastFrame = 0;
|
||||
private initialized = false;
|
||||
private nextBlink = performance.now() + 2000;
|
||||
private blinkFrame = -1;
|
||||
constructor(private zone: NgZone) {
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
return loadAndInitSpriteSheets()
|
||||
.then(() => this.initialized = true)
|
||||
.then(() => this.ngOnChanges());
|
||||
}
|
||||
ngOnDestroy() {
|
||||
cancelAnimationFrame(this.frame);
|
||||
}
|
||||
ngOnChanges() {
|
||||
if (!this.frame) {
|
||||
this.zone.runOutsideAngular(() => this.frame = requestAnimationFrame(this.onFrame));
|
||||
}
|
||||
}
|
||||
@HostListener('window:resize')
|
||||
redraw() {
|
||||
this.tryDraw();
|
||||
}
|
||||
blink() {
|
||||
this.nextBlink = performance.now();
|
||||
}
|
||||
private onFrame = () => {
|
||||
if (this.passive && this.initialized) {
|
||||
this.frame = 0;
|
||||
this.tryDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
this.frame = requestAnimationFrame(this.onFrame);
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
if ((now - this.lastFrame) > (1000 / 24)) {
|
||||
if (this.blinks) {
|
||||
if (this.blinkFrame === -1) {
|
||||
if (this.nextBlink < now) {
|
||||
this.blinkFrame = 0;
|
||||
}
|
||||
} else {
|
||||
this.blinkFrame++;
|
||||
|
||||
if (this.blinkFrame >= BLINK_FRAMES.length) {
|
||||
this.nextBlink = now + Math.random() * 2000 + 3000;
|
||||
this.blinkFrame = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state) {
|
||||
this.state.blinkFrame = this.blinkFrame === -1 ? 1 : BLINK_FRAMES[this.blinkFrame];
|
||||
}
|
||||
}
|
||||
|
||||
this.lastFrame = now;
|
||||
this.tryDraw();
|
||||
}
|
||||
}
|
||||
private tryDraw() {
|
||||
try {
|
||||
this.draw();
|
||||
} catch { }
|
||||
}
|
||||
private draw() {
|
||||
if (!this.initialized)
|
||||
return;
|
||||
|
||||
const canvas = this.canvas.nativeElement as HTMLCanvasElement;
|
||||
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
resizeCanvasWithRatio(canvas, width, height, false);
|
||||
|
||||
const scale = this.scale * getPixelRatio();
|
||||
const bufferWidth = Math.round(canvas.width / scale);
|
||||
const bufferHeight = Math.round(canvas.height / scale);
|
||||
|
||||
if (!bufferWidth || !bufferHeight)
|
||||
return;
|
||||
|
||||
this.batch = this.batch || new ContextSpriteBatch(createCanvas(bufferWidth, bufferHeight));
|
||||
resizeCanvas(this.batch.canvas, bufferWidth, bufferHeight);
|
||||
|
||||
const x = Math.round(bufferWidth / 2);
|
||||
const y = Math.round(bufferHeight / 2 + 28);
|
||||
|
||||
if (this.pony) {
|
||||
this.batch.start(paletteSpriteSheet, this.noBackground ? TRANSPARENT : GRASS_COLOR);
|
||||
|
||||
try {
|
||||
const options = { ...DEFAULT_OPTIONS, shadow: !this.noShadow, extra: !!this.extra };
|
||||
drawPony(this.batch, toPalette(this.pony), this.state || DEFAULT_STATE, x, y, options);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
this.batch.end();
|
||||
}
|
||||
|
||||
const viewContext = canvas.getContext('2d');
|
||||
|
||||
if (!viewContext)
|
||||
return;
|
||||
|
||||
disableImageSmoothing(viewContext);
|
||||
|
||||
if (this.noBackground) {
|
||||
viewContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
viewContext.save();
|
||||
viewContext.scale(scale, scale);
|
||||
|
||||
// draw outline
|
||||
if (this.pony && this.noShadow && this.noBackground && !this.noOutline) {
|
||||
for (let x = -1; x <= 1; x++) {
|
||||
for (let y = -1; y <= 1; y++) {
|
||||
viewContext.drawImage(this.batch.canvas, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
viewContext.globalCompositeOperation = 'source-in';
|
||||
viewContext.fillStyle = colorToCSS(GRASS_COLOR);
|
||||
viewContext.fillRect(0, 0, viewContext.canvas.width, viewContext.canvas.height);
|
||||
viewContext.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
viewContext.drawImage(this.batch.canvas, 0, 0);
|
||||
viewContext.restore();
|
||||
|
||||
// draw name plate
|
||||
if (!this.noShadow && this.name) {
|
||||
const name = replaceEmojis(this.name);
|
||||
const scale = 2 * getPixelRatio();
|
||||
const nameBufferWidth = Math.round(canvas.width / scale);
|
||||
this.nameBatch = this.nameBatch || new ContextSpriteBatch(createCanvas(nameBufferWidth, 25));
|
||||
resizeCanvas(this.nameBatch.canvas, nameBufferWidth, 25);
|
||||
this.nameBatch.start(paletteSpriteSheet, TRANSPARENT);
|
||||
drawNamePlate(this.nameBatch, name, nameBufferWidth / 2, 11, DrawNameFlags.None, commonPalettes, this.tag);
|
||||
this.nameBatch.end();
|
||||
viewContext.save();
|
||||
viewContext.scale(scale, scale);
|
||||
viewContext.drawImage(this.nameBatch.canvas, 0, 10);
|
||||
viewContext.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.sr-only(#ariaAnnounce aria-live="assertive")
|
||||
|
||||
.character-select.input-group
|
||||
input.form-control.text-center(
|
||||
#nameInput
|
||||
type="text"
|
||||
[(ngModel)]="pony.name"
|
||||
[maxlength]="maxNameLength"
|
||||
placeholder="Name of your character"
|
||||
aria-label="Name of your character")
|
||||
.input-group-append
|
||||
button.btn.btn-default(*ngIf="newButton" (click)="createNew()" [disabled]="!canNew" aria-label="Create New character")
|
||||
| new
|
||||
button.btn.btn-default(*ngIf="editButton" (click)="edit()" [disabled]="!canEdit" aria-label="Edit character")
|
||||
| edit
|
||||
|
||||
.dropdown(dropdown #dropdown="ag-dropdown" (isOpenChange)="onToggle($event)" [focusOnOpen]="false")
|
||||
button.btn.btn-default.dropdown-toggle.br-0(
|
||||
[ngClass]="removeButton ? 'btn-no-round' : 'btn-no-round-left'"
|
||||
[disabled]="!hasPonies || joining" dropdownToggle aria-label="Select character")
|
||||
character-list.dropdown-menu(
|
||||
*dropdownMenu (close)="dropdown.close()" (selectCharacter)="select($event)" (newCharacter)="createNew()"
|
||||
(previewCharacter)="preview.emit($event)" [canNew]="!newButton && canNew")
|
||||
|
||||
button.btn.btn-danger.remove-button(
|
||||
*ngIf="removeButton && !removing" (click)="remove()" [disabled]="!canRemove"
|
||||
tooltip="Delete pony" aria-label="Delete pony")
|
||||
fa-icon([icon]="deleteIcon" [fixedWidth]="true")
|
||||
button.btn.btn-danger.cancel-remove-button(
|
||||
*ngIf="removing" (click)="cancelRemove()"
|
||||
tooltip="Cancel delete" aria-label="Cancel delete")
|
||||
fa-icon([icon]="removeIcon" [fixedWidth]="true")
|
||||
button.btn.btn-success(
|
||||
*ngIf="removing" (click)="confirmRemove()" [disabled]="!canRemove"
|
||||
tooltip="Confirm delete" aria-label="Confirm delete")
|
||||
fa-icon([icon]="confirmIcon" [fixedWidth]="true")
|
||||
@@ -0,0 +1,14 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.character-select {
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
character-list {
|
||||
@include media-breakpoint-down(sm) {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Component, Input, EventEmitter, Output, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PonyObject } from '../../../common/interfaces';
|
||||
import { PLAYER_NAME_MAX_LENGTH } from '../../../common/constants';
|
||||
import { VERSION_ERROR } from '../../../common/errors';
|
||||
import { GameService } from '../../services/gameService';
|
||||
import { Model, createDefaultPonyObject } from '../../services/model';
|
||||
import { Dropdown } from '../directives/dropdown';
|
||||
import { faSpinner, faTrash, faTimes, faCheck } from '../../../client/icons';
|
||||
import { focusElementAfterTimeout } from '../../../client/htmlUtils';
|
||||
import { delay } from '../../../common/utils';
|
||||
import { isMobile } from '../../../client/data';
|
||||
|
||||
@Component({
|
||||
selector: 'character-select',
|
||||
templateUrl: 'character-select.pug',
|
||||
styleUrls: ['character-select.scss'],
|
||||
})
|
||||
export class CharacterSelect {
|
||||
readonly maxNameLength = PLAYER_NAME_MAX_LENGTH;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly deleteIcon = faTrash;
|
||||
readonly removeIcon = faTimes;
|
||||
readonly confirmIcon = faCheck;
|
||||
@Input() newButton = false;
|
||||
@Input() editButton = false;
|
||||
@Input() removeButton = false;
|
||||
@Input() error?: string;
|
||||
@Output() errorChange = new EventEmitter<string | undefined>();
|
||||
@Output() change = new EventEmitter<PonyObject>();
|
||||
@Output() preview = new EventEmitter<PonyObject | undefined>();
|
||||
@ViewChild('nameInput', { static: true }) nameInput!: ElementRef;
|
||||
@ViewChild('ariaAnnounce', { static: true }) ariaAnnounce!: ElementRef;
|
||||
@ViewChild('dropdown', { static: true }) dropdown!: Dropdown;
|
||||
removing = false;
|
||||
private locked = false; // TEMP: move to model
|
||||
constructor(
|
||||
private element: ElementRef,
|
||||
private router: Router,
|
||||
private model: Model,
|
||||
private gameService: GameService,
|
||||
) {
|
||||
}
|
||||
get joining() {
|
||||
return this.gameService.joining;
|
||||
}
|
||||
get pony() {
|
||||
return this.model.pony;
|
||||
}
|
||||
get canNew() {
|
||||
return !this.joining && this.model.account && this.model.account.characterCount < this.model.characterLimit;
|
||||
}
|
||||
get canEdit() {
|
||||
return !this.joining;
|
||||
}
|
||||
get canRemove() {
|
||||
return !this.joining && !this.locked && !this.model.pending && !!this.pony
|
||||
&& !!this.pony.id && this.error !== VERSION_ERROR;
|
||||
}
|
||||
get hasPonies() {
|
||||
return !!this.model.ponies.length;
|
||||
}
|
||||
select(pony: PonyObject) {
|
||||
if (pony) {
|
||||
this.removing = false;
|
||||
this.model.selectPony(pony);
|
||||
this.change.emit(pony);
|
||||
this.preview.emit(undefined);
|
||||
}
|
||||
|
||||
this.dropdown.close();
|
||||
this.focusName();
|
||||
}
|
||||
createNew() {
|
||||
if (this.canNew) {
|
||||
this.removing = false;
|
||||
this.model.selectPony(createDefaultPonyObject());
|
||||
this.change.emit(this.pony);
|
||||
this.router.navigate(['/character']);
|
||||
this.focusName();
|
||||
}
|
||||
}
|
||||
edit() {
|
||||
if (this.canEdit) {
|
||||
this.removing = false;
|
||||
this.router.navigate(['/character']);
|
||||
}
|
||||
}
|
||||
remove() {
|
||||
if (this.canRemove) {
|
||||
this.removing = true;
|
||||
focusElementAfterTimeout(this.element.nativeElement, '.cancel-remove-button');
|
||||
}
|
||||
}
|
||||
cancelRemove() {
|
||||
this.removing = false;
|
||||
focusElementAfterTimeout(this.element.nativeElement, '.remove-button');
|
||||
}
|
||||
confirmRemove() {
|
||||
if (this.canRemove) {
|
||||
this.setError(undefined);
|
||||
this.removing = false;
|
||||
this.locked = true;
|
||||
|
||||
this.model.removePony(this.pony)
|
||||
.then(() => this.change.emit(this.pony))
|
||||
.catch((e: Error) => this.setError(e.message))
|
||||
.then(() => this.ariaAnnounce.nativeElement.textContent = 'Character removed')
|
||||
.then(() => delay(2000))
|
||||
.then(() => this.locked = false)
|
||||
.then(() => this.focusName());
|
||||
}
|
||||
}
|
||||
onToggle(show: boolean) {
|
||||
if (!show) {
|
||||
this.preview.emit(undefined);
|
||||
}
|
||||
}
|
||||
private focusName() {
|
||||
if (!isMobile) {
|
||||
this.nameInput.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
private setError(error: string | undefined) {
|
||||
this.error = error;
|
||||
this.errorChange.emit(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.chat-box(#chatBox)
|
||||
div(#chatBoxInput)
|
||||
input.chat-input#chat-input(
|
||||
#inputElement [(ngModel)]="message" (keydown)="keydown($event)" [maxlength]="maxSayLength"
|
||||
aria-label="Chat message")
|
||||
.chat-box-type(#typeBox (click)="toggleChatType()")
|
||||
| #[span(#typePrefix)][#[span.chat-box-type-name(#typeName)]]:
|
||||
button.game-button.chat-send-button((click)="send($event)" title="Send message" aria-label="Send message")
|
||||
fa-icon([icon]="sendIcon")
|
||||
|
||||
button.game-button.chat-open-button(
|
||||
(click)="toggle()" (mousedown)="$event.preventDefault()" [disabled]="disabled" title="Toggle chat"
|
||||
aria-label="Toggle message")
|
||||
fa-icon([icon]="commentIcon" [fixedWidth]="true")
|
||||
@@ -0,0 +1,88 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.chat-box {
|
||||
max-width: 500px;
|
||||
height: 37px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.chat-open-button {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.chat-send-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
opacity: 0.6;
|
||||
background: black;
|
||||
color: white;
|
||||
border: none;
|
||||
z-index: 1;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
padding-right: 35px;
|
||||
padding-left: 90px;
|
||||
border-radius: $border-radius-base;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-box-type {
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
top: 8px;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
.chat-party > &, .chat-party-think > & {
|
||||
color: $chat-party;
|
||||
}
|
||||
|
||||
.chat-say > &, .chat-think > & {
|
||||
color: $chat-color;
|
||||
}
|
||||
|
||||
.chat-sup > & {
|
||||
color: $supporter-1;
|
||||
}
|
||||
|
||||
.chat-sup1 > & {
|
||||
color: $supporter-1;
|
||||
}
|
||||
|
||||
.chat-sup2 > & {
|
||||
color: $supporter-2;
|
||||
}
|
||||
|
||||
.chat-sup3 > & {
|
||||
color: $supporter-3;
|
||||
}
|
||||
|
||||
.chat-whisper > & {
|
||||
color: $chat-whisper;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-box-type-name {
|
||||
display: inline-block;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { Component, ElementRef, NgZone, AfterViewInit, ViewChild, OnDestroy, Input } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ChatType, isPartyChat, Entity, FakeEntity } from '../../../common/interfaces';
|
||||
import { SAY_MAX_LENGTH } from '../../../common/constants';
|
||||
import { Key } from '../../../client/input/input';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { cleanMessage, isSpamMessage } from '../../../client/clientUtils';
|
||||
import { faComment, faAngleDoubleRight } from '../../../client/icons';
|
||||
import { isInParty } from '../../../client/partyUtils';
|
||||
import { handleActionCommand } from '../../../client/playerActions';
|
||||
import { hasHeadAnimation } from '../../../common/pony';
|
||||
import { AutocompleteState, autocompleteMesssage, replaceEmojis } from '../../../client/emoji';
|
||||
import { replaceNodes } from '../../../client/htmlUtils';
|
||||
import { invalidEnumReturn } from '../../../common/utils';
|
||||
import { findMatchingEntityNames, findEntityOrMockByAnyMeans, findBestEntityByName } from '../../../client/handlers';
|
||||
|
||||
const chatTypeNames: string[] = [];
|
||||
const chatTypeClasses: string[] = [];
|
||||
|
||||
function setupChatType(type: ChatType, name: string) {
|
||||
chatTypeNames[type] = name;
|
||||
chatTypeClasses[type] = `chat-${name.replace(/ /, '-')}`;
|
||||
}
|
||||
|
||||
setupChatType(ChatType.Say, 'say');
|
||||
setupChatType(ChatType.Party, 'party');
|
||||
setupChatType(ChatType.Supporter, 'sup');
|
||||
setupChatType(ChatType.Supporter1, 'sup1');
|
||||
setupChatType(ChatType.Supporter2, 'sup2');
|
||||
setupChatType(ChatType.Supporter3, 'sup3');
|
||||
setupChatType(ChatType.Whisper, 'whisper');
|
||||
setupChatType(ChatType.Think, 'think');
|
||||
setupChatType(ChatType.PartyThink, 'party think');
|
||||
|
||||
function isActionCommand(message: string) {
|
||||
return /^\/(yawn|sneeze|achoo|laugh|lol|haha|хаха|jaja)/i.test(message);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'chat-box',
|
||||
templateUrl: 'chat-box.pug',
|
||||
styleUrls: ['chat-box.scss'],
|
||||
})
|
||||
export class ChatBox implements AfterViewInit, OnDestroy {
|
||||
readonly maxSayLength = SAY_MAX_LENGTH;
|
||||
readonly commentIcon = faComment;
|
||||
readonly sendIcon = faAngleDoubleRight;
|
||||
@ViewChild('inputElement', { static: true }) inputElement!: ElementRef;
|
||||
@ViewChild('typeBox', { static: true }) typeBox!: ElementRef;
|
||||
@ViewChild('typePrefix', { static: true }) typePrefix!: ElementRef;
|
||||
@ViewChild('typeName', { static: true }) typeName!: ElementRef;
|
||||
@ViewChild('chatBox', { static: true }) chatBox!: ElementRef;
|
||||
@ViewChild('chatBoxInput', { static: true }) chatBoxInput!: ElementRef;
|
||||
isOpen = false;
|
||||
message: string | undefined = '';
|
||||
chatType = ChatType.Say;
|
||||
private pasted = false;
|
||||
private lastMessages: string[] = [];
|
||||
private state: AutocompleteState = {};
|
||||
private subscriptions: Subscription[];
|
||||
private _disabled = false;
|
||||
constructor(private game: PonyTownGame, zone: NgZone) {
|
||||
this.subscriptions = [
|
||||
this.game.onChat.subscribe(() => zone.run(() => this.chat(undefined))),
|
||||
this.game.onToggleChat.subscribe(() => zone.run(() => this.toggle())),
|
||||
this.game.onCommand.subscribe(() => zone.run(() => this.command())),
|
||||
this.game.onLeft.subscribe(() => {
|
||||
this.chatType = ChatType.Say;
|
||||
this.close();
|
||||
}),
|
||||
];
|
||||
|
||||
this.game.onCancel = () => this.isOpen ? (zone.run(() => this.close()), true) : false;
|
||||
}
|
||||
@Input() get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
set disabled(value) {
|
||||
this._disabled = value;
|
||||
|
||||
if (value) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
get input() {
|
||||
return this.inputElement.nativeElement as HTMLInputElement;
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
this.chatBox.nativeElement.hidden = true;
|
||||
this.input.addEventListener('paste', () => this.pasted = true);
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(s => s.unsubscribe());
|
||||
}
|
||||
send(_event: Event | undefined) {
|
||||
let chatType = this.chatType;
|
||||
let message = replaceEmojis(cleanMessage(this.message || '')).substr(0, SAY_MAX_LENGTH);
|
||||
const handled = handleActionCommand(message, this.game);
|
||||
const spam = this.pasted && chatType !== ChatType.Party && isSpamMessage(message, this.lastMessages);
|
||||
const empty = !this.game.player || !message;
|
||||
const ignoreAction = isActionCommand(message) && this.game.player && hasHeadAnimation(this.game.player);
|
||||
const whisperTo = this.game.whisperTo;
|
||||
let entityId = whisperTo && whisperTo.id || 0;
|
||||
|
||||
if (/^\/(w|whisper) .+$/i.test(message)) {
|
||||
chatType = ChatType.Whisper;
|
||||
message = message.substr(/^\/w /i.test(message) ? 3 : 9);
|
||||
|
||||
let offset = 0;
|
||||
let entity: Entity | FakeEntity | undefined = undefined;
|
||||
|
||||
do {
|
||||
offset = message.indexOf(' ', offset);
|
||||
|
||||
if (offset === -1)
|
||||
break;
|
||||
|
||||
const name = message.substr(0, offset);
|
||||
entity = findBestEntityByName(this.game, name);
|
||||
offset++;
|
||||
} while (!entity);
|
||||
|
||||
if (entity) {
|
||||
message = message.substr(offset);
|
||||
entityId = entity.id;
|
||||
} else {
|
||||
entityId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled || spam || empty || ignoreAction || this.say(message, chatType, entityId)) {
|
||||
if (message) {
|
||||
this.lastMessages.push(message);
|
||||
|
||||
while (this.lastMessages.length > 5) {
|
||||
this.lastMessages.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
keydown(e: KeyboardEvent) {
|
||||
if (e.keyCode !== Key.TAB && e.keyCode !== Key.SHIFT) {
|
||||
this.state.lastEmoji = undefined;
|
||||
}
|
||||
|
||||
if (e.keyCode === Key.TAB) {
|
||||
if (this.message) {
|
||||
if (/^\/(w|whisper) .+$/i.test(this.message)) {
|
||||
const space = this.message.indexOf(' ');
|
||||
const names = findMatchingEntityNames(this.game, this.message.substr(space + 1));
|
||||
|
||||
if (names.length === 1) {
|
||||
this.message = `${this.message.substring(0, space)} ${names[0]}`;
|
||||
}
|
||||
} else {
|
||||
this.message = autocompleteMesssage(this.message, e.shiftKey, this.state);
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
} else if (e.keyCode === Key.ENTER && this.isOpen) {
|
||||
this.send(e);
|
||||
} else if (e.keyCode === Key.ESCAPE) {
|
||||
this.close();
|
||||
e.preventDefault();
|
||||
} else if (e.keyCode === Key.SPACE) {
|
||||
if (!this.message)
|
||||
return;
|
||||
|
||||
const isParty = /^\/(p|party)$/i.test(this.message);
|
||||
const isSay = /^\/(s|say)$/i.test(this.message);
|
||||
const isSup = /^\/(ss)$/i.test(this.message);
|
||||
const isSup1 = /^\/(s1)$/i.test(this.message);
|
||||
const isSup2 = /^\/(s2)$/i.test(this.message);
|
||||
const isSup3 = /^\/(s3)$/i.test(this.message);
|
||||
|
||||
const supporter = this.game.model.supporter;
|
||||
const isSayOrInvalid = isSay
|
||||
|| (isParty && !isInParty(this.game))
|
||||
|| (isSup && supporter === 0)
|
||||
|| (isSup1 && supporter < 1)
|
||||
|| (isSup2 && supporter < 2)
|
||||
|| (isSup3 && supporter < 3);
|
||||
|
||||
if (isSayOrInvalid) {
|
||||
this.changeChatType(e, ChatType.Say);
|
||||
} else if (isParty) {
|
||||
this.changeChatType(e, ChatType.Party);
|
||||
} else if (isSup) {
|
||||
this.changeChatType(e, ChatType.Supporter);
|
||||
} else if (isSup1) {
|
||||
this.changeChatType(e, ChatType.Supporter1);
|
||||
} else if (isSup2) {
|
||||
this.changeChatType(e, ChatType.Supporter2);
|
||||
} else if (isSup3) {
|
||||
this.changeChatType(e, ChatType.Supporter3);
|
||||
} else if (/^\/(t|think)$/i.test(this.message)) {
|
||||
if (isPartyChat(this.chatType)) {
|
||||
this.changeChatType(e, ChatType.PartyThink);
|
||||
} else {
|
||||
this.changeChatType(e, ChatType.Think);
|
||||
}
|
||||
} else if (/^\/(r|reply)$/i.test(this.message)) {
|
||||
const lastWhisperFrom = this.game.lastWhisperFrom;
|
||||
const entity = lastWhisperFrom && findEntityOrMockByAnyMeans(this.game, lastWhisperFrom.entityId);
|
||||
|
||||
if (entity) {
|
||||
this.game.whisperTo = entity;
|
||||
this.changeChatType(e, ChatType.Whisper);
|
||||
} else {
|
||||
this.changeChatType(e, ChatType.Say);
|
||||
}
|
||||
} else if (/^\/(w|whisper) .+$/i.test(this.message) && !e.shiftKey) {
|
||||
const name = this.message.substr(/^\/w /i.test(this.message) ? 3 : 9);
|
||||
const entity = findBestEntityByName(this.game, name);
|
||||
|
||||
if (entity) {
|
||||
this.game.whisperTo = entity;
|
||||
this.changeChatType(e, ChatType.Whisper);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private say(message: string, chatType: ChatType, entityId: number): boolean {
|
||||
this.game.lastChatMessageType = chatType;
|
||||
return !!this.game.send(server => server.say(entityId, message, chatType));
|
||||
}
|
||||
private changeChatType(e: KeyboardEvent, chatType: ChatType) {
|
||||
this.chatType = chatType;
|
||||
this.message = '';
|
||||
this.updateChatType();
|
||||
e.preventDefault();
|
||||
}
|
||||
private chat(event: Event | undefined) {
|
||||
if (this.isOpen) {
|
||||
this.send(event);
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
private command() {
|
||||
if (!this.isOpen) {
|
||||
this.chat(undefined);
|
||||
this.message = '/';
|
||||
this.input.selectionStart = this.input.selectionEnd = 10000;
|
||||
}
|
||||
}
|
||||
private open() {
|
||||
if (!this.isOpen) {
|
||||
this.isOpen = true;
|
||||
this.chatBox.nativeElement.hidden = false;
|
||||
}
|
||||
|
||||
this.chatType = isValidChatType(this.chatType, this.game) ? this.chatType : ChatType.Say;
|
||||
this.updateChatType();
|
||||
this.input.focus();
|
||||
}
|
||||
private close() {
|
||||
if (this.isOpen) {
|
||||
this.input.blur();
|
||||
this.isOpen = false;
|
||||
this.chatBox.nativeElement.hidden = true;
|
||||
this.message = '';
|
||||
this.pasted = false;
|
||||
}
|
||||
}
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
toggleChatType() {
|
||||
const chatTypes = getChatTypes(this.game);
|
||||
this.chatType = chatTypes[(chatTypes.indexOf(this.chatType) + 1) % chatTypes.length];
|
||||
this.updateChatType();
|
||||
this.input.focus();
|
||||
}
|
||||
setChatType(type: 'say' | 'party' | 'whisper') {
|
||||
if (type === 'say') {
|
||||
this.chatType = ChatType.Say;
|
||||
this.open();
|
||||
} else if (type === 'party' && isInParty(this.game)) {
|
||||
this.chatType = ChatType.Party;
|
||||
this.open();
|
||||
} else if (type === 'whisper') {
|
||||
this.chatType = ChatType.Whisper;
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
private currentTypeClass = '';
|
||||
private currentTypePrefix = '';
|
||||
private currentTypeName = '';
|
||||
private updateChatType() {
|
||||
let typeName: string;
|
||||
let typePrefix: string;
|
||||
let changed = false;
|
||||
|
||||
const typeClass = chatTypeClass(this.chatType, this.game.model.supporter);
|
||||
|
||||
if (this.currentTypeClass !== typeClass) {
|
||||
this.currentTypeClass = typeClass;
|
||||
(this.chatBoxInput.nativeElement as HTMLElement).className = typeClass;
|
||||
}
|
||||
|
||||
if (this.chatType === ChatType.Whisper) {
|
||||
typePrefix = 'To ';
|
||||
typeName = this.game.whisperTo && this.game.whisperTo.name || 'unknown';
|
||||
} else {
|
||||
typePrefix = '';
|
||||
typeName = chatTypeNames[this.chatType];
|
||||
}
|
||||
|
||||
if (this.currentTypePrefix !== typePrefix) {
|
||||
changed = true;
|
||||
this.currentTypePrefix = typePrefix;
|
||||
(this.typePrefix.nativeElement as HTMLElement).textContent = typePrefix;
|
||||
}
|
||||
|
||||
if (this.currentTypeName !== typeName) {
|
||||
changed = true;
|
||||
this.currentTypeName = typeName;
|
||||
replaceNodes(this.typeName.nativeElement, typeName);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
const { width } = (this.typeBox.nativeElement as HTMLElement).getBoundingClientRect();
|
||||
const padding = 35 + 13 + Math.ceil(width);
|
||||
(this.inputElement.nativeElement as HTMLElement).style.paddingLeft = `${padding}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chatTypeClass(chatType: ChatType, supporter: number) {
|
||||
if (chatType === ChatType.Supporter) {
|
||||
switch (supporter) {
|
||||
case 1: return 'chat-sup chat-sup1';
|
||||
case 2: return 'chat-sup chat-sup2';
|
||||
case 3: return 'chat-sup chat-sup3';
|
||||
}
|
||||
}
|
||||
|
||||
return chatTypeClasses[chatType];
|
||||
}
|
||||
|
||||
function isValidChatType(type: ChatType, game: PonyTownGame) {
|
||||
const supporter = game.model.supporter;
|
||||
|
||||
switch (type) {
|
||||
case ChatType.Say:
|
||||
case ChatType.Think:
|
||||
case ChatType.Whisper:
|
||||
return true;
|
||||
case ChatType.Party:
|
||||
case ChatType.PartyThink:
|
||||
return isInParty(game);
|
||||
case ChatType.Supporter:
|
||||
return supporter > 0;
|
||||
case ChatType.Supporter1:
|
||||
return supporter >= 1;
|
||||
case ChatType.Supporter2:
|
||||
return supporter >= 2;
|
||||
case ChatType.Supporter3:
|
||||
return supporter >= 3;
|
||||
case ChatType.Dismiss:
|
||||
return false;
|
||||
default:
|
||||
return invalidEnumReturn(type, false);
|
||||
}
|
||||
}
|
||||
|
||||
function getChatTypes(game: PonyTownGame) {
|
||||
const chatTypes = [ChatType.Say];
|
||||
const supporter = game.model.supporter;
|
||||
|
||||
if (isInParty(game)) {
|
||||
chatTypes.push(ChatType.Party);
|
||||
}
|
||||
|
||||
if (supporter) {
|
||||
chatTypes.push(ChatType.Supporter);
|
||||
}
|
||||
|
||||
return chatTypes;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.chat-log(#chatLog)
|
||||
.d-flex.chat-log-buttons
|
||||
.flex-grow-1.d-flex.flex-column-reverse
|
||||
button.game-button.mb-2((click)="scrollToEnd()" title="Scroll to end")
|
||||
fa-icon([icon]="toBottomIcon" [fixedWidth]="true")
|
||||
|
||||
.chat-log-content(#content)
|
||||
fa-icon.resize-icon([icon]="resizeIcon")
|
||||
.chat-log-tabs
|
||||
a.link-plain.chat-log-tab(#localTab tabindex (click)="switchTab('local')")
|
||||
| Local
|
||||
a.link-plain.chat-log-tab(#partyTab tabindex (click)="switchTab('party')")
|
||||
| Party
|
||||
a.link-plain.chat-log-tab(#whisperTab tabindex (click)="switchTab('whisper')")
|
||||
| Whisper
|
||||
.chat-log-scroll-outer()
|
||||
.chat-log-scroll-inner(#scroll)
|
||||
.chat-log-scroll-inner-inner(#lines)
|
||||
.chat-log-resize-top((agDrag)="drag($event, true, false)")
|
||||
.chat-log-resize-right((agDrag)="drag($event, false, true)")
|
||||
.chat-log-resize-top-right((agDrag)="drag($event, true, true)")
|
||||
|
||||
button.game-button.chat-log-toggle(#toggleButton (click)="toggle()" title="Toggle chatlog")
|
||||
svg.fa-icon(aria-hidden="true" class="svg-inline--fa fa-comments fa-w-18 fa-fw" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512")
|
||||
path.main-bubble(fill="currentColor" d="M416 192c0-88.4-93.1-160-208-160S0 103.6 0 192c0 34.3 14.1 65.9 38 92-13.4 30.2-35.5 54.2-35.8 54.5-2.2 2.3-2.8 5.7-1.5 8.7S4.8 352 8 352c36.6 0 66.9-12.3 88.7-25 32.2 15.7 70.3 25 111.3 25 114.9 0 208-71.6 208-160z")
|
||||
path(fill="currentColor" d="M538 412c23.9-26 38-57.7 38-92 0-66.9-53.5-124.2-129.3-148.1.9 6.6 1.3 13.3 1.3 20.1 0 105.9-107.7 192-240 192-10.8 0-21.3-.8-31.7-1.9C207.8 439.6 281.8 480 368 480c41 0 79.1-9.2 111.3-25 21.8 12.7 52.1 25 88.7 25 3.2 0 6.1-1.9 7.3-4.8 1.3-2.9.7-6.3-1.5-8.7-.3-.3-22.4-24.2-35.8-54.5z")
|
||||
.whispers-count(#count)
|
||||
@@ -0,0 +1,159 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
$chat-log-bg: rgba(0, 0, 0, 0.2);
|
||||
$chat-log-bg-faded: rgba(0, 0, 0, 0.1);
|
||||
$chat-log-bg-hover: rgba(0, 0, 0, 0.5);
|
||||
$chat-log-tabs-height: 30px;
|
||||
$chat-log-buttons-width: 40px;
|
||||
|
||||
$resizer-size: 12px;
|
||||
$resizer-offset: $resizer-size / 2;
|
||||
|
||||
.chat-log {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 220px);
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.chat-log-buttons {
|
||||
flex-direction: column-reverse;
|
||||
width: $chat-log-buttons-width;
|
||||
padding: 5px 3px;
|
||||
padding-bottom: 30px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chat-log-toggle {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.has-unread .main-bubble {
|
||||
color: $chat-whisper;
|
||||
}
|
||||
|
||||
.whispers-count {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 9px;
|
||||
width: 16px;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.whisper-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 14px;
|
||||
color: $chat-whisper !important;
|
||||
}
|
||||
|
||||
.chat-log-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
background: $chat-log-bg;
|
||||
padding: 5px 0;
|
||||
padding-left: 40px;
|
||||
margin-left: -$chat-log-buttons-width;
|
||||
border-radius: $border-radius-base;
|
||||
word-break: break-word;
|
||||
white-space: pre-line;
|
||||
line-height: 20px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-log-tabs {
|
||||
position: absolute;
|
||||
top: -$chat-log-tabs-height;
|
||||
left: 30px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-log-tab {
|
||||
background: $chat-log-bg-faded;
|
||||
padding: 5px 10px 0;
|
||||
margin-left: 10px;
|
||||
height: $chat-log-tabs-height;
|
||||
border-top-left-radius: $border-radius-base;
|
||||
border-top-right-radius: $border-radius-base;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
background: $chat-log-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-log-scroll-outer {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-log-scroll-inner {
|
||||
width: calc(100% + 50px);
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chat-log-scroll-inner-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 100%;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.chat-log-resize-top {
|
||||
position: absolute;
|
||||
top: -$resizer-offset;
|
||||
right: $resizer-offset;
|
||||
height: $resizer-size;
|
||||
left: 250px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.chat-log-resize-top-right {
|
||||
position: absolute;
|
||||
top: -($resizer-offset + 1);
|
||||
right: -($resizer-offset + 1);
|
||||
width: $resizer-size + 4;
|
||||
height: $resizer-size + 4;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.chat-log-resize-right {
|
||||
position: absolute;
|
||||
top: $resizer-offset;
|
||||
right: -$resizer-offset;
|
||||
width: $resizer-size;
|
||||
bottom: $resizer-offset;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-icon {
|
||||
font-size: 18px;
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: 1px;
|
||||
transform: rotate(45deg);
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import {
|
||||
Component, ViewChild, ElementRef, NgZone, AfterViewInit, OnDestroy, HostListener, Output, EventEmitter, DoCheck
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { clamp, escapeRegExp } from 'lodash';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { MessageType, isPartyMessage, ChatMessage, Pony, FakeEntity, isWhisper, isWhisperTo } from '../../../common/interfaces';
|
||||
import { SettingsService } from '../../services/settingsService';
|
||||
import { AgDragEvent } from '../directives/agDrag';
|
||||
import { element, textNode, removeAllNodes, replaceNodes } from '../../../client/htmlUtils';
|
||||
import { DEFAULT_CHATLOG_OPACITY, PONY_TYPE } from '../../../common/constants';
|
||||
import { faCaretUp, faArrowDown } from '../../../client/icons';
|
||||
import { sampleMessages } from '../../../common/debugData';
|
||||
|
||||
interface IndexEntryUser {
|
||||
id: number;
|
||||
crc: number | undefined;
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
users: IndexEntryUser[];
|
||||
counter: number;
|
||||
}
|
||||
|
||||
interface ChatLogLineDOM {
|
||||
entry: ChatLogMessage;
|
||||
root: HTMLElement;
|
||||
label: HTMLElement;
|
||||
labelText: Text;
|
||||
name: HTMLElement;
|
||||
nameContent: HTMLElement;
|
||||
index: HTMLElement;
|
||||
indexText: Text;
|
||||
prefixText: Text;
|
||||
suffixText: Text;
|
||||
message: HTMLElement;
|
||||
}
|
||||
|
||||
export interface ChatLogMessage {
|
||||
message: string;
|
||||
name?: string;
|
||||
crc?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
label?: string;
|
||||
index: number;
|
||||
classes?: string;
|
||||
entityId?: number;
|
||||
dom?: ChatLogLineDOM;
|
||||
}
|
||||
|
||||
type Tab = 'local' | 'party' | 'whisper';
|
||||
type ClickHandler = (entry: ChatLogMessage) => void;
|
||||
|
||||
const GENERAL_CHAT_LIMIT = 100;
|
||||
const PARTY_CHAT_LIMIT = 100;
|
||||
const WHISPER_CHAT_LIMIT = 100;
|
||||
const FORGET_INDEX_AFTER = 1000;
|
||||
const SCROLL_END_THRESHOLD = 60;
|
||||
|
||||
const LABELS: (string | undefined)[] = [];
|
||||
LABELS[MessageType.System] = 'system';
|
||||
LABELS[MessageType.Admin] = 'admin';
|
||||
LABELS[MessageType.Mod] = 'mod';
|
||||
LABELS[MessageType.Party] = 'party';
|
||||
LABELS[MessageType.PartyThinking] = 'party';
|
||||
LABELS[MessageType.PartyAnnouncement] = 'party';
|
||||
|
||||
const PREFIXES: (string | undefined)[] = [];
|
||||
PREFIXES[MessageType.WhisperTo] = 'To ';
|
||||
PREFIXES[MessageType.WhisperToAnnouncement] = 'To ';
|
||||
|
||||
const SUFFIXES: (string | undefined)[] = [];
|
||||
SUFFIXES[MessageType.Thinking] = 'thinks';
|
||||
SUFFIXES[MessageType.PartyThinking] = 'thinks';
|
||||
SUFFIXES[MessageType.Whisper] = 'whispers';
|
||||
SUFFIXES[MessageType.WhisperAnnouncement] = 'whispers';
|
||||
|
||||
const CLASSES: (string | undefined)[] = [];
|
||||
CLASSES[MessageType.System] = 'chat-line-system';
|
||||
CLASSES[MessageType.Admin] = 'chat-line-admin';
|
||||
CLASSES[MessageType.Mod] = 'chat-line-mod';
|
||||
CLASSES[MessageType.Party] = 'chat-line-party';
|
||||
CLASSES[MessageType.Thinking] = 'chat-line-thinking';
|
||||
CLASSES[MessageType.PartyThinking] = 'chat-line-party-thinking';
|
||||
CLASSES[MessageType.Announcement] = 'chat-line-announcement';
|
||||
CLASSES[MessageType.PartyAnnouncement] = 'chat-line-party-announcement';
|
||||
CLASSES[MessageType.Supporter1] = 'chat-line-supporter-1';
|
||||
CLASSES[MessageType.Supporter2] = 'chat-line-supporter-2';
|
||||
CLASSES[MessageType.Supporter3] = 'chat-line-supporter-3';
|
||||
CLASSES[MessageType.Whisper] = 'chat-line-whisper';
|
||||
CLASSES[MessageType.WhisperTo] = 'chat-line-whisper';
|
||||
CLASSES[MessageType.WhisperAnnouncement] = 'chat-line-whisper-announcement';
|
||||
CLASSES[MessageType.WhisperToAnnouncement] = 'chat-line-whisper-announcement';
|
||||
|
||||
export function createChatLogLineDOM(clickLabel: ClickHandler, clickName: ClickHandler): ChatLogLineDOM {
|
||||
const line: ChatLogLineDOM = {} as any;
|
||||
|
||||
line.root = element('div', 'chat-line', [
|
||||
element('span', 'chat-line-lead'),
|
||||
line.label = element(
|
||||
'span', 'chat-line-label mr-1', [line.labelText = textNode('')], undefined, { click: () => clickLabel(line.entry) }),
|
||||
line.prefixText = textNode(''),
|
||||
line.name = element('span', 'chat-line-name', [
|
||||
textNode('['),
|
||||
line.nameContent = element(
|
||||
'span', 'chat-line-name-content', [textNode('')], undefined, { click: () => clickName(line.entry) }),
|
||||
line.index = element('span', 'chat-line-name-index', [line.indexText = textNode('')], { title: 'duplicate name' }),
|
||||
textNode(']'),
|
||||
]),
|
||||
line.suffixText = textNode(''),
|
||||
line.message = element('span', 'chat-line-message', [textNode('')]),
|
||||
]);
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export function updateChatLogLine(line: ChatLogLineDOM, entry: ChatLogMessage) {
|
||||
const { classes, label, message, prefix, suffix } = entry;
|
||||
const hasSpace = message.indexOf(' ') !== -1;
|
||||
|
||||
line.entry = entry;
|
||||
line.root.className = `chat-line ${hasSpace ? '' : 'chat-line-break '}${classes}`.trim();
|
||||
line.label.style.display = label ? 'inline' : 'none';
|
||||
line.labelText.nodeValue = label ? `[${label}]` : '';
|
||||
|
||||
updateChatLogName(line, entry);
|
||||
|
||||
line.prefixText.nodeValue = prefix || '';
|
||||
line.suffixText.nodeValue = suffix ? ` ${suffix}: ` : ': ';
|
||||
replaceNodes(line.message, message);
|
||||
}
|
||||
|
||||
function updateChatLogName(line: ChatLogLineDOM, { name, index }: ChatLogMessage) {
|
||||
if (name) {
|
||||
line.name.style.display = 'inline';
|
||||
replaceNodes(line.nameContent, name);
|
||||
line.index.style.display = (index > 0) ? 'inline' : 'none';
|
||||
line.indexText.nodeValue = (index > 0) ? ` #${index + 1}` : '';
|
||||
} else {
|
||||
line.name.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function isMatch(e: ChatLogMessage, id: number, name: string, crc: number | undefined) {
|
||||
return e.name === name && (e.entityId === id || (e.crc === crc && crc !== undefined));
|
||||
}
|
||||
|
||||
function addOrUpdatePony(pony: Pony, list: ChatLogMessage[]) {
|
||||
for (const e of list) {
|
||||
if (isMatch(e, pony.id, pony.name!, pony.crc)) {
|
||||
e.entityId = pony.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateEntityId(list: ChatLogMessage[], oldId: number, newId: number) {
|
||||
for (const e of list) {
|
||||
if (e.entityId === oldId) {
|
||||
e.entityId = newId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findUserIndex(users: IndexEntryUser[], id: number, crc: number | undefined) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
const user = users[i];
|
||||
|
||||
if (user.id === id || (crc !== undefined && user.crc === crc)) {
|
||||
user.id = id;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'chat-log',
|
||||
templateUrl: 'chat-log.pug',
|
||||
styleUrls: ['chat-log.scss'],
|
||||
})
|
||||
export class ChatLog implements AfterViewInit, OnDestroy, DoCheck {
|
||||
readonly toBottomIcon = faArrowDown;
|
||||
readonly resizeIcon = faCaretUp;
|
||||
@ViewChild('chatLog', { static: true }) chatLog!: ElementRef;
|
||||
@ViewChild('scroll', { static: true }) scroll!: ElementRef;
|
||||
@ViewChild('lines', { static: true }) lines!: ElementRef;
|
||||
@ViewChild('localTab', { static: true }) localTab!: ElementRef;
|
||||
@ViewChild('partyTab', { static: true }) partyTab!: ElementRef;
|
||||
@ViewChild('whisperTab', { static: true }) whisperTab!: ElementRef;
|
||||
@ViewChild('toggleButton', { static: true }) toggleButton!: ElementRef;
|
||||
@ViewChild('count', { static: true }) countElement!: ElementRef;
|
||||
@ViewChild('content', { static: true }) contentElement!: ElementRef;
|
||||
@Output() toggleType = new EventEmitter<string>();
|
||||
@Output() nameClick = new EventEmitter<ChatLogMessage>();
|
||||
innerWidth = 0;
|
||||
// TODO: move to game ?
|
||||
local: ChatLogMessage[] = [];
|
||||
party: ChatLogMessage[] = [];
|
||||
whisper: ChatLogMessage[] = [];
|
||||
unread = 0;
|
||||
private subscriptions: Subscription[] = [];
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private shouldScrollToEnd = false;
|
||||
private scrolledToEnd = true;
|
||||
private scrollingToEnd = false;
|
||||
private scrollToEndAtFrame = false;
|
||||
private indexes = new Map<string, IndexEntry>();
|
||||
private messageCounter = 0;
|
||||
private lastOpacity = 0;
|
||||
constructor(
|
||||
private game: PonyTownGame,
|
||||
private settingsService: SettingsService,
|
||||
private element: ElementRef,
|
||||
private zone: NgZone,
|
||||
) {
|
||||
// TODO: just put reference to chatlog on game ???
|
||||
this.subscriptions.push(
|
||||
game.onFrame.subscribe(() => {
|
||||
if (this.scrollToEndAtFrame) {
|
||||
this.scrollToEndAtFrame = false;
|
||||
this.scrollHandler();
|
||||
}
|
||||
}),
|
||||
game.onMessage.subscribe(message => {
|
||||
this.addMessage(message);
|
||||
}),
|
||||
// TODO: move to game ?
|
||||
game.onPonyAddOrUpdate.subscribe(pony => {
|
||||
addOrUpdatePony(pony, this.local);
|
||||
addOrUpdatePony(pony, this.party);
|
||||
addOrUpdatePony(pony, this.whisper);
|
||||
}),
|
||||
game.onJoined.subscribe(() => {
|
||||
if (!DEVELOPMENT) {
|
||||
// TODO: move to game ?
|
||||
this.local = [];
|
||||
this.party = [];
|
||||
this.whisper = [];
|
||||
this.messageCounter = 0;
|
||||
this.indexes = new Map();
|
||||
this.clearList();
|
||||
}
|
||||
}),
|
||||
game.onEntityIdUpdate.subscribe(update => {
|
||||
updateEntityId(this.local, update.old, update.new);
|
||||
updateEntityId(this.party, update.old, update.new);
|
||||
updateEntityId(this.whisper, update.old, update.new);
|
||||
}),
|
||||
);
|
||||
}
|
||||
get linesElement() {
|
||||
return this.lines.nativeElement as HTMLElement;
|
||||
}
|
||||
private updateOpen() {
|
||||
this.updateChatlog();
|
||||
|
||||
if (this.open) {
|
||||
this.setUnread(0);
|
||||
this.regenerateList();
|
||||
this.scrollToEnd();
|
||||
} else {
|
||||
this.clearList();
|
||||
}
|
||||
|
||||
this.updateInnerWidth();
|
||||
}
|
||||
private updateChatlog() {
|
||||
const element = this.chatLog.nativeElement as HTMLElement;
|
||||
element.style.display = this.open ? 'flex' : 'none';
|
||||
|
||||
if (this.open) {
|
||||
element.style.width = `${this.width}px`;
|
||||
element.style.height = `${this.height}px`;
|
||||
}
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
this.game.findEntityFromChatLog = this.findEntityFromMessages;
|
||||
this.game.findEntityFromChatLogByName = this.findEntityFromMessagesByName;
|
||||
|
||||
this.updateTabs();
|
||||
this.updateOpen();
|
||||
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const scroll = this.scroll.nativeElement as HTMLElement;
|
||||
|
||||
scroll.addEventListener('scroll', () => {
|
||||
if (this.scrollingToEnd) {
|
||||
this.scrolledToEnd = true;
|
||||
this.scrollingToEnd = false;
|
||||
} else {
|
||||
const clientHeight = scroll.getBoundingClientRect().height;
|
||||
this.scrolledToEnd = scroll.scrollTop >= (scroll.scrollHeight - clientHeight - SCROLL_END_THRESHOLD);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.scrollToEnd();
|
||||
this.updateInnerWidth();
|
||||
});
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
sampleMessages.forEach(({ name, id, message, type }) =>
|
||||
this.addMessage({ id: id || 999999, crc: undefined, name, message, type: type || MessageType.Chat }));
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
if (this.game.findEntityFromChatLog === this.findEntityFromMessages) {
|
||||
this.game.findEntityFromChatLog = () => undefined;
|
||||
}
|
||||
|
||||
if (this.game.findEntityFromChatLogByName === this.findEntityFromMessagesByName) {
|
||||
this.game.findEntityFromChatLogByName = () => undefined;
|
||||
}
|
||||
|
||||
this.subscriptions.forEach(s => s.unsubscribe());
|
||||
this.subscriptions = [];
|
||||
}
|
||||
ngDoCheck() {
|
||||
if (this.lastOpacity !== this.opacity) {
|
||||
this.lastOpacity = this.opacity;
|
||||
this.contentElement.nativeElement.style.backgroundColor = this.bg;
|
||||
this.updateTabs();
|
||||
}
|
||||
}
|
||||
@HostListener('window:resize')
|
||||
updateInnerWidth() {
|
||||
const maxWidth = (this.element.nativeElement as HTMLElement).getBoundingClientRect().width;
|
||||
const innerWidth = Math.min(maxWidth || this.width, this.width) - 40;
|
||||
|
||||
if (this.innerWidth !== innerWidth) {
|
||||
this.innerWidth = innerWidth;
|
||||
this.linesElement.style.width = `${innerWidth}px`;
|
||||
}
|
||||
|
||||
if (!maxWidth) {
|
||||
setTimeout(() => this.updateInnerWidth(), 10);
|
||||
}
|
||||
}
|
||||
get messages() {
|
||||
return this[this.activeTab];
|
||||
}
|
||||
get settings() {
|
||||
return this.settingsService.browser;
|
||||
}
|
||||
get settings2() {
|
||||
return this.settingsService.account;
|
||||
}
|
||||
get activeTab(): Tab {
|
||||
const tab = this.settings.chatlogTab;
|
||||
return (tab === 'local' || tab === 'party' || tab === 'whisper') ? tab : 'local';
|
||||
}
|
||||
get open() {
|
||||
return !this.settings.chatlogClosed;
|
||||
}
|
||||
get width() {
|
||||
return this.settings.chatlogWidth || 500;
|
||||
}
|
||||
get height() {
|
||||
return this.settings.chatlogHeight || 310;
|
||||
}
|
||||
get opacity() {
|
||||
return this.settings2.chatlogOpacity === undefined ? DEFAULT_CHATLOG_OPACITY : this.settings2.chatlogOpacity;
|
||||
}
|
||||
get bg() {
|
||||
return `rgba(0, 0, 0, ${this.opacity / 100})`;
|
||||
}
|
||||
get inactiveBg() {
|
||||
return `rgba(0, 0, 0, ${(this.opacity / 200) * 0.5})`;
|
||||
}
|
||||
private createEntry({ id, crc, name, message, type }: ChatMessage): ChatLogMessage {
|
||||
const system = type === MessageType.System;
|
||||
const entry: ChatLogMessage = {
|
||||
entityId: system ? 0 : id,
|
||||
name: system ? '' : name,
|
||||
index: 0,
|
||||
crc,
|
||||
message,
|
||||
label: LABELS[type] || '',
|
||||
prefix: PREFIXES[type] || '',
|
||||
suffix: SUFFIXES[type] || '',
|
||||
classes: CLASSES[type] || '',
|
||||
};
|
||||
|
||||
if (!system) {
|
||||
entry.index = this.findOrCreateIndex(name, id, crc);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
private findOrCreateIndex(name: string, id: number, crc: number | undefined) {
|
||||
let found = this.indexes.get(name);
|
||||
|
||||
if (!found || (this.messageCounter - found.counter) > FORGET_INDEX_AFTER) {
|
||||
found = {
|
||||
users: [{ id, crc }],
|
||||
counter: 0,
|
||||
};
|
||||
|
||||
this.indexes.set(name, found);
|
||||
}
|
||||
|
||||
found.counter = this.messageCounter;
|
||||
|
||||
let index = findUserIndex(found.users, id, crc);
|
||||
|
||||
if (index === -1) {
|
||||
index = found.users.length;
|
||||
found.users.push({ id, crc });
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
addMessage(message: ChatMessage) {
|
||||
if (message.name && message.message) {
|
||||
const entry = this.createEntry(message);
|
||||
const party = isPartyMessage(message.type);
|
||||
const whisper = isWhisper(message.type) || isWhisperTo(message.type);
|
||||
const open = this.open;
|
||||
const scrolledToEnd = open ? this.scrolledToEnd : false;
|
||||
const tab = this.activeTab;
|
||||
|
||||
this.addEntryToList(this.local, GENERAL_CHAT_LIMIT, open && tab === 'local', entry);
|
||||
|
||||
if (party || whisper) {
|
||||
const partyEntry = { ...entry };
|
||||
partyEntry.dom = undefined;
|
||||
partyEntry.label = whisper ? partyEntry.label : undefined;
|
||||
this.addEntryToList(this.party, PARTY_CHAT_LIMIT, open && tab === 'party', partyEntry);
|
||||
}
|
||||
|
||||
if (whisper) {
|
||||
const whisperEntry = { ...entry };
|
||||
whisperEntry.dom = undefined;
|
||||
whisperEntry.label = undefined;
|
||||
this.addEntryToList(this.whisper, WHISPER_CHAT_LIMIT, open && tab === 'whisper', whisperEntry);
|
||||
}
|
||||
|
||||
if (message.type === MessageType.Whisper && !this.open) {
|
||||
this.setUnread(this.unread + 1);
|
||||
}
|
||||
|
||||
if (scrolledToEnd) {
|
||||
this.scrollToEnd();
|
||||
}
|
||||
|
||||
this.messageCounter++;
|
||||
}
|
||||
}
|
||||
private addEntryToList(list: ChatLogMessage[], limit: number, isOpen: boolean, entry: ChatLogMessage) {
|
||||
let removedDom: ChatLogLineDOM | undefined;
|
||||
|
||||
while (list.length >= limit) {
|
||||
const removed = list.shift();
|
||||
|
||||
if (isOpen && removed && removed.dom) {
|
||||
if (removed.dom.root.parentElement) {
|
||||
removed.dom.root.parentElement.removeChild(removed.dom.root);
|
||||
}
|
||||
|
||||
removedDom = removed.dom;
|
||||
removed.dom = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
list.push(entry);
|
||||
|
||||
if (isOpen) {
|
||||
entry.dom = removedDom || createChatLogLineDOM(this.clickLabel, this.clickNameHandler);
|
||||
updateChatLogLine(entry.dom, entry);
|
||||
this.linesElement.appendChild(entry.dom.root);
|
||||
}
|
||||
}
|
||||
toggle() {
|
||||
this.settings.chatlogClosed = !this.settings.chatlogClosed;
|
||||
this.settingsService.saveBrowserSettings();
|
||||
this.updateOpen();
|
||||
}
|
||||
switchTab(tab: Tab) {
|
||||
if (this.activeTab !== tab) {
|
||||
this.settings.chatlogTab = tab;
|
||||
this.settingsService.saveBrowserSettings();
|
||||
this.regenerateList();
|
||||
this.scrollToEnd();
|
||||
this.updateTabs();
|
||||
}
|
||||
}
|
||||
private updateTabs() {
|
||||
this.setActiveTab(this.localTab.nativeElement, this.activeTab === 'local');
|
||||
this.setActiveTab(this.partyTab.nativeElement, this.activeTab === 'party');
|
||||
this.setActiveTab(this.whisperTab.nativeElement, this.activeTab === 'whisper');
|
||||
}
|
||||
private setActiveTab(tab: HTMLElement, active: boolean) {
|
||||
if (active) {
|
||||
tab.classList.add('active');
|
||||
tab.style.backgroundColor = this.bg;
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
tab.style.backgroundColor = this.inactiveBg;
|
||||
}
|
||||
}
|
||||
scrollToEnd() {
|
||||
// requestAnimationFrame(this.scrollHandler);
|
||||
this.scrollToEndAtFrame = true;
|
||||
}
|
||||
scrollHandler = () => {
|
||||
this.scrollingToEnd = true;
|
||||
this.scroll.nativeElement.scrollTop = 99999;
|
||||
}
|
||||
clickNameHandler = (message: ChatLogMessage) => {
|
||||
this.zone.run(() => this.nameClick.emit(message));
|
||||
}
|
||||
clickLabel = (message: ChatLogMessage) => {
|
||||
this.zone.run(() => {
|
||||
if (message.label) {
|
||||
this.toggleType.emit(message.label);
|
||||
}
|
||||
});
|
||||
}
|
||||
private clearList() {
|
||||
removeAllNodes(this.linesElement);
|
||||
}
|
||||
private regenerateList() {
|
||||
this.clearList();
|
||||
|
||||
const lines = this.linesElement;
|
||||
|
||||
this.messages.forEach(entry => {
|
||||
if (!entry.dom) {
|
||||
entry.dom = createChatLogLineDOM(this.clickLabel, this.clickNameHandler);
|
||||
updateChatLogLine(entry.dom, entry);
|
||||
}
|
||||
|
||||
lines.appendChild(entry.dom.root);
|
||||
});
|
||||
}
|
||||
drag({ x, y, type, event }: AgDragEvent, resizeY: boolean, resizeX: boolean) {
|
||||
event.preventDefault();
|
||||
|
||||
if (type === 'start') {
|
||||
const { left, top } = (this.element.nativeElement as HTMLElement).getBoundingClientRect();
|
||||
this.startX = left;
|
||||
this.startY = top;
|
||||
this.shouldScrollToEnd = this.scrolledToEnd;
|
||||
}
|
||||
|
||||
if (resizeX) {
|
||||
this.settings.chatlogWidth = clamp(x - this.startX, 200, 2000);
|
||||
}
|
||||
|
||||
if (resizeY) {
|
||||
this.settings.chatlogHeight = clamp(this.startY - y, 120, 2000);
|
||||
}
|
||||
|
||||
this.updateChatlog();
|
||||
this.updateInnerWidth();
|
||||
|
||||
if (type === 'end') {
|
||||
this.settingsService.saveBrowserSettings();
|
||||
}
|
||||
|
||||
if (this.shouldScrollToEnd) {
|
||||
this.scrollToEnd();
|
||||
}
|
||||
}
|
||||
private setUnread(value: number) {
|
||||
if (this.unread !== value) {
|
||||
this.unread = value;
|
||||
const count = this.countElement.nativeElement as HTMLElement;
|
||||
const toggle = this.toggleButton.nativeElement as HTMLElement;
|
||||
|
||||
if (value) {
|
||||
count.textContent = value > 99 ? '99+' : `${value}`;
|
||||
toggle.classList.add('has-unread');
|
||||
} else {
|
||||
count.textContent = '';
|
||||
toggle.classList.remove('has-unread');
|
||||
}
|
||||
}
|
||||
}
|
||||
private findEntityFromMessages = (id: number): FakeEntity | undefined => {
|
||||
return findEntityFromMessages(id, this.whisper) ||
|
||||
findEntityFromMessages(id, this.party) ||
|
||||
findEntityFromMessages(id, this.local);
|
||||
}
|
||||
private findEntityFromMessagesByName = (name: string): FakeEntity | undefined => {
|
||||
return findEntityFromMessagesByName(name, this.game.playerId, this.whisper) ||
|
||||
findEntityFromMessagesByName(name, this.game.playerId, this.party) ||
|
||||
findEntityFromMessagesByName(name, this.game.playerId, this.local);
|
||||
}
|
||||
}
|
||||
|
||||
function findEntityFromMessages(id: number, messages: ChatLogMessage[]): FakeEntity | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].entityId === id) {
|
||||
return { fake: true, id, type: PONY_TYPE, name: messages[i].name, crc: messages[i].crc };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findEntityFromMessagesByName(
|
||||
name: string, playerId: number | undefined, messages: ChatLogMessage[]
|
||||
): FakeEntity | undefined {
|
||||
const regex = new RegExp(`^${escapeRegExp(name)}$`, 'i');
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
|
||||
if (message.name && message.entityId && message.entityId !== playerId && regex.test(message.name)) {
|
||||
return { fake: true, id: message.entityId, type: PONY_TYPE, name: message.name, crc: message.crc };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
button.check-box(
|
||||
role="checkbox"
|
||||
(click)="toggle()"
|
||||
[class.disabled]="disabled"
|
||||
[attr.aria-disabled]="disabled"
|
||||
[attr.aria-label]="label || ''"
|
||||
[attr.aria-checked]="checked")
|
||||
fa-icon(*ngIf="checked" [icon]="icon" [fixedWidth]="true")
|
||||
@@ -0,0 +1,30 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.check-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
height: $input-height;
|
||||
width: $input-height;
|
||||
margin: 0;
|
||||
color: $input-color;
|
||||
background-color: $input-bg;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $input-border-radius;
|
||||
appearance: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 $btn-focus-width rgba($focus-color, .5);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: $input-disabled-bg;
|
||||
color: $input-disabled-color;
|
||||
}
|
||||
|
||||
> fa-icon {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { faCheck } from '../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'check-box',
|
||||
templateUrl: 'check-box.pug',
|
||||
styleUrls: ['check-box.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CheckBox {
|
||||
@Input() icon = faCheck;
|
||||
@Input() label?: string;
|
||||
@Input() disabled = false;
|
||||
@Input() checked = false;
|
||||
@Output() checkedChange = new EventEmitter<boolean>();
|
||||
toggle() {
|
||||
if (!this.disabled) {
|
||||
this.checked = !this.checked;
|
||||
this.checkedChange.emit(this.checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.color-picker.dropdown(
|
||||
[class.disabled]="isDisabled" [class.open]="isOpen" [class.show]="isOpen")
|
||||
//-[class.indicator]="indicatorColor"
|
||||
.color-picker-box([style.background-color]="bg")
|
||||
input.form-control.color-picker-input(
|
||||
#input
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
(focus)="focus($event)"
|
||||
(blur)="close()"
|
||||
(input)="inputChanged(input.value)"
|
||||
[value]="inputColor"
|
||||
[disabled]="isDisabled"
|
||||
[attr.aria-labelledby]="labelledBy"
|
||||
[attr.aria-label]="label")
|
||||
//-[style.border-left-color]="indicatorColor"
|
||||
a.color-picker-chevron((mousedown)="toggleOpen()" [hidden]="isDisabled" aria-label="Pick color")
|
||||
fa-icon([icon]="chevronIcon" [fixedWidth]="true")
|
||||
.dropdown-menu.dropdown-menu-right.color-picker-menu.show(
|
||||
(mousedown)="stopEvent($event)" *ngIf="isOpen" aria-hidden="true")
|
||||
.color-picker-sv(
|
||||
[style.background-color]="hue" (agDrag)="dragSV($event)" agDragRelative="self" [agDragPrevent]="true")
|
||||
.color-picker-sv-overlay
|
||||
.color-wheel-circle-sv([style.left.%]="svLeft" [style.top.%]="svTop")
|
||||
.color-picker-hue((agDrag)="dragHue($event)" agDragRelative="self" [agDragPrevent]="true")
|
||||
.color-wheel-circle-hue([style.top.%]="hueTop")
|
||||
@@ -0,0 +1,123 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
@import '../../../../styles/partials/mixins';
|
||||
|
||||
$color-picker-height: 175px;
|
||||
$color-picker-hue-width: 28px;
|
||||
$color-picker-sv-width: $color-picker-height;
|
||||
$color-picker-indicator-size: 5px;
|
||||
$color-picker-box-offset: 5px;
|
||||
|
||||
.color-picker {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
padding-left: 35px;
|
||||
padding-right: 40px;
|
||||
width: 100% !important;
|
||||
font-family: $font-family-monospace;
|
||||
|
||||
.indicator > & {
|
||||
border-left-width: $color-picker-indicator-size;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-box {
|
||||
background: black;
|
||||
position: absolute;
|
||||
left: $color-picker-box-offset;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
width: 25px;
|
||||
border: solid 1px $border-color;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
|
||||
.disabled > & {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.indicator > & {
|
||||
left: $color-picker-box-offset + $color-picker-indicator-size;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-chevron {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: $text-muted !important;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
padding: 7px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.color-picker-menu {
|
||||
@include nipple(10px);
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.color-picker-sv {
|
||||
position: relative;
|
||||
width: $color-picker-sv-width;
|
||||
height: $color-picker-height;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.color-picker-hue {
|
||||
background: linear-gradient(to bottom, red, yellow, lime, cyan, blue, magenta, red);
|
||||
position: relative;
|
||||
width: $color-picker-hue-width;
|
||||
height: $color-picker-height;
|
||||
}
|
||||
|
||||
.color-picker-sv-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: $color-picker-sv-width;
|
||||
height: $color-picker-height;
|
||||
background:
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0), black),
|
||||
linear-gradient(to right, white, rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
.color-wheel-circle-sv, .color-wheel-circle-hue {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border: solid 1px black;
|
||||
box-shadow: 0 0 0 2px white;
|
||||
}
|
||||
}
|
||||
|
||||
.color-wheel-circle-sv{
|
||||
&::after {
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
border-radius: 4px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-wheel-circle-hue {
|
||||
width: 100%;
|
||||
|
||||
&::after {
|
||||
left: 0;
|
||||
top: -2px;
|
||||
right: 0;
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { clamp } from '../../../common/utils';
|
||||
import { parseColorFast, colorToCSS, colorFromHSVA, colorToHSVA, colorToHexRGB } from '../../../common/color';
|
||||
import { AgDragEvent } from '../directives/agDrag';
|
||||
import { faChevronDown } from '../../../client/icons';
|
||||
|
||||
const SIZE = 175;
|
||||
|
||||
@Component({
|
||||
selector: 'color-picker',
|
||||
templateUrl: 'color-picker.pug',
|
||||
styleUrls: ['color-picker.scss'],
|
||||
})
|
||||
export class ColorPicker {
|
||||
readonly chevronIcon = faChevronDown;
|
||||
@Input() isOpen = false;
|
||||
@Input() isDisabled = false;
|
||||
@Input() disabledColor = '';
|
||||
@Input() color = '';
|
||||
@Input() indicatorColor = '';
|
||||
@Input() label?: string = undefined;
|
||||
@Input() labelledBy?: string = undefined;
|
||||
@Output() colorChange = new EventEmitter<string>();
|
||||
s = 0;
|
||||
v = 0;
|
||||
h = 0;
|
||||
private lastColor = '';
|
||||
private closeHandler = () => this.close();
|
||||
get inputColor() {
|
||||
return this.isDisabled && this.disabledColor ? this.disabledColor : this.color;
|
||||
}
|
||||
set inputColor(value) {
|
||||
if (!this.isDisabled) {
|
||||
this.color = value;
|
||||
}
|
||||
}
|
||||
get bg() {
|
||||
return colorToCSS(parseColorFast(this.inputColor));
|
||||
}
|
||||
get svLeft() {
|
||||
this.updateHsv();
|
||||
return this.s * 100;
|
||||
}
|
||||
get svTop() {
|
||||
this.updateHsv();
|
||||
return (1 - this.v) * 100;
|
||||
}
|
||||
get hueTop() {
|
||||
this.updateHsv();
|
||||
return this.h * 100 / 360;
|
||||
}
|
||||
get hue() {
|
||||
this.updateHsv();
|
||||
return colorToCSS(colorFromHSVA(this.h, 1, 1, 1));
|
||||
}
|
||||
focus(e: Event) {
|
||||
this.isOpen = true;
|
||||
(e.target as HTMLInputElement).select();
|
||||
}
|
||||
dragSV({ event, x, y }: AgDragEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
this.updateHsv();
|
||||
this.s = clamp(x / SIZE, 0, 1);
|
||||
this.v = 1 - clamp(y / SIZE, 0, 1);
|
||||
this.updateColor();
|
||||
}
|
||||
dragHue({ event, y }: AgDragEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
this.updateHsv();
|
||||
this.h = clamp(360 * y / SIZE, 0, 360);
|
||||
this.updateColor();
|
||||
}
|
||||
updateHsv() {
|
||||
if (this.lastColor !== this.color) {
|
||||
const { h, s, v } = colorToHSVA(parseColorFast(this.color), this.h);
|
||||
this.h = h;
|
||||
this.s = s;
|
||||
this.v = v;
|
||||
this.lastColor = this.color;
|
||||
}
|
||||
}
|
||||
updateColor() {
|
||||
const color = colorToHexRGB(colorFromHSVA(this.h, this.s, this.v, 1));
|
||||
const changed = this.color !== color;
|
||||
this.lastColor = this.color = color;
|
||||
|
||||
if (changed) {
|
||||
this.colorChange.emit(color);
|
||||
}
|
||||
}
|
||||
inputChanged(value: string) {
|
||||
this.color = value;
|
||||
this.colorChange.emit(this.color);
|
||||
}
|
||||
stopEvent(e: Event) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
open() {
|
||||
if (!this.isOpen) {
|
||||
this.isOpen = true;
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', this.closeHandler);
|
||||
document.addEventListener('touchstart', this.closeHandler);
|
||||
});
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
document.removeEventListener('mousedown', this.closeHandler);
|
||||
document.removeEventListener('touchstart', this.closeHandler);
|
||||
}
|
||||
toggleOpen() {
|
||||
if (!this.isDisabled) {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.custom-control.custom-checkbox.mb-2([class.disabled]="disabled")
|
||||
label
|
||||
input.custom-control-input(
|
||||
type="checkbox"
|
||||
[disabled]="disabled"
|
||||
[(ngModel)]="checked"
|
||||
(ngModelChange)="checkedChange.emit(checked)"
|
||||
[attr.aria-describedby]="help ? helpId : undefined")
|
||||
.custom-control-label
|
||||
ng-content
|
||||
small.form-text.text-muted.mt-0(*ngIf="help" [id]="helpId") {{help}}
|
||||
@@ -0,0 +1,8 @@
|
||||
.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
label {
|
||||
line-height: 1.4rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component, ChangeDetectionStrategy, Output, Input, EventEmitter } from '@angular/core';
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
@Component({
|
||||
selector: 'custom-checkbox',
|
||||
templateUrl: 'custom-checkbox.pug',
|
||||
styleUrls: ['custom-checkbox.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CustomCheckbox {
|
||||
@Input() disabled = false;
|
||||
@Input() help = '';
|
||||
@Input() checked = false;
|
||||
@Output() checkedChange = new EventEmitter<boolean>();
|
||||
helpId = uniqueId('custom-checkbox-help-');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.d-flex
|
||||
select.form-control.w-50(placeholder="Day" [(ngModel)]="day" (ngModelChange)="change()")
|
||||
option(disabled [ngValue]="0") Day
|
||||
option(*ngFor="let d of days" [ngValue]="d") {{d}}
|
||||
select.form-control.ml-2(placeholder="Month" [(ngModel)]="month" (ngModelChange)="change()")
|
||||
option(disabled [ngValue]="0") Month
|
||||
option(*ngFor="let m of months; let i = index" [ngValue]="i + 1") {{m}}
|
||||
select.form-control.ml-2.w-50(placeholder="Year" [(ngModel)]="year" (ngModelChange)="change()")
|
||||
option(disabled [ngValue]="0") Year
|
||||
option(*ngFor="let y of years" [ngValue]="y") {{y}}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { times, formatISODate, parseISODate, createValidBirthDate } from '../../../common/utils';
|
||||
import { MONTH_NAMES_EN } from '../../../common/constants';
|
||||
import { getLocale } from '../../../client/clientUtils';
|
||||
|
||||
@Component({
|
||||
selector: 'date-picker',
|
||||
templateUrl: 'date-picker.pug',
|
||||
})
|
||||
export class DatePicker {
|
||||
readonly days = times(31, i => i + 1);
|
||||
readonly years: number[] = [];
|
||||
readonly months = getMonthNames();
|
||||
day = 0;
|
||||
month = 0;
|
||||
year = 0;
|
||||
@Output() dateChange = new EventEmitter<string | undefined>();
|
||||
constructor() {
|
||||
const minYear = 1914;
|
||||
const maxYear = (new Date()).getFullYear() - 6;
|
||||
|
||||
for (let year = maxYear; year >= minYear; year--) {
|
||||
this.years.push(year);
|
||||
}
|
||||
}
|
||||
@Input() get date() {
|
||||
const date = createValidBirthDate(this.day, this.month, this.year);
|
||||
return date && formatISODate(date);
|
||||
}
|
||||
set date(value) {
|
||||
if (value) {
|
||||
const { day, month, year } = parseISODate(value);
|
||||
this.day = day;
|
||||
this.month = month;
|
||||
this.year = year;
|
||||
}
|
||||
}
|
||||
change() {
|
||||
this.dateChange.emit(this.date);
|
||||
}
|
||||
}
|
||||
|
||||
function getMonthNames() {
|
||||
try {
|
||||
const format = new Intl.DateTimeFormat(getLocale(), { month: 'long' });
|
||||
|
||||
return times(12, i => {
|
||||
const date = new Date(523456789);
|
||||
date.setMonth(i);
|
||||
return format.format(date);
|
||||
});
|
||||
} catch {
|
||||
return MONTH_NAMES_EN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Directive, AfterViewInit, ElementRef } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[agAutoFocus]'
|
||||
})
|
||||
export class AgAutoFocus implements AfterViewInit {
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.element.nativeElement.focus(), 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Directive, OnInit, Input, Output, EventEmitter, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { noop } from 'lodash';
|
||||
import { getButton, getX, getY, AnyEvent } from '../../../common/utils';
|
||||
|
||||
export interface AgDragEvent {
|
||||
event: AnyEvent;
|
||||
type: 'start' | 'drag' | 'end';
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
export interface AgDragOptions {
|
||||
relative?: 'self' | 'parent';
|
||||
prevent?: boolean;
|
||||
}
|
||||
|
||||
export function handleDrag(element: HTMLElement, emit: (event: AgDragEvent) => void, options: AgDragOptions = {}) {
|
||||
// typeof PointerEvent !== 'undefined'
|
||||
const eventSets = window.navigator.pointerEnabled ? [
|
||||
{ down: 'pointerdown', move: 'pointermove', up: 'pointerup' }, // , up2: 'pointercancel' },
|
||||
] : [
|
||||
{ down: 'mousedown', move: 'mousemove', up: 'mouseup' },
|
||||
{ down: 'touchstart', move: 'touchmove', up: 'touchend', up2: 'touchcancel' },
|
||||
];
|
||||
const emptyRect = { left: 0, top: 0 };
|
||||
let rect = emptyRect;
|
||||
let scrollLeft = 0;
|
||||
let scrollTop = 0;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let button = 0;
|
||||
let dragging = false;
|
||||
let lastEvent: any;
|
||||
|
||||
function setupScrollAndRect() {
|
||||
// TODO: fix issue with scroll
|
||||
switch (options.relative) {
|
||||
case 'self':
|
||||
rect = element.getBoundingClientRect();
|
||||
scrollLeft = -(window.scrollX || window.pageXOffset || 0);
|
||||
scrollTop = -(window.scrollY || window.pageYOffset || 0);
|
||||
break;
|
||||
case 'parent':
|
||||
rect = element.parentElement!.getBoundingClientRect();
|
||||
scrollLeft = element.parentElement!.scrollLeft;
|
||||
scrollTop = element.parentElement!.scrollTop;
|
||||
break;
|
||||
default:
|
||||
rect = emptyRect;
|
||||
scrollLeft = 0;
|
||||
scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function send(event: AnyEvent, type: 'start' | 'drag' | 'end') {
|
||||
const x = getX(event);
|
||||
const y = getY(event);
|
||||
|
||||
emit({
|
||||
event,
|
||||
type,
|
||||
x: x - rect.left + scrollLeft,
|
||||
y: y - rect.top + scrollTop,
|
||||
dx: x - startX,
|
||||
dy: y - startY,
|
||||
});
|
||||
}
|
||||
|
||||
const handlers = eventSets.map(events => {
|
||||
function move(e: any) {
|
||||
lastEvent = e;
|
||||
e.preventDefault();
|
||||
send(e, 'drag');
|
||||
}
|
||||
|
||||
function up(e: any) {
|
||||
if (getButton(e) === button) {
|
||||
// touchend event does not have x, y coordinates, use last touchmove event instead
|
||||
if (e.type !== 'touchend' && e.type !== 'touchcancel') {
|
||||
lastEvent = e;
|
||||
}
|
||||
end();
|
||||
}
|
||||
}
|
||||
|
||||
function end() {
|
||||
send(lastEvent, 'end');
|
||||
window.removeEventListener(events.move, move);
|
||||
window.removeEventListener(events.up, up);
|
||||
events.up2 && window.removeEventListener(events.up2, up);
|
||||
window.removeEventListener('blur', end);
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function handler(e: any) {
|
||||
if (!dragging) {
|
||||
setupScrollAndRect();
|
||||
dragging = true;
|
||||
button = getButton(e);
|
||||
startX = getX(e);
|
||||
startY = getY(e);
|
||||
send(e, 'start');
|
||||
lastEvent = e;
|
||||
|
||||
window.addEventListener(events.move, move);
|
||||
window.addEventListener(events.up, up);
|
||||
events.up2 && window.addEventListener(events.up2, up);
|
||||
window.addEventListener('blur', end);
|
||||
e.stopPropagation();
|
||||
|
||||
if (options.prevent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener(events.down, handler);
|
||||
return () => element.removeEventListener(events.down, handler);
|
||||
});
|
||||
|
||||
return () => handlers.forEach(f => f());
|
||||
}
|
||||
|
||||
@Directive({ selector: '[agDrag]' })
|
||||
export class AgDrag implements OnInit, OnDestroy {
|
||||
@Input('agDragRelative') relative: 'self' | 'parent' | undefined = undefined;
|
||||
@Input('agDragPrevent') prevent = false;
|
||||
@Output('agDrag') drag = new EventEmitter<AgDragEvent>();
|
||||
private unsubscribe = noop;
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.unsubscribe = handleDrag(this.element.nativeElement, e => this.drag.emit(e), this);
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Directive, OnInit, ElementRef } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: 'a[href]'
|
||||
})
|
||||
export class Anchor implements OnInit {
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
ngOnInit() {
|
||||
const a = this.element.nativeElement as HTMLAnchorElement;
|
||||
|
||||
if (/^(https?|mailto):/.test(a.href) && !a.target) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Directive, Input, Optional } from '@angular/core';
|
||||
import { NgModel } from '@angular/forms';
|
||||
|
||||
@Directive({
|
||||
selector: '[btnHighlight]',
|
||||
host: {
|
||||
'[class.btn-default]': '!on',
|
||||
'[class.btn-primary]': 'on',
|
||||
},
|
||||
})
|
||||
export class BtnHighlight {
|
||||
@Input() btnHighlight?: boolean = undefined;
|
||||
constructor(@Optional() private model?: NgModel) {
|
||||
}
|
||||
get on() {
|
||||
const value = this.btnHighlight;
|
||||
return (value === true || value === false || !this.model) ? value : !!this.model.value;
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[btnHighlightDanger]',
|
||||
host: {
|
||||
'[class.btn-default]': '!btnHighlightDanger',
|
||||
'[class.btn-danger]': 'btnHighlightDanger',
|
||||
},
|
||||
})
|
||||
export class BtnHighlightDanger {
|
||||
@Input() btnHighlightDanger = false;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Component, Directive, Input, Output, EventEmitter, ElementRef, OnInit, Injectable, OnDestroy } from '@angular/core';
|
||||
import { noop } from 'lodash';
|
||||
import { AgDragEvent, handleDrag } from './agDrag';
|
||||
import { clamp, setTransform, removeItem, pointInRect } from '../../../common/utils';
|
||||
import { rect } from '../../../common/rect';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DraggableService {
|
||||
root?: ElementRef;
|
||||
draggedItem?: any;
|
||||
activeDropZone?: DraggableDrop<any>;
|
||||
dropZones: DraggableDrop<any>[] = [];
|
||||
get rootElement(): HTMLElement {
|
||||
return this.root ? this.root.nativeElement : document.body;
|
||||
}
|
||||
setActiveDropZone(dropZone: DraggableDrop<any> | undefined) {
|
||||
if (this.activeDropZone !== dropZone) {
|
||||
if (this.activeDropZone) {
|
||||
this.activeDropZone.setActive(false);
|
||||
}
|
||||
|
||||
this.activeDropZone = dropZone;
|
||||
|
||||
if (this.activeDropZone) {
|
||||
this.activeDropZone.setActive(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
startMove(element: HTMLElement, item: any) {
|
||||
this.setActiveDropZone(undefined);
|
||||
this.rootElement.appendChild(element);
|
||||
this.draggedItem = item;
|
||||
this.initRects();
|
||||
}
|
||||
endMove() {
|
||||
if (this.activeDropZone) {
|
||||
this.activeDropZone.drop.emit(this.draggedItem);
|
||||
this.setActiveDropZone(undefined);
|
||||
}
|
||||
|
||||
this.draggedItem = undefined;
|
||||
}
|
||||
addDropZone(dropZone: DraggableDrop<any>) {
|
||||
this.dropZones.push(dropZone);
|
||||
|
||||
if (this.draggedItem) {
|
||||
this.initRects();
|
||||
}
|
||||
}
|
||||
removeDropZone(dropZone: DraggableDrop<any>) {
|
||||
removeItem(this.dropZones, dropZone);
|
||||
|
||||
if (this.draggedItem) {
|
||||
this.initRects();
|
||||
}
|
||||
|
||||
if (this.activeDropZone === dropZone) {
|
||||
this.setActiveDropZone(undefined);
|
||||
}
|
||||
}
|
||||
updateHover(x: number, y: number) {
|
||||
if (this.draggedItem) {
|
||||
for (const zone of this.dropZones) {
|
||||
if (pointInRect(x, y, zone.rect)) {
|
||||
this.setActiveDropZone(zone);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setActiveDropZone(undefined);
|
||||
}
|
||||
}
|
||||
private initRects() {
|
||||
this.dropZones.forEach(i => i.initRect());
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'draggable-outlet',
|
||||
template: `<div></div>`,
|
||||
styles: [`:host { position: fixed; top: 0; left: 0; z-index: 10000; }`],
|
||||
})
|
||||
export class DraggableOutlet {
|
||||
constructor(element: ElementRef, service: DraggableService) {
|
||||
service.root = element;
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({ selector: '[draggableDrop]' })
|
||||
export class DraggableDrop<T> implements OnInit, OnDestroy {
|
||||
@Input('draggablePad') pad = 0;
|
||||
@Output('draggableDrop') drop = new EventEmitter<T>();
|
||||
rect = rect(0, 0, 0, 0);
|
||||
constructor(private element: ElementRef, private service: DraggableService) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.service.addDropZone(this);
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.service.removeDropZone(this);
|
||||
}
|
||||
setActive(active: boolean) {
|
||||
const element = this.element.nativeElement as HTMLElement;
|
||||
|
||||
if (active) {
|
||||
element.classList.add('draggable-hover');
|
||||
} else {
|
||||
element.classList.remove('draggable-hover');
|
||||
}
|
||||
}
|
||||
initRect() {
|
||||
const element = this.element.nativeElement as HTMLElement;
|
||||
const clientBounds = element.getBoundingClientRect();
|
||||
this.rect.x = clientBounds.left - this.pad;
|
||||
this.rect.y = clientBounds.top - this.pad;
|
||||
this.rect.w = clientBounds.width + 2 * this.pad;
|
||||
this.rect.h = clientBounds.height + 2 * this.pad;
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[draggableItem]',
|
||||
host: {
|
||||
'[style.touch-action]': `touchAction`,
|
||||
}
|
||||
})
|
||||
export class DraggableItem<T> implements OnInit, OnDestroy {
|
||||
@Input('draggableItem') item: T | undefined;
|
||||
@Output('draggableDrag') dragStarted = new EventEmitter<void>();
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private draggable?: HTMLElement;
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private unsubscribeDrag = noop;
|
||||
private _disabled = false;
|
||||
constructor(private element: ElementRef, private service: DraggableService) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.setupDragEvents();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.unsubscribeDrag();
|
||||
}
|
||||
get touchAction() {
|
||||
return this.disabled ? 'inherit' : 'none';
|
||||
}
|
||||
@Input('draggableDisabled') get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
set disabled(value) {
|
||||
if (this._disabled !== value) {
|
||||
this._disabled = value;
|
||||
this.setupDragEvents();
|
||||
}
|
||||
}
|
||||
private setupDragEvents() {
|
||||
this.unsubscribeDrag();
|
||||
this.unsubscribeDrag = noop;
|
||||
|
||||
if (!this.disabled) {
|
||||
this.unsubscribeDrag = handleDrag(this.element.nativeElement, e => this.drag(e), { prevent: true });
|
||||
}
|
||||
}
|
||||
drag(e: AgDragEvent) {
|
||||
if (this.item && !this.disabled && !this.draggable && (Math.abs(e.dx) > 5 || Math.abs(e.dy) > 5)) {
|
||||
const element = this.element.nativeElement as HTMLElement;
|
||||
const rect = element.getBoundingClientRect();
|
||||
this.startX = rect.left;
|
||||
this.startY = rect.top;
|
||||
this.draggable = element.cloneNode(true) as HTMLElement;
|
||||
this.draggable.style.position = 'absolute';
|
||||
this.draggable.style.width = `${rect.width}px`;
|
||||
this.draggable.style.height = `${rect.height}px`;
|
||||
this.draggable.style.margin = '0';
|
||||
this.draggable.classList.add('draggable-dragging');
|
||||
this.width = rect.width;
|
||||
this.height = rect.height;
|
||||
|
||||
const src = element.querySelectorAll('canvas') as NodeListOf<HTMLCanvasElement>;
|
||||
const dst = this.draggable.querySelectorAll('canvas') as NodeListOf<HTMLCanvasElement>;
|
||||
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const context = dst.item(i).getContext('2d');
|
||||
context && context.drawImage(src.item(i), 0, 0);
|
||||
}
|
||||
|
||||
this.service.startMove(this.draggable, this.item!);
|
||||
this.dragStarted.emit();
|
||||
}
|
||||
|
||||
if (this.draggable) {
|
||||
if (e.type === 'end') {
|
||||
this.draggable!.parentNode!.removeChild(this.draggable!);
|
||||
this.draggable = undefined;
|
||||
this.service.endMove();
|
||||
} else {
|
||||
const x = clamp(this.startX + e.dx, 0, window.innerWidth - this.width);
|
||||
const y = clamp(this.startY + e.dy, 0, window.innerHeight - this.height);
|
||||
setTransform(this.draggable, `translate3d(${x}px, ${y}px, 0px)`);
|
||||
this.service.updateHover(e.x, e.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const draggableComponents = [DraggableOutlet, DraggableItem, DraggableDrop];
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
Directive, HostListener, Input, Output, EventEmitter, TemplateRef, ViewContainerRef, ContentChild,
|
||||
Renderer2, ElementRef, EmbeddedViewRef, Component, Injectable
|
||||
} from '@angular/core';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { focusFirstElement } from '../../../client/htmlUtils';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DropdownOutletService {
|
||||
viewContainer?: ViewContainerRef;
|
||||
rootElement?: HTMLElement;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dropdown-outlet',
|
||||
template: `<ng-template></ng-template>`,
|
||||
})
|
||||
export class DropdownOutlet {
|
||||
constructor(service: DropdownOutletService, viewContainer: ViewContainerRef, element: ElementRef) {
|
||||
service.viewContainer = viewContainer;
|
||||
service.rootElement = element.nativeElement.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dropdownMenu]',
|
||||
})
|
||||
export class DropdownMenu {
|
||||
ref?: EmbeddedViewRef<any>;
|
||||
id = uniqueId('dropdown-menu-');
|
||||
private onClose?: () => void;
|
||||
constructor(
|
||||
private templateRef: TemplateRef<any>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private renderer: Renderer2,
|
||||
private service: DropdownOutletService,
|
||||
) {
|
||||
}
|
||||
private get root(): HTMLElement {
|
||||
return this.ref && this.ref.rootNodes[0];
|
||||
}
|
||||
open(useOutlet: boolean, rootElement: HTMLElement) {
|
||||
if (!this.ref) {
|
||||
if (useOutlet) {
|
||||
this.ref = this.service.viewContainer!.createEmbeddedView(this.templateRef);
|
||||
} else {
|
||||
this.ref = this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
|
||||
const { renderer, root } = this;
|
||||
|
||||
renderer.addClass(root, 'show');
|
||||
renderer.setAttribute(root, 'id', this.id);
|
||||
|
||||
if (useOutlet) {
|
||||
const positionMenu = () => {
|
||||
const rect = rootElement.getBoundingClientRect();
|
||||
const menuRect = root.getBoundingClientRect();
|
||||
let transform: string;
|
||||
|
||||
if ((rect.bottom + menuRect.height) > window.innerHeight) {
|
||||
transform = `translate3d(${Math.round(rect.left)}px, ${Math.round(rect.top - menuRect.height)}px, 0)`;
|
||||
renderer.addClass(root, 'dropdown-menu-up');
|
||||
} else {
|
||||
transform = `translate3d(${Math.round(rect.left)}px, ${Math.round(rect.bottom)}px, 0)`;
|
||||
renderer.removeClass(root, 'dropdown-menu-up');
|
||||
}
|
||||
|
||||
renderer.setStyle(root, 'transform', transform);
|
||||
};
|
||||
|
||||
renderer.addClass(root, 'dropdown-in-outlet');
|
||||
positionMenu();
|
||||
|
||||
const closeDropdown = () => {
|
||||
this.close();
|
||||
};
|
||||
|
||||
document.addEventListener('scroll', closeDropdown, true);
|
||||
window.addEventListener('resize', closeDropdown, true);
|
||||
|
||||
this.onClose = () => {
|
||||
document.removeEventListener('scroll', closeDropdown, true);
|
||||
window.removeEventListener('resize', closeDropdown, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
close() {
|
||||
if (this.ref) {
|
||||
this.ref.destroy();
|
||||
this.ref = undefined;
|
||||
}
|
||||
|
||||
if (this.onClose) {
|
||||
this.onClose();
|
||||
this.onClose = undefined;
|
||||
}
|
||||
}
|
||||
checkTarget(e: Event) {
|
||||
return this.root && this.root.contains(e.target as any);
|
||||
}
|
||||
focusFirstElement() {
|
||||
if (this.root) {
|
||||
focusFirstElement(this.root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dropdown]',
|
||||
exportAs: 'ag-dropdown',
|
||||
host: {
|
||||
'[class.show]': 'isOpen',
|
||||
},
|
||||
})
|
||||
export class Dropdown {
|
||||
dropdownToggle?: DropdownToggle;
|
||||
@ContentChild(DropdownMenu, { static: false }) menu!: DropdownMenu;
|
||||
@Input() autoClose: boolean | 'outsideClick' = true;
|
||||
@Input() preventAutoCloseOnOutlet = false;
|
||||
@Input() hookToCanvas = false;
|
||||
@Input() focusOnOpen = true;
|
||||
@Input() focusOnClose = true;
|
||||
@Input() useOutlet = false;
|
||||
@Input() isOpen = false;
|
||||
@Output() isOpenChange = new EventEmitter<boolean>();
|
||||
get menuId() {
|
||||
return this.isOpen ? this.menu.id : '';
|
||||
}
|
||||
constructor(private element: ElementRef, private service: DropdownOutletService) {
|
||||
}
|
||||
open() {
|
||||
if (!this.isOpen) {
|
||||
this.isOpen = true;
|
||||
this.isOpenChange.emit(true);
|
||||
this.menu.open(this.useOutlet, this.element.nativeElement);
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.closeHandler);
|
||||
document.addEventListener('keydown', this.closeHandler);
|
||||
|
||||
if (this.focusOnOpen) {
|
||||
this.menu.focusFirstElement();
|
||||
}
|
||||
|
||||
if (this.hookToCanvas) {
|
||||
const canvas = document.getElementById('canvas');
|
||||
|
||||
if (canvas) {
|
||||
canvas.addEventListener('touchstart', this.canvasCloseHandler);
|
||||
canvas.addEventListener('mousedown', this.canvasCloseHandler);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
close() {
|
||||
if (this.isOpen) {
|
||||
this.isOpen = false;
|
||||
this.isOpenChange.emit(false);
|
||||
this.menu.close();
|
||||
|
||||
if (this.focusOnClose && this.dropdownToggle) {
|
||||
this.dropdownToggle.focus();
|
||||
}
|
||||
|
||||
document.removeEventListener('click', this.closeHandler);
|
||||
document.removeEventListener('keydown', this.closeHandler);
|
||||
|
||||
if (this.hookToCanvas) {
|
||||
const canvas = document.getElementById('canvas');
|
||||
|
||||
if (canvas) {
|
||||
canvas.removeEventListener('touchstart', this.canvasCloseHandler);
|
||||
canvas.removeEventListener('mousedown', this.canvasCloseHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
private closeHandler: any = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!e.keyCode
|
||||
&& (this.autoClose || (this.dropdownToggle && this.dropdownToggle.checkTarget(e)))
|
||||
&& !(this.preventAutoCloseOnOutlet && this.service.rootElement && this.service.rootElement.contains(e.target as any))
|
||||
&& !(this.autoClose === 'outsideClick' && this.menu.checkTarget(e))
|
||||
) {
|
||||
this.close();
|
||||
} else if (this.autoClose && e.keyCode === 27) { // esc
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
private canvasCloseHandler: any = () => this.close();
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dropdownToggle]',
|
||||
host: {
|
||||
'aria-haspopup': 'true',
|
||||
'[attr.aria-expanded]': 'dropdown.isOpen',
|
||||
'[attr.aria-controls]': 'dropdown.isOpen ? dropdown.menuId : undefined',
|
||||
},
|
||||
})
|
||||
export class DropdownToggle {
|
||||
constructor(private element: ElementRef, public dropdown: Dropdown) {
|
||||
dropdown.dropdownToggle = this;
|
||||
}
|
||||
@HostListener('click')
|
||||
click() {
|
||||
this.dropdown.toggle();
|
||||
}
|
||||
checkTarget(e: Event) {
|
||||
return this.element.nativeElement.contains(e.target);
|
||||
}
|
||||
focus() {
|
||||
this.element.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export const dropdownDirectives = [Dropdown, DropdownToggle, DropdownMenu, DropdownOutlet];
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Directive, ElementRef, Input, HostListener, HostBinding, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[fixToTop]',
|
||||
})
|
||||
export class FixToTop {
|
||||
@Input() fixToTopOffset = 0;
|
||||
@Output() fixToTop = new EventEmitter<boolean>();
|
||||
@HostBinding('class.fixed-to-top') fixed = false;
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
@HostListener('window:scroll')
|
||||
scroll() {
|
||||
const element = this.element.nativeElement as HTMLElement;
|
||||
const { top } = element.getBoundingClientRect();
|
||||
|
||||
if (this.fixed !== top < this.fixToTopOffset) {
|
||||
this.fixed = top < this.fixToTopOffset;
|
||||
this.fixToTop.emit(this.fixed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Directive, AfterViewInit, ElementRef } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[focusTitle]',
|
||||
host: {
|
||||
'tabindex': '-1',
|
||||
},
|
||||
})
|
||||
export class FocusTitle implements AfterViewInit {
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.element.nativeElement.focus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Directive, Input, OnDestroy, ElementRef, OnInit } from '@angular/core';
|
||||
import { isParentOf, focusFirstElement, findFocusableElements } from '../../../client/htmlUtils';
|
||||
import { isMobile } from '../../../client/data';
|
||||
|
||||
@Directive({
|
||||
selector: '[focusTrap]',
|
||||
})
|
||||
export class FocusTrap implements OnInit, OnDestroy {
|
||||
private on = true;
|
||||
private lastActiveElement?: HTMLElement;
|
||||
@Input() set focusTrap(value: boolean) {
|
||||
if (this.on !== value) {
|
||||
this.on = value;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.update();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.focusTrap = false;
|
||||
}
|
||||
private update() {
|
||||
if (!isMobile) {
|
||||
if (this.on) {
|
||||
this.lastActiveElement = document.activeElement as HTMLElement;
|
||||
document.addEventListener('focusin', this.focus);
|
||||
|
||||
if (!isParentOf(this.element.nativeElement, this.lastActiveElement)) {
|
||||
setTimeout(() => this.lastActiveElement = focusFirstElement(this.element.nativeElement));
|
||||
}
|
||||
} else {
|
||||
this.lastActiveElement = undefined;
|
||||
document.removeEventListener('focusin', this.focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
private focus = (e: Event) => {
|
||||
if (isParentOf(this.element.nativeElement, e.target as any)) {
|
||||
this.lastActiveElement = e.target as any;
|
||||
} else {
|
||||
const focusable = findFocusableElements(this.element.nativeElement);
|
||||
|
||||
if (focusable.length) {
|
||||
if (this.lastActiveElement === focusable[0]) {
|
||||
this.lastActiveElement = focusable[focusable.length - 1];
|
||||
} else {
|
||||
this.lastActiveElement = focusable[0];
|
||||
}
|
||||
|
||||
this.lastActiveElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Directive, Input, TemplateRef, ViewContainerRef, OnDestroy, EmbeddedViewRef, AfterViewInit } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { hasFeatureFlag, featureFlagsChanged } from '../../../client/clientUtils';
|
||||
import { Model } from '../../services/model';
|
||||
|
||||
@Directive({
|
||||
selector: '[hasFeature]',
|
||||
})
|
||||
export class HasFeature implements AfterViewInit, OnDestroy {
|
||||
private subscriptions: Subscription[] = [];
|
||||
private showing = false;
|
||||
private _flag: string | undefined = undefined;
|
||||
private _orMod = false;
|
||||
private _alsoIf = true;
|
||||
private ref?: EmbeddedViewRef<any>;
|
||||
constructor(private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef, private model: Model) {
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
this.subscriptions.push(featureFlagsChanged.subscribe(() => this.update()));
|
||||
this.subscriptions.push(this.model.accountChanged.subscribe(() => this.update()));
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(s => s.unsubscribe());
|
||||
}
|
||||
@Input()
|
||||
set hasFeature(value: string | undefined) {
|
||||
if (this._flag !== value) {
|
||||
this._flag = value;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
@Input()
|
||||
set hasFeatureOrMod(value: boolean) {
|
||||
if (this._orMod !== value) {
|
||||
this._orMod = value;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
@Input()
|
||||
set hasFeatureAlso(value: boolean) {
|
||||
if (this._alsoIf !== value) {
|
||||
this._alsoIf = value;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
private update() {
|
||||
const show = this._alsoIf && (hasFeatureFlag(this._flag as any) || (this._orMod && this.model.isMod));
|
||||
|
||||
if (this.showing !== show) {
|
||||
this.showing = show;
|
||||
|
||||
if (show) {
|
||||
this.ref = this.ref || this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
} else {
|
||||
this.viewContainer.clear();
|
||||
this.ref = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Directive, Input, OnInit, ElementRef } from '@angular/core';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { findParentElement } from '../../../client/htmlUtils';
|
||||
|
||||
@Directive({
|
||||
selector: '[labelledBy]',
|
||||
})
|
||||
export class LabelledBy implements OnInit {
|
||||
@Input('labelledBy') selector!: string;
|
||||
constructor(private element: ElementRef) {
|
||||
}
|
||||
ngOnInit() {
|
||||
const element = this.element.nativeElement as HTMLElement;
|
||||
const target = findParentElement(element, this.selector);
|
||||
const id = element.id = element.id || uniqueId('labelled-by-');
|
||||
|
||||
if (target) {
|
||||
target.setAttribute('aria-labelledby', id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Directive, HostBinding } from '@angular/core';
|
||||
import { RouterLinkActive } from '@angular/router';
|
||||
|
||||
@Directive({
|
||||
selector: '[linkCurrent]',
|
||||
})
|
||||
export class LinkCurrent {
|
||||
constructor(private routerLinkActive: RouterLinkActive) {
|
||||
}
|
||||
@HostBinding('attr.aria-current')
|
||||
get current() {
|
||||
return this.routerLinkActive.isActive ? 'true' : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Directive, Input, HostBinding } from '@angular/core';
|
||||
import { getUrl } from '../../../client/rev';
|
||||
|
||||
@Directive({
|
||||
selector: '[revSrc]',
|
||||
})
|
||||
export class RevSrc {
|
||||
@HostBinding() get src() {
|
||||
return this.revSrc && getUrl(this.revSrc);
|
||||
}
|
||||
@Input() revSrc?: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Directive, Input, Host, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Tabset } from '../tabset/tabset';
|
||||
import { StorageService } from '../../services/storageService';
|
||||
|
||||
@Directive({
|
||||
selector: '[saveActiveTab]',
|
||||
})
|
||||
export class SaveActiveTab implements OnInit, OnDestroy {
|
||||
@Input('saveActiveTab') key!: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(@Host() private tabset: Tabset, private storage: StorageService) {
|
||||
}
|
||||
ngOnInit() {
|
||||
// this.tabset.activeIndex = parseInt(this.storage.getItem(this.key) || '0', 10);
|
||||
this.tabset.select(parseInt(this.storage.getItem(this.key) || '0', 10));
|
||||
this.subscription = this.tabset.activeIndexChange.subscribe((i: number) => {
|
||||
this.storage.setItem(this.key, i.toString());
|
||||
});
|
||||
}
|
||||
ngOnDestroy() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Component, Input, AfterViewInit, ElementRef, ChangeDetectionStrategy, ViewChild, NgZone } from '@angular/core';
|
||||
import { findEmoji, getEmojiImageAsync } from '../../../client/emoji';
|
||||
import { loadAndInitSpriteSheets } from '../../../client/spriteUtils';
|
||||
import { font } from '../../../client/fonts';
|
||||
import { getCharacterSprite } from '../../../graphics/spriteFont';
|
||||
|
||||
@Component({
|
||||
selector: 'emote-box',
|
||||
template: '<img #image class="emote-box pixelart" />',
|
||||
styles: ['.emote-box { pointer-events: none; }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EmoteBox implements AfterViewInit {
|
||||
@ViewChild('image', { static: true }) image!: ElementRef;
|
||||
private emoteValue = '';
|
||||
private scaleValue = 2;
|
||||
private initialized = false;
|
||||
constructor(private zone: NgZone) {
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
loadAndInitSpriteSheets()
|
||||
.then(() => {
|
||||
this.initialized = true;
|
||||
this.zone.runOutsideAngular(() => this.redraw());
|
||||
});
|
||||
}
|
||||
get emote() {
|
||||
return this.emoteValue;
|
||||
}
|
||||
@Input()
|
||||
set emote(value: string) {
|
||||
if (this.emoteValue !== value) {
|
||||
this.emoteValue = value;
|
||||
this.zone.runOutsideAngular(() => this.redraw());
|
||||
}
|
||||
}
|
||||
get scale() {
|
||||
return this.scaleValue;
|
||||
}
|
||||
@Input()
|
||||
set scale(value: number) {
|
||||
if (this.scaleValue !== value) {
|
||||
this.scaleValue = value;
|
||||
this.zone.runOutsideAngular(() => this.redraw());
|
||||
}
|
||||
}
|
||||
redraw() {
|
||||
if (this.initialized) {
|
||||
const emote = findEmoji(this.emote);
|
||||
const sprite = font && emote && getCharacterSprite(emote.symbol, font);
|
||||
const image = this.image.nativeElement as HTMLImageElement;
|
||||
|
||||
if (sprite) {
|
||||
const width = sprite.w + sprite.ox;
|
||||
const height = 10; // sprite.h + sprite.oy;
|
||||
|
||||
image.style.width = `${width * this.scale}px`;
|
||||
image.style.height = `${height * this.scale}px`;
|
||||
image.style.marginTop = `${-this.scale}px`;
|
||||
image.style.display = 'inline-block';
|
||||
image.style.visibility = 'hidden';
|
||||
|
||||
if (emote) {
|
||||
image.setAttribute('aria-label', emote.names[0]);
|
||||
}
|
||||
|
||||
getEmojiImageAsync(sprite, src => {
|
||||
image.src = src;
|
||||
image.alt = emote ? emote.symbol : '';
|
||||
image.style.visibility = 'visible';
|
||||
});
|
||||
} else {
|
||||
image.style.width = `0px`;
|
||||
image.style.height = `0px`;
|
||||
image.src = '';
|
||||
image.alt = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.row.form-group(*ngIf="outlineHidden")
|
||||
.col-4.col-lock-box
|
||||
label.text-muted.color-label {{label}}
|
||||
check-box.lock-box(
|
||||
*ngIf="hasLock && !nonLockable" [(checked)]="locked" (checkedChange)="onLockedChange($event)"
|
||||
[icon]="lockIcon" label="Automatic color")
|
||||
.col-8
|
||||
color-picker(
|
||||
[(color)]="fill" (colorChange)="onFillChange($event)" [isDisabled]="locked" [label]="label || 'Color'"
|
||||
[indicatorColor]="indicatorColor")
|
||||
|
||||
.row.form-group(*ngIf="!outlineHidden")
|
||||
.col-4.col-sm-3.col-lock-box
|
||||
label.text-muted.color-label {{label}}
|
||||
check-box.lock-box(
|
||||
*ngIf="hasLock && !nonLockable" [(checked)]="locked" (checkedChange)="onLockedChange($event)"
|
||||
[icon]="lockIcon" label="Automatic color")
|
||||
.col-8.col-sm-4
|
||||
color-picker(
|
||||
[(color)]="fill" (colorChange)="onFillChange($event)" [isDisabled]="locked" [label]="label + ' fill'"
|
||||
[indicatorColor]="indicatorColor")
|
||||
.col-4.col-sm-1.col-outline
|
||||
check-box.lock-box(
|
||||
[(checked)]="outlineLocked" (checkedChange)="onOutlineLockedChange($event)" [icon]="lockIcon"
|
||||
label="Automatic outline")
|
||||
.col-8.col-sm-4.col-outline
|
||||
color-picker(
|
||||
[(color)]="outline" (colorChange)="onOutlineChange($event)" [isDisabled]="outlineLocked"
|
||||
[label]="label + ' outline'" [indicatorColor]="indicatorColor")
|
||||
@@ -0,0 +1,18 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
@import '../../../../styles/partials/mixins';
|
||||
|
||||
.col-lock-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
> label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.col-outline {
|
||||
@include media-breakpoint-down(xs) {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { faLock } from '../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'fill-outline',
|
||||
templateUrl: 'fill-outline.pug',
|
||||
styleUrls: ['fill-outline.scss'],
|
||||
})
|
||||
export class FillOutline {
|
||||
readonly lockIcon = faLock;
|
||||
@Input() label = 'Color';
|
||||
@Input() indicatorColor = '';
|
||||
@Input() base?: string;
|
||||
@Input() fill?: string;
|
||||
@Output() fillChange = new EventEmitter<string>();
|
||||
@Input() outline?: string;
|
||||
@Output() outlineChange = new EventEmitter<string>();
|
||||
@Input() locked?: boolean;
|
||||
@Output() lockedChange = new EventEmitter<boolean>();
|
||||
@Input() nonLockable = false;
|
||||
@Input() outlineLocked = false;
|
||||
@Output() outlineLockedChange = new EventEmitter<boolean>();
|
||||
@Input() outlineHidden = false;
|
||||
@Output() change = new EventEmitter<void>();
|
||||
get hasLock() {
|
||||
return this.locked !== undefined;
|
||||
}
|
||||
onChange() {
|
||||
this.change.emit();
|
||||
}
|
||||
onFillChange(value: string) {
|
||||
this.fillChange.emit(value);
|
||||
this.onChange();
|
||||
}
|
||||
onOutlineChange(value: string) {
|
||||
this.outlineChange.emit(value);
|
||||
this.onChange();
|
||||
}
|
||||
onLockedChange(value: boolean) {
|
||||
this.lockedChange.emit(value);
|
||||
this.onChange();
|
||||
}
|
||||
onOutlineLockedChange(value: boolean) {
|
||||
this.outlineLockedChange.emit(value);
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.friends-box.dropdown(
|
||||
dropdown autoClose="outsideClick" (isOpenChange)="toggle()" [hookToCanvas]="true" [focusOnOpen]="false"
|
||||
[preventAutoCloseOnOutlet]="true")
|
||||
button.game-button.dropdown-toggle.no-arrow(dropdownToggle (click)="false" title="Friends")
|
||||
fa-icon([icon]="friendsIcon" [fixedWidth]="true")
|
||||
.friends-dropdown-menu.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
.friends-list-header.list-unstyled
|
||||
.dropdown-header.d-flex
|
||||
.flex-grow-1 Friends
|
||||
.dropdown(dropdown)
|
||||
button.dropdown-toggle.no-arrow(dropdownToggle)
|
||||
span.text-success(*ngIf="!hidden") online
|
||||
span.text-muted(*ngIf="hidden") offline
|
||||
fa-icon.ml-2([icon]="cogIcon")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
button.dropdown-item((click)="setStatus('online')")
|
||||
fa-icon.mr-2.text-success([icon]="statusIcon" size="xs" [fixedWidth]="true")
|
||||
| Online
|
||||
button.dropdown-item((click)="setStatus('invisible')")
|
||||
fa-icon.mr-2.text-muted([icon]="statusIcon" size="xs" [fixedWidth]="true")
|
||||
| Show as Offline
|
||||
.friends-list(*ngIf="friends && friends.length; else noFriends")
|
||||
//- [itemSize]="42")
|
||||
.friends-item(
|
||||
dropdown
|
||||
*ngFor="let f of friends"
|
||||
[ngClass]="f.online ? 'online' : 'offline'"
|
||||
[useOutlet]="true"
|
||||
[hookToCanvas]="true"
|
||||
[focusOnOpen]="false"
|
||||
[focusOnClose]="false")
|
||||
button.d-flex(dropdownToggle)
|
||||
portrait-box.mr-2([pony]="f.ponyInfo" size="small" [noBorder]="true")
|
||||
.flex-grow-1.friends-item-details
|
||||
.friends-item-name {{f.actualName}} 
|
||||
.friends-item-more
|
||||
.text-success.float-right.ml-2(*ngIf="f.online") online
|
||||
.text-muted.float-right.ml-2(*ngIf="!f.online") offline
|
||||
.text-muted.friends-item-account {{f.accountName}}
|
||||
.friends-item-delete.d-flex.px-2.py-1(*ngIf="removing === f")
|
||||
button.btn.btn-sm.btn-outline-success.flex-grow-1((click)="cancelRemove(); $event.stopPropagation()")
|
||||
| Cancel
|
||||
button.btn.btn-sm.btn-outline-danger.flex-grow-1.ml-2((click)="confirmRemove(); $event.stopPropagation()")
|
||||
| Confirm remove
|
||||
//- span.badge.badge-none.friends-item-server PG
|
||||
.options-dropdown-menu.dropdown-menu(*dropdownMenu)
|
||||
button.dropdown-item((click)="sendMessageTo(f)" [disabled]="!f.online") Send whisper
|
||||
button.dropdown-item((click)="inviteToParty(f)" [disabled]="!f.online") Invite to party
|
||||
.dropdown-divider
|
||||
button.dropdown-item((click)="remove(f)") Remove
|
||||
ng-template(#noFriends)
|
||||
.text-center.text-muted.p-2(*ngIf="friends")
|
||||
| select a player and use #[fa-icon([icon]="userOptionsIcon")] menu to add them to your friends
|
||||
.text-center.text-muted.p-2(*ngIf="!friends")
|
||||
| Loading...
|
||||
@@ -0,0 +1,107 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
@import '../../../../styles/partials/mixins';
|
||||
|
||||
.friends-box {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.friends-dropdown-menu {
|
||||
@include nipple(8px);
|
||||
min-width: 205px;
|
||||
}
|
||||
|
||||
.options-dropdown-menu {
|
||||
@include nippleLeft(11px);
|
||||
min-width: 200px;
|
||||
margin-left: 4px;
|
||||
|
||||
&.dropdown-menu-up {
|
||||
@include nippleLeftFlip();
|
||||
}
|
||||
}
|
||||
|
||||
.friends-list-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.friends-list {
|
||||
width: 250px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media screen and (max-height: 600px) {
|
||||
max-height: 70vh;
|
||||
max-height: calc(100vh - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
.friends-item {
|
||||
height: 42px;
|
||||
color: $dropdown-link-color;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
|
||||
> button {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
portrait-box {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
&.offline {
|
||||
> button {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $dropdown-link-hover-bg;
|
||||
color: $dropdown-link-hover-color;
|
||||
}
|
||||
}
|
||||
|
||||
.friends-item-delete {
|
||||
position: absolute;
|
||||
background: white;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.friends-item-details {
|
||||
overflow: hidden;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.friends-item-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.friends-item-more {
|
||||
margin-bottom: -2px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.friends-item-account {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.friends-item-server {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 0;
|
||||
font-size: 8px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, Output, EventEmitter } from '@angular/core';
|
||||
import { faCog, faUserFriends, faUserPlus, faUserCog, faCircle } from '../../../client/icons';
|
||||
import { Model, Friend } from '../../services/model';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { PlayerAction, Action } from '../../../common/interfaces';
|
||||
import { removeItem } from '../../../common/utils';
|
||||
import { SettingsService } from '../../services/settingsService';
|
||||
|
||||
@Component({
|
||||
selector: 'friends-box',
|
||||
templateUrl: 'friends-box.pug',
|
||||
styleUrls: ['friends-box.scss'],
|
||||
})
|
||||
export class FriendsBox {
|
||||
readonly friendsIcon = faUserFriends;
|
||||
readonly cogIcon = faCog;
|
||||
readonly addToPartyIcon = faUserPlus;
|
||||
readonly userOptionsIcon = faUserCog;
|
||||
readonly statusIcon = faCircle;
|
||||
@Output() sendMessage = new EventEmitter<Friend>();
|
||||
removing?: Friend;
|
||||
constructor(private settings: SettingsService, private model: Model, private game: PonyTownGame) {
|
||||
}
|
||||
get friends() {
|
||||
return this.model.friends;
|
||||
}
|
||||
get hidden() {
|
||||
return !!this.settings.account.hidden;
|
||||
}
|
||||
toggleHidden() {
|
||||
this.settings.account.hidden = !this.settings.account.hidden;
|
||||
this.settings.saveAccountSettings(this.settings.account);
|
||||
}
|
||||
toggle() {
|
||||
this.removing = undefined;
|
||||
}
|
||||
sendMessageTo(friend: Friend) {
|
||||
this.sendMessage.emit(friend);
|
||||
}
|
||||
inviteToParty(friend: Friend) {
|
||||
this.game.send(server => server.playerAction(friend.entityId, PlayerAction.InviteToParty, undefined));
|
||||
}
|
||||
remove(friend: Friend) {
|
||||
this.removing = friend;
|
||||
}
|
||||
cancelRemove() {
|
||||
this.removing = undefined;
|
||||
}
|
||||
confirmRemove() {
|
||||
if (this.removing && this.model.friends) {
|
||||
const { accountId } = this.removing;
|
||||
this.game.send(server => server.actionParam(Action.RemoveFriend, accountId));
|
||||
removeItem(this.model.friends, this.removing);
|
||||
this.removing = undefined;
|
||||
}
|
||||
}
|
||||
setStatus(status: string) {
|
||||
this.settings.account.hidden = status === 'invisible';
|
||||
this.settings.saveAccountSettings(this.settings.account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.btn-group.d-flex(*ngIf="canInstall")
|
||||
button.btn.btn-lg.btn-outline-success.text-wrap.flex-grow-1((click)="install()")
|
||||
| Add #[b Pony Town] to {{isMobile ? 'home screen' : 'desktop'}}
|
||||
button.btn.btn-lg.btn-outline-success.flex-grow-0(
|
||||
(click)="dismiss()" title="Dismiss" [attr.aria-label]="'Dismiss add to ' + (isMobile ? 'home screen' : 'desktop')")
|
||||
fa-icon([icon]="closeIcon")
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { faTimes } from '../../../client/icons';
|
||||
import { InstallService } from '../../services/installService';
|
||||
import { isMobile } from '../../../client/data';
|
||||
|
||||
@Component({
|
||||
selector: 'install-button',
|
||||
templateUrl: 'install-button.pug',
|
||||
styleUrls: ['install-button.scss'],
|
||||
})
|
||||
export class InstallButton {
|
||||
readonly closeIcon = faTimes;
|
||||
constructor(private installService: InstallService) {
|
||||
}
|
||||
get canInstall() {
|
||||
return this.installService.canInstall;
|
||||
}
|
||||
get isMobile() {
|
||||
return isMobile;
|
||||
}
|
||||
install() {
|
||||
this.installService.install();
|
||||
}
|
||||
dismiss() {
|
||||
this.installService.dismiss();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.modal-header.d-none.d-sm-block
|
||||
h4.modal-title(labelledBy=".modal")
|
||||
| Invites
|
||||
|
||||
.modal-body
|
||||
p
|
||||
| Using #[b {{invites.length}}] out of #[b {{inviteLimit}}] invites.
|
||||
.list-group
|
||||
.list-group-item(*ngFor="let i of invites" [class.text-muted]="!i.active")
|
||||
.d-flex.justify-content-between
|
||||
.d-flex.align-items-center
|
||||
portrait-box([pony]="i.pony" size="small" [noBorder]="true" style="margin: -5px 10px -5px -10px;")
|
||||
b {{i.name}}
|
||||
span.text-muted.ml-1(*ngIf="!i.active") (inactive)
|
||||
button.btn.btn-xs.btn-outline-danger Cancel
|
||||
|
||||
.alert.alert-danger.mt-3.mb-0(*ngIf="error")
|
||||
| {{error}}
|
||||
|
||||
.modal-footer.justify-content-end
|
||||
button.btn.btn-outline-secondary((click)="close.emit()")
|
||||
| Close
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, Output, EventEmitter, OnInit } from '@angular/core';
|
||||
import { toPalette } from '../../../common/ponyInfo';
|
||||
import { decompressPonyString } from '../../../common/compressPony';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { Action, SupporterInvite, PalettePonyInfo } from '../../../common/interfaces';
|
||||
import { removeItem } from '../../../common/utils';
|
||||
import { Model } from '../../services/model';
|
||||
|
||||
@Component({
|
||||
selector: 'invites-modal',
|
||||
templateUrl: 'invites-modal.pug',
|
||||
})
|
||||
export class InvitesModal implements OnInit {
|
||||
@Output() close = new EventEmitter();
|
||||
invites: (SupporterInvite & { pony: PalettePonyInfo; })[] = [];
|
||||
error?: string;
|
||||
constructor(private model: Model, private game: PonyTownGame) {
|
||||
}
|
||||
get inviteLimit() {
|
||||
return this.model.supporterInviteLimit;
|
||||
}
|
||||
ngOnInit() {
|
||||
this.game.send(server => server.getInvites())!
|
||||
.then(invites => invites.map(i => ({ ...i, pony: toPalette(decompressPonyString(i.info)) })))
|
||||
.then(invites => this.invites = invites);
|
||||
}
|
||||
remove(invite: SupporterInvite) {
|
||||
this.error = undefined;
|
||||
this.game.send(server => server.actionParam(Action.CancelSupporterInvite, invite.id));
|
||||
removeItem(this.invites, invite);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
kbd([title]="title || ''" [attr.aria-label]="title")
|
||||
span([attr.aria-hidden]="!!title")
|
||||
ng-content
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'kbd-key',
|
||||
templateUrl: 'kbd-key.pug',
|
||||
})
|
||||
export class KbdKey {
|
||||
@Input() title?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
nav.navbar.navbar-expand.justify-content-end(role="navigation")
|
||||
.d-none.d-md-block
|
||||
a.pixelart.logo(*ngIf="logo" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }")
|
||||
img.pixelart.logo-large(revSrc="images/logo-large.png" alt="Pony Town")
|
||||
img.pixelart.logo-small(revSrc="images/logo-small.png" alt="Pony Town")
|
||||
|
||||
.navbar-collapse.text-right
|
||||
.navbar-nav.ml-auto
|
||||
ng-content
|
||||
.dropdown(*ngIf="account" #dropdown="ag-dropdown" dropdown autoClose="outsideClick")
|
||||
button.btn.cursor-pointer.dropdown-toggle(dropdownToggle [attr.aria-label]="'Signed-in as ' + account.name")
|
||||
fa-icon.mr-1(
|
||||
*ngIf="hasSupporterIcon" [icon]="starIcon" [ngClass]="supporterClass"
|
||||
[title]="supporterTitle" [attr.aria-label]="supporterTitle" aria-hidden="true")
|
||||
.d-none.d-sm-inline(aria-hidden="true") {{account.name}}
|
||||
fa-icon.d-inline-block.d-sm-none(
|
||||
[icon]="userIcon" [title]="account.name" [fixedWidth]="true" size="lg" aria-hidden="true")
|
||||
fa-icon.text-danger.account-alert-icon(
|
||||
*ngIf="showAccountAlert" [icon]="alertIcon" [fixedWidth]="true" size="sm" aria-hidden="true")
|
||||
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
.dropdown-item.d-flex.align-items-center
|
||||
.flex-grow-1.mr-3 Status:
|
||||
.dropdown(dropdown style="width: 65px;")
|
||||
button.online-offline.no-highlight.p-0.dropdown-toggle.no-arrow(dropdownToggle)
|
||||
span.text-success(*ngIf="!hidden") online
|
||||
span.text-muted(*ngIf="hidden") offline
|
||||
fa-icon.ml-2([icon]="cogIcon")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
button.dropdown-item((click)="setStatus('online')")
|
||||
fa-icon.mr-2.text-success([icon]="statusIcon" size="xs" [fixedWidth]="true")
|
||||
| Online
|
||||
button.dropdown-item((click)="setStatus('invisible')")
|
||||
fa-icon.mr-2.text-muted([icon]="statusIcon" size="xs" [fixedWidth]="true")
|
||||
| Show as Offline
|
||||
a.dropdown-item(routerLink="/account" (click)="dropdown.close()" tabindex)
|
||||
| Account settings
|
||||
fa-icon.text-danger.ml-1(*ngIf="showAccountAlert" [icon]="alertIcon" aria-hidden="true")
|
||||
.dropdown-divider
|
||||
button.dropdown-item((click)="signOut.emit(); dropdown.close()")
|
||||
| Sign out
|
||||
|
||||
.text-muted(*ngIf="loading" style="font-size: 20px; padding: 10px 20px;")
|
||||
fa-icon([icon]="spinnerIcon" [fixedWidth]="true" [spin]="true")
|
||||
|
||||
form.navbar-form.ml-2(*ngIf="!loading && !account")
|
||||
.button-group.dropdown(dropdown)
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle [disabled]="!!loadingError")
|
||||
| Sign in
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
.dropdown-header(*ngIf="signUpProviders.length")
|
||||
| Sign in or sign up
|
||||
button.dropdown-item(
|
||||
*ngFor="let p of signUpProviders" (click)="signInTo(p)" title="Sign in using {{p.name}}")
|
||||
fa-icon.mr-1([icon]="icon(p.id)" [fixedWidth]="true")
|
||||
| {{p.name}}
|
||||
.dropdown-header(*ngIf="signInProviders.length")
|
||||
| Sign in only
|
||||
button.dropdown-item.sign-in-only(
|
||||
*ngFor="let p of signInProviders" (click)="signInTo(p)" title="Sign in using {{p.name}}")
|
||||
fa-icon.mr-1([icon]="icon(p.id)" [fixedWidth]="true")
|
||||
| {{p.name}}
|
||||
@@ -0,0 +1,59 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.navbar {
|
||||
font-size: 16px;
|
||||
margin-bottom: 0;
|
||||
padding: 0px !important;
|
||||
padding-top: $navbar-link-border !important;
|
||||
|
||||
.navbar-toggler {
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-top: 5px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
&.active {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-large {
|
||||
width: 287px;
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
width: 37px;
|
||||
display: block;
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sign-in-only {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.account-alert-icon {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.online-offline {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, Input, Output, EventEmitter, HostListener } from '@angular/core';
|
||||
import { AccountData, OAuthProvider } from '../../../common/interfaces';
|
||||
import { signUpProviders, signInProviders } from '../../../client/data';
|
||||
import { getProviderIcon } from '../sign-in-box/sign-in-box';
|
||||
import { faStar, faSpinner, faUser, faExclamationCircle, faCog, faCircle } from '../../../client/icons';
|
||||
import { Model } from '../../services/model';
|
||||
import { supporterClass, supporterTitle, isSupporterOrPastSupporter } from '../../../client/clientUtils';
|
||||
import { SettingsService } from '../../services/settingsService';
|
||||
import { REQUEST_DATE_OF_BIRTH } from '../../../common/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'menu-bar',
|
||||
templateUrl: 'menu-bar.pug',
|
||||
styleUrls: ['menu-bar.scss'],
|
||||
})
|
||||
export class MenuBar {
|
||||
readonly signUpProviders = signUpProviders;
|
||||
readonly signInProviders = signInProviders;
|
||||
readonly starIcon = faStar;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly userIcon = faUser;
|
||||
readonly alertIcon = faExclamationCircle;
|
||||
readonly cogIcon = faCog;
|
||||
readonly statusIcon = faCircle;
|
||||
@Input() logo = false;
|
||||
@Input() loading = false;
|
||||
@Input() loadingError = false;
|
||||
@Input() account?: AccountData;
|
||||
@Output() signOut = new EventEmitter();
|
||||
@Output() signIn = new EventEmitter<OAuthProvider>();
|
||||
constructor(private model: Model, private settings: SettingsService) {
|
||||
}
|
||||
get hasSupporterIcon() {
|
||||
return isSupporterOrPastSupporter(this.account);
|
||||
}
|
||||
get supporterTitle() {
|
||||
return supporterTitle(this.account);
|
||||
}
|
||||
get supporterClass() {
|
||||
return supporterClass(this.account);
|
||||
}
|
||||
get showAccountAlert() {
|
||||
return this.model.missingBirthdate && REQUEST_DATE_OF_BIRTH;
|
||||
}
|
||||
get hidden() {
|
||||
return !!this.settings.account.hidden;
|
||||
}
|
||||
icon(id: string) {
|
||||
return getProviderIcon(id);
|
||||
}
|
||||
signInTo(provider: OAuthProvider) {
|
||||
this.signIn.emit(provider);
|
||||
}
|
||||
@HostListener('window:resize')
|
||||
resize() {
|
||||
}
|
||||
setStatus(status: string) {
|
||||
this.settings.account.hidden = status === 'invisible';
|
||||
this.settings.saveAccountSettings(this.settings.account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.navbar-link(routerLinkActive="active" [routerLinkActiveOptions]="{ exact: route === '/' }")
|
||||
a([routerLink]="route" tabindex linkCurrent)
|
||||
.d-none.d-sm-inline {{name}}
|
||||
fa-icon.d-inline-block.d-sm-none(
|
||||
[icon]="icon" [title]="name" [attr.aria-label]="name" [fixedWidth]="true" size="lg")
|
||||
@@ -0,0 +1,26 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.navbar-link {
|
||||
border-top: solid $navbar-link-border transparent;
|
||||
margin-top: -$navbar-link-border;
|
||||
margin-right: 5px;
|
||||
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
border-top-color: $navbar-dark-active-color;
|
||||
border-right-color: $navbar-dark-active-color;
|
||||
}
|
||||
|
||||
&:hover, &:active, &:link {
|
||||
text-decoration: none;
|
||||
border-top-color: $navbar-dark-active-color;
|
||||
border-right-color: $navbar-dark-active-color;
|
||||
}
|
||||
|
||||
> a {
|
||||
@media (max-width: 360px) {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { emptyIcon } from '../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'menu-item',
|
||||
templateUrl: 'menu-item.pug',
|
||||
styleUrls: ['menu-item.scss'],
|
||||
})
|
||||
export class MenuItem {
|
||||
@Input() route: any;
|
||||
@Input() name?: string;
|
||||
@Input() icon = emptyIcon;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
ng-template(#notePopover)
|
||||
textarea.form-control.pony-mod-note-editor(
|
||||
cols="20" rows="8" agAutoFocus [(ngModel)]="note" (keydown.escape)="blur()" (blur)="blur()")
|
||||
|
||||
ng-template(#tooltip)
|
||||
.text-left.text-pre
|
||||
div(*ngIf="hasCounters")
|
||||
span.text-muted spam:
|
||||
span.ml-1 {{counters?.spam || 0}}
|
||||
span.text-muted.ml-1 swearing:
|
||||
span.ml-1 {{counters?.swears || 0}}
|
||||
span.text-muted.ml-1 timeouts:
|
||||
span.ml-1 {{counters?.timeouts || 0}}
|
||||
div {{note}}
|
||||
|
||||
.btn-group.btn-group-xs.btn-group-shadow(*ngIf="check?.xcz?.vdw?.qwe?.mnb")
|
||||
.btn-group.dropdown(dropdown)
|
||||
button.btn.btn-xs.btn-default(dropdownToggle)
|
||||
fa-icon([icon]="moreIcon")
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
a.dropdown-item((click)="report()")
|
||||
fa-icon.mr-1([icon]="flagIcon" [fixedWidth]="true")
|
||||
| Report
|
||||
h6.dropdown-header Other options
|
||||
a.dropdown-item(*ngFor="let a of check.actions" (click)="modAction(a.action)")
|
||||
fa-icon.mr-1.text-danger([icon]="dangerIcon" [fixedWidth]="true")
|
||||
| {{a.name}}
|
||||
|
||||
.btn-group(
|
||||
[tooltip]="isNoteOpen ? null : tooltip" [isDisabled]="!note && !hasCounters"
|
||||
placement="bottom" containerClass="tooltip-notes")
|
||||
button.btn.btn-xs(
|
||||
[popover]="notePopover"
|
||||
placement="bottom"
|
||||
[isOpen]="isNoteOpen"
|
||||
[btnHighlightDanger]="!!note"
|
||||
[class.pointer-none]="isNoteOpen"
|
||||
(onShown)="isNoteOpen = true"
|
||||
(onHidden)="blur()"
|
||||
container="body")
|
||||
fa-icon([icon]="noteIcon")
|
||||
|
||||
.btn-group.dropdown(dropdown [tooltip]="muteTooltip")
|
||||
button.btn.btn-xs(dropdownToggle [ngClass]="className(mute)")
|
||||
fa-icon([icon]="muteIcon")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="setMute(0)")
|
||||
| clear #[b mute]
|
||||
a.dropdown-item((click)="setMute(-1)")
|
||||
| perma #[b mute]
|
||||
.dropdown-divider
|
||||
a.dropdown-item(*ngFor="let t of timeouts" (click)="setMute(t.value)")
|
||||
| {{t.label}}
|
||||
|
||||
.btn-group.dropdown(dropdown [tooltip]="shadowTooltip")
|
||||
button.btn.btn-xs(dropdownToggle [ngClass]="className(shadow)")
|
||||
fa-icon([icon]="hideIcon")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="setShadow(0)")
|
||||
| clear #[b shadow]
|
||||
a.dropdown-item((click)="setShadow(-1)")
|
||||
| perma #[b shadow]
|
||||
.dropdown-divider
|
||||
a.dropdown-item(*ngFor="let t of timeouts" (click)="setShadow(t.value)")
|
||||
| {{t.label}}
|
||||
|
||||
.pony-mod-age([title]="ageTitle")
|
||||
| {{ageLabel}}
|
||||
|
||||
.pony-mod-account(*ngIf="account")
|
||||
| {{account}}
|
||||
span.text-smuted.ml-1 [{{country || '??'}}]
|
||||
@@ -0,0 +1,38 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.pony-mod-account {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
bottom: -22px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
color: white;
|
||||
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pony-mod-age {
|
||||
position: absolute;
|
||||
left: -25px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: $primary;
|
||||
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.pony-mod-note-editor {
|
||||
color: #333;
|
||||
background: none;
|
||||
margin: -9px -14px;
|
||||
width: 250px;
|
||||
max-width: 250px;
|
||||
max-height: 140px;
|
||||
font-size: smaller;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { ModAction, Pony } from '../../../common/interfaces';
|
||||
import { TIMEOUTS } from '../../../common/constants';
|
||||
import { Model } from '../../services/model';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { faFlag, faStickyNote, faMicrophoneSlash, faEyeSlash, faUserCog, faExclamationCircle } from '../../../client/icons';
|
||||
|
||||
const ageLabels = ['', 'M', 'A', '', '', '[M]', '[A]'];
|
||||
const ageTitles = ['Not set', 'Minor', 'Adult', '', '', 'Minor (locked)', 'Adult (locked)'];
|
||||
|
||||
@Component({
|
||||
selector: 'mod-box',
|
||||
templateUrl: 'mod-box.pug',
|
||||
styleUrls: ['mod-box.scss'],
|
||||
})
|
||||
export class ModBox implements OnDestroy {
|
||||
readonly flagIcon = faFlag;
|
||||
readonly noteIcon = faStickyNote;
|
||||
readonly muteIcon = faMicrophoneSlash;
|
||||
readonly hideIcon = faEyeSlash;
|
||||
readonly moreIcon = faUserCog;
|
||||
readonly dangerIcon = faExclamationCircle;
|
||||
readonly timeouts = TIMEOUTS;
|
||||
@Input() pony!: Pony;
|
||||
isNoteOpen = false;
|
||||
constructor(private model: Model, private game: PonyTownGame) {
|
||||
}
|
||||
get ageLabel() {
|
||||
return ageLabels[this.modInfo && this.modInfo.age || 0];
|
||||
}
|
||||
get ageTitle() {
|
||||
return ageTitles[this.modInfo && this.modInfo.age || 0];
|
||||
}
|
||||
get modInfo() {
|
||||
return this.pony.modInfo;
|
||||
}
|
||||
get account() {
|
||||
return this.modInfo && this.modInfo.account;
|
||||
}
|
||||
get country() {
|
||||
return this.modInfo && this.modInfo.country;
|
||||
}
|
||||
get mute() {
|
||||
return this.modInfo && this.modInfo.mute;
|
||||
}
|
||||
get muteTooltip() {
|
||||
return this.mute ? (this.mute === 'perma' ? 'Permanently Muted' : `Muted for ${this.mute}`) : 'Mute';
|
||||
}
|
||||
get shadow() {
|
||||
return this.modInfo && this.modInfo.shadow;
|
||||
}
|
||||
get shadowTooltip() {
|
||||
return this.shadow ? (this.shadow === 'perma' ? 'Permanently Shadowed' : `Shadowed for ${this.shadow}`) : 'Shadow';
|
||||
}
|
||||
get counters() {
|
||||
return this.modInfo && this.modInfo.counters;
|
||||
}
|
||||
get hasCounters() {
|
||||
const counters = this.counters;
|
||||
return counters && (counters.spam || counters.swears || counters.timeouts);
|
||||
}
|
||||
get check() {
|
||||
return this.model.modCheck;
|
||||
}
|
||||
get note() {
|
||||
return this.modInfo && this.modInfo.note;
|
||||
}
|
||||
set note(value) {
|
||||
if (this.modInfo) {
|
||||
this.modInfo.note = value;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
if (this.isNoteOpen) {
|
||||
this.blur();
|
||||
}
|
||||
}
|
||||
className(value: string) {
|
||||
return value ? (value === 'perma' ? 'btn-danger' : 'btn-warning') : 'btn-default';
|
||||
}
|
||||
report() {
|
||||
this.modAction(ModAction.Report);
|
||||
}
|
||||
setMute(value: number) {
|
||||
this.modAction(ModAction.Mute, value);
|
||||
}
|
||||
setShadow(value: number) {
|
||||
this.modAction(ModAction.Shadow, value);
|
||||
}
|
||||
blur() {
|
||||
this.game.send(server => server.setNote(this.pony.id, this.modInfo && this.modInfo.note || ''));
|
||||
this.isNoteOpen = false;
|
||||
}
|
||||
modAction(type: ModAction, param = 0) {
|
||||
return this.game.send(server => server.otherAction(this.pony.id, type, param));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
ng-template(#popover)
|
||||
.notification-popover
|
||||
p([innerHTML]="notification.message")
|
||||
.small.text-muted.mb-1(*ngIf="notification.note")
|
||||
| {{notification.note}}
|
||||
div
|
||||
button.btn.btn-xs.btn-outline-danger.float-right(
|
||||
*ngIf="ignoreButton" (click)="ignore()" tooltip="Ignore this player")
|
||||
fa-icon([icon]="banIcon")
|
||||
.notification-buttons
|
||||
button.btn.btn-xs.btn-success(*ngIf="okButton" (click)="accept()") ok
|
||||
button.btn.btn-xs.btn-success(*ngIf="yesButton" (click)="accept()") yes
|
||||
button.btn.btn-xs.btn-success(*ngIf="acceptButton" (click)="accept()") accept
|
||||
button.btn.btn-xs.btn-danger(*ngIf="noButton" (click)="reject()") no
|
||||
button.btn.btn-xs.btn-danger(*ngIf="rejectButton" (click)="reject()") reject
|
||||
|
||||
.notification.cursor-pointer(
|
||||
[popover]="popover" [isOpen]="isOpen" (contextmenu)="reject(); $event.preventDefault()"
|
||||
placement="left" containerClass="popover-notification")
|
||||
portrait-box([pony]="paletteInfo" [flip]="true" size="small")
|
||||
@@ -0,0 +1,3 @@
|
||||
.notification {
|
||||
padding: 5px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { PlayerAction, Notification, NotificationFlags, EntityPlayerState } from '../../../common/interfaces';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { hasFlag, setFlag } from '../../../common/utils';
|
||||
import { faBan } from '../../../client/icons';
|
||||
import { getPaletteInfo } from '../../../common/pony';
|
||||
|
||||
@Component({
|
||||
selector: 'notification-item',
|
||||
templateUrl: 'notification-item.pug',
|
||||
styleUrls: ['notification-item.scss'],
|
||||
})
|
||||
export class NotificationItem implements OnDestroy {
|
||||
readonly banIcon = faBan;
|
||||
@Input() notification!: Notification;
|
||||
constructor(private game: PonyTownGame) {
|
||||
}
|
||||
get isOpen() {
|
||||
return this.notification.open;
|
||||
}
|
||||
set isOpen(value: boolean) {
|
||||
if (value) {
|
||||
this.game.notifications.forEach(n => n.open = false);
|
||||
}
|
||||
|
||||
this.notification.open = value;
|
||||
}
|
||||
get okButton() {
|
||||
return hasFlag(this.notification.flags, NotificationFlags.Ok);
|
||||
}
|
||||
get yesButton() {
|
||||
return hasFlag(this.notification.flags, NotificationFlags.Yes);
|
||||
}
|
||||
get acceptButton() {
|
||||
return hasFlag(this.notification.flags, NotificationFlags.Accept);
|
||||
}
|
||||
get noButton() {
|
||||
return hasFlag(this.notification.flags, NotificationFlags.No);
|
||||
}
|
||||
get rejectButton() {
|
||||
return hasFlag(this.notification.flags, NotificationFlags.Reject);
|
||||
}
|
||||
get ignoreButton() {
|
||||
return hasFlag(this.notification.flags, NotificationFlags.Ignore);
|
||||
}
|
||||
get paletteInfo() {
|
||||
return getPaletteInfo(this.notification.pony);
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
accept() {
|
||||
this.game.send(server => server.acceptNotification(this.notification.id));
|
||||
}
|
||||
reject() {
|
||||
this.game.send(server => server.rejectNotification(this.notification.id));
|
||||
}
|
||||
ignore() {
|
||||
this.reject();
|
||||
|
||||
const pony = this.notification.pony;
|
||||
|
||||
if (pony !== this.game.player) {
|
||||
this.game.send(server => server.playerAction(pony.id, PlayerAction.Ignore, undefined));
|
||||
pony.playerState = setFlag(pony.playerState, EntityPlayerState.Ignored, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.notification-list
|
||||
.game-button.notification-ellipsis(*ngIf="start" (click)="prev()")
|
||||
fa-icon([icon]="ellipsisIcon")
|
||||
notification-item(
|
||||
*ngFor="let n of notifications | slice:start:limit" [notification]="n" [class.elastic-from-right]="n.fresh")
|
||||
.game-button.notification-ellipsis(*ngIf="hasMore" (click)="next()")
|
||||
fa-icon([icon]="ellipsisIcon")
|
||||
@@ -0,0 +1,4 @@
|
||||
.notification-ellipsis {
|
||||
text-align: center;
|
||||
margin-right: -2px;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Notification } from '../../../common/interfaces';
|
||||
import { faEllipsisV } from '../../../client/icons';
|
||||
|
||||
const LIMIT = 8;
|
||||
|
||||
@Component({
|
||||
selector: 'notification-list',
|
||||
templateUrl: 'notification-list.pug',
|
||||
styleUrls: ['notification-list.scss'],
|
||||
})
|
||||
export class NotificationList {
|
||||
readonly ellipsisIcon = faEllipsisV;
|
||||
@Input() notifications!: Notification[];
|
||||
@Input() set notificationsLength(value: number) {
|
||||
while (this.start > value) {
|
||||
this.prev();
|
||||
}
|
||||
}
|
||||
start = 0;
|
||||
get limit() {
|
||||
return this.start + LIMIT;
|
||||
}
|
||||
get hasMore() {
|
||||
return this.notifications.length > (this.start + this.limit);
|
||||
}
|
||||
next() {
|
||||
this.start += this.limit;
|
||||
}
|
||||
prev() {
|
||||
this.start -= this.start <= LIMIT ? LIMIT : LIMIT - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.page-loader.text-muted.text-center.text-large(*ngIf="loading || updating")
|
||||
fa-icon([icon]="spinnerIcon" [fixedWidth]="true" [spin]="true")
|
||||
|
||||
div(*ngIf="updating")
|
||||
p.page-updating.text-muted Updating...
|
||||
p.text-unsafe(*ngIf="updatingTakesLongTime")
|
||||
| Updating is taking longer than expected, #[button.btn.btn-outline-danger((click)="reload()") restart]
|
||||
|
||||
div(*ngIf="loadingError && !updating")
|
||||
div([ngSwitch]="loadingError")
|
||||
p.text-unsafe(*ngSwitchCase="'request-limit'")
|
||||
| Server is under heavy load, please wait...
|
||||
p.text-unsafe(*ngSwitchCase="'cannot-connect'")
|
||||
| Cannot connect to the server, retrying...
|
||||
p.text-unsafe(*ngSwitchCase="'cloudflare-error'")
|
||||
| Cloudflare protection error, #[button.btn.btn-outline-danger((click)="reload()") reload] to continue.
|
||||
p.text-unsafe(*ngSwitchDefault)
|
||||
| Unexpected error occurred, #[button.btn.btn-outline-danger((click)="reload()") reload] to continue.
|
||||
@@ -0,0 +1,20 @@
|
||||
.page-loader {
|
||||
padding: 150px 0;
|
||||
}
|
||||
|
||||
fa-icon {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.page-updating {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
vertical-align: baseline;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { faSpinner } from '../../../client/icons';
|
||||
import { Model } from '../../services/model';
|
||||
import { hardReload } from '../../../client/clientUtils';
|
||||
|
||||
@Component({
|
||||
selector: 'page-loader',
|
||||
templateUrl: 'page-loader.pug',
|
||||
styleUrls: ['page-loader.scss'],
|
||||
})
|
||||
export class PageLoader {
|
||||
readonly spinnerIcon = faSpinner;
|
||||
constructor(private model: Model) {
|
||||
}
|
||||
get loading() {
|
||||
return this.model.loading;
|
||||
}
|
||||
get updating() {
|
||||
return this.model.updating;
|
||||
}
|
||||
get updatingTakesLongTime() {
|
||||
return this.model.updatingTakesLongTime;
|
||||
}
|
||||
get loadingError() {
|
||||
return this.model.loadingError;
|
||||
}
|
||||
reload() {
|
||||
hardReload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.party-box([class.pending]="member.pending" [class.offline]="member.offline" (window:resize)="true")
|
||||
portrait-box.cursor-pointer([pony]="paletteInfo" size="small" (click)="click()")
|
||||
.party-box-icon.party-box-leader.pointer-none(*ngIf="member.leader" title="Party leader")
|
||||
fa-icon([icon]="leaderIcon" size="xs")
|
||||
.party-box-icon.party-box-offline.pointer-none(*ngIf="member.offline" title="Offline")
|
||||
fa-icon([icon]="offlineIcon" size="sm")
|
||||
@@ -0,0 +1,33 @@
|
||||
.party-box {
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
|
||||
&.pending {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.offline {
|
||||
opacity: 0.7;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.party-box-icon {
|
||||
position: absolute;
|
||||
text-shadow: 0 0 5px #000;
|
||||
font-size: 18px;
|
||||
|
||||
> fa-icon {
|
||||
filter: drop-shadow(0 0 5px #000);
|
||||
}
|
||||
}
|
||||
|
||||
.party-box-leader {
|
||||
left: -5px;
|
||||
top: -14px;
|
||||
}
|
||||
|
||||
.party-box-offline {
|
||||
left: -2px;
|
||||
bottom: -8px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { PartyMember } from '../../../common/interfaces';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { partyLeaderIcon, offlineIcon } from '../../../client/icons';
|
||||
import { getPaletteInfo } from '../../../common/pony';
|
||||
|
||||
@Component({
|
||||
selector: 'party-box',
|
||||
templateUrl: 'party-box.pug',
|
||||
styleUrls: ['party-box.scss'],
|
||||
})
|
||||
export class PartyBox {
|
||||
readonly leaderIcon = partyLeaderIcon;
|
||||
readonly offlineIcon = offlineIcon;
|
||||
@Input() member!: PartyMember;
|
||||
constructor(private game: PonyTownGame) {
|
||||
}
|
||||
get paletteInfo() {
|
||||
return this.member.pony && getPaletteInfo(this.member.pony);
|
||||
}
|
||||
click() {
|
||||
this.game.select(this.member.pony);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.party-list.unselectable(*ngIf="hasParty")
|
||||
.dropdown(dropdown [hookToCanvas]="true")
|
||||
a.game-button.party-list-options(dropdownToggle)
|
||||
fa-icon(*ngIf="!isLeader" [icon]="cogIcon" [fixedWidth]="true" title="Party options")
|
||||
fa-icon(*ngIf="isLeader" [icon]="leaderIcon" [fixedWidth]="true" title="You are party leader")
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
.dropdown-header(*ngIf="isLeader")
|
||||
| You are party leader
|
||||
a.dropdown-item((click)="leave()")
|
||||
| Leave party
|
||||
a.dropdown-item((click)="hidden = !hidden")
|
||||
| {{hidden ? 'Show party' : 'Hide party'}}
|
||||
|
||||
.party-list-items.pt-1(*ngIf="!hidden")
|
||||
.game-button.party-list-ellipsis.mb-1(*ngIf="start" (click)="prev()")
|
||||
fa-icon([icon]="ellipsisIcon")
|
||||
div(*ngFor="let m of members | slice:start:limit")
|
||||
party-box([member]="m")
|
||||
.game-button.party-list-ellipsis(*ngIf="hasMore" (click)="next()" style="margin-top: -5px")
|
||||
fa-icon([icon]="ellipsisIcon")
|
||||
@@ -0,0 +1,18 @@
|
||||
.party-list {
|
||||
position: relative;
|
||||
|
||||
@media (max-height: 180px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.party-list-ellipsis {
|
||||
margin-top: -4px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.party-list-items {
|
||||
@media (max-height: 260px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Component, HostListener, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { PartyMember } from '../../../common/interfaces';
|
||||
import { PARTY_LIMIT } from '../../../common/constants';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import { partyLeaderIcon, faCog, faEllipsisV } from '../../../client/icons';
|
||||
import { clamp } from '../../../common/utils';
|
||||
import { isPartyLeader } from '../../../client/partyUtils';
|
||||
|
||||
function visibleMembers(members: PartyMember[], max: number, start: number) {
|
||||
return members.length > max ? Math.max(max - (start > 0 ? 2 : 1), 1) : max;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'party-list',
|
||||
templateUrl: 'party-list.pug',
|
||||
styleUrls: ['party-list.scss'],
|
||||
})
|
||||
export class PartyList implements OnInit, OnDestroy {
|
||||
readonly ellipsisIcon = faEllipsisV;
|
||||
readonly leaderIcon = partyLeaderIcon;
|
||||
readonly cogIcon = faCog;
|
||||
hidden = false;
|
||||
start = 0;
|
||||
maxMembers = PARTY_LIMIT - 1;
|
||||
members: PartyMember[] = [];
|
||||
private subscription?: Subscription;
|
||||
constructor(private game: PonyTownGame) {
|
||||
}
|
||||
get hasParty() {
|
||||
return this.game.party !== undefined;
|
||||
}
|
||||
get isLeader() {
|
||||
return isPartyLeader(this.game);
|
||||
}
|
||||
get hasMore() {
|
||||
return this.members.length > (this.start + this.visible);
|
||||
}
|
||||
get visible() {
|
||||
return visibleMembers(this.members, this.maxMembers, this.start);
|
||||
}
|
||||
get limit() {
|
||||
return this.start + this.visible;
|
||||
}
|
||||
ngOnInit() {
|
||||
this.subscription = this.game.onPartyUpdate.subscribe(() => this.update());
|
||||
this.resized();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
isMe(member: PartyMember) {
|
||||
return this.game.player && this.game.player.id === member.id;
|
||||
}
|
||||
leave() {
|
||||
this.game.send(server => server.leaveParty());
|
||||
}
|
||||
update() {
|
||||
if (this.hasParty) {
|
||||
this.members = this.game.party ? this.game.party.members.filter(m => !m.self) : [];
|
||||
|
||||
while (this.start > 0 && this.members.length <= this.start) {
|
||||
this.start = 0;
|
||||
}
|
||||
} else {
|
||||
this.members = [];
|
||||
this.start = 0;
|
||||
}
|
||||
}
|
||||
@HostListener('window:resize')
|
||||
resized() {
|
||||
const padding = 140 + 110;
|
||||
const max = clamp(Math.floor((window.innerHeight - padding) / 43), 0, PARTY_LIMIT - 1);
|
||||
|
||||
if (this.maxMembers !== max) {
|
||||
this.start = 0;
|
||||
this.maxMembers = max;
|
||||
}
|
||||
}
|
||||
next() {
|
||||
this.start += this.visible;
|
||||
}
|
||||
prev() {
|
||||
const max = this.members.length - 1;
|
||||
let start = 0;
|
||||
|
||||
while (start < max && (start + visibleMembers(this.members, this.maxMembers, start)) !== this.start) {
|
||||
start++;
|
||||
}
|
||||
|
||||
this.start = start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'siteName',
|
||||
})
|
||||
export class SiteNamePipe implements PipeTransform {
|
||||
transform(value: string | undefined) {
|
||||
const match = String(value || '').match(/(\w+)\.com/);
|
||||
return match && match[1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
.form-group(*ngIf="hasTooManyPonies")
|
||||
.alert.alert-warning(role="alert")
|
||||
| You have more than the allowed number of {{characterLimit}} ponies, the additional ones may get deleted.
|
||||
| Remove some of your unused ponies to prevent losing the ones you want to keep.
|
||||
|
||||
.form-group(*ngIf="isMarkedForMultiples")
|
||||
.alert.alert-warning(role="alert")
|
||||
| Your account has been flagged for creating multiple accounts.
|
||||
| Creating more accounts may result in a permanent ban.
|
||||
|
||||
.form-group(*ngIf="accountAlert")
|
||||
.alert.alert-warning(role="alert")
|
||||
| {{accountAlert}}
|
||||
|
||||
.form-group(*ngIf="leftMessage")
|
||||
.alert.alert-warning(role="alert")
|
||||
| {{leftMessage}}
|
||||
|
||||
.form-group(*ngIf="isAndroidBrowser")
|
||||
.alert.alert-warning(role="alert")
|
||||
| Your browser is outdated and is not able to correctly run Pony Town.
|
||||
| Please install different browser to be able to play the game.
|
||||
|
||||
.form-group(*ngIf="model.missingBirthdate && !birthdateSet && requestBirthdate")
|
||||
.alert.alert-warning.text-left(role="alert")
|
||||
label(for="birthdate")
|
||||
| Set your date of birth
|
||||
date-picker([(date)]="birthdate")
|
||||
.mt-2.text-right
|
||||
button.btn.btn-default.px-4((click)="saveBirthdate()") Save
|
||||
p.mb-0.mt-2
|
||||
| Please fill-in your #[b date of birth] in order to not lose access to the game in future updates.
|
||||
|
||||
.form-group.dropdown(dropdown)
|
||||
.btn-group.d-flex
|
||||
button.btn.btn-lg.btn-success.text-ellipsis.flex-grow-1(
|
||||
#playButton (click)="joining ? cancel() : play()" [disabled]="!canPlay && !joining")
|
||||
div(*ngIf="joining")
|
||||
fa-icon.mr-1([icon]="spinnerIcon" [spin]="true")
|
||||
| Cancel
|
||||
div(*ngIf="!joining && server")
|
||||
| #[strong {{label || 'Play'}}] on #[span {{server.name}}]
|
||||
.text-faded(*ngIf="!joining && !server")
|
||||
| select server to play
|
||||
button.btn.btn-lg.btn-success.flex-grow-0.dropdown-toggle(
|
||||
dropdownToggle aria-label="select server" [disabled]="joining" style="position: relative")
|
||||
|
||||
.dropdown-menu.w-100(*dropdownMenu style="overflow: hidden")
|
||||
button.dropdown-item(*ngFor="let s of servers" (click)="server = s; playButton.focus()")
|
||||
div
|
||||
.float-right(style="position: relative;")
|
||||
.text-unsafe(*ngIf="s.offline") #[span.sr-only server] offline
|
||||
.text-muted(*ngIf="!s.offline") online #[span.sr-only players] ({{s.online}})
|
||||
.flag.mr-2(*ngFor="let f of s.countryFlags" [ngClass]="'flag-' + f")
|
||||
fa-icon.text-muted.mr-2(*ngIf="!hasFlag(s)" [icon]="getIcon(s)" size="lg")
|
||||
strong {{s.name}}
|
||||
.text-muted.text-wrap {{s.desc}}
|
||||
|
||||
.form-group.text-left.text-muted.server-alert(*ngIf="server?.alert === '18+'")
|
||||
fa-icon.float-left.p-2([icon]="warningIcon" size="2x")
|
||||
| By playing on this server you confirm that you are over 18 years old and you take no issue
|
||||
| with seeing adult topics.
|
||||
|
||||
.form-group.text-left.text-info.server-alert(*ngIf="server?.alert === 'test'")
|
||||
fa-icon.float-left.p-2([icon]="infoIcon" size="2x")
|
||||
| Supporter test server: Here you can try experimental and unfinished features that we're working on.
|
||||
| Keep in mind everything is subject to change.
|
||||
|
||||
.form-group(*ngIf="server?.offline")
|
||||
.alert.alert-info(role="alert")
|
||||
| Selected server is offline, try again later
|
||||
|
||||
.form-group(*ngIf="offline")
|
||||
.alert.alert-info(role="alert")
|
||||
| Server is offline, try again later
|
||||
|
||||
.form-group(*ngIf="protectionError && !offline")
|
||||
.alert.alert-info(role="alert")
|
||||
| Cloudflare error, #[button.btn.btn-sm.btn-outline-default((click)="reload()") reload] to continue.
|
||||
|
||||
.form-group(*ngIf="updateWarning")
|
||||
.alert.alert-warning(role="alert")
|
||||
| Server will restart shortly for updates and maintenance.
|
||||
| Save your character to avoid losing any progress.
|
||||
|
||||
.form-group(*ngIf="isBrowserOutdated")
|
||||
.alert.alert-warning(role="alert")
|
||||
button.close.float-right((click)="dismissOutdatedBrowser()" aria-label="Close" style="font-size: 20px;") ×
|
||||
| Your browser is outdated and is known to have issues running Pony Town.
|
||||
| Make sure you have latest version installed.
|
||||
|
||||
.form-group(*ngIf="invalidVersion && !offline")
|
||||
.alert.alert-danger(role="alert")
|
||||
| Your client version is outdated, #[button.btn.btn-sm.btn-outline-default((click)="reload()") reload] to be able to play.
|
||||
|
||||
.form-group(*ngIf="isAccessError")
|
||||
.alert.alert-danger(role="alert")
|
||||
| You're no longer signed-in, #[button.btn.btn-sm.btn-outline-default((click)="reload()") reload] to be able to sign-in again.
|
||||
|
||||
.form-group(*ngIf="failedToLoadImages")
|
||||
.alert.alert-danger(role="alert")
|
||||
| Failed to load game assets, #[button.btn.btn-sm.btn-outline-default((click)="hardReload()") reload] to retry.
|
||||
|
||||
.form-group(*ngIf="isWebGLError")
|
||||
.alert.alert-danger(role="alert")
|
||||
| Failed to create WebGL context. Your graphics card drivers or browser are outdated or graphics
|
||||
| acceleration is disabled. Go to #[a.alert-link(href="http://webglreport.com/" tabindex) WebGL Report] to
|
||||
| check WebGL support in your browser.
|
||||
|
||||
.form-group(*ngIf="isBrowserError")
|
||||
.alert.alert-danger(role="alert")
|
||||
| Your browser is outdated, make sure you have the latest version installed.
|
||||
|
||||
.form-group(*ngIf="isOtherError")
|
||||
.alert.alert-danger(role="alert")
|
||||
| {{error}}
|
||||
|
||||
.form-group.text-left.text-large(*ngIf="server")
|
||||
h5 Server rules
|
||||
p.text-muted.list-rules
|
||||
| {{server.desc}}
|
||||
@@ -0,0 +1,26 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.btn-group.d-flex {
|
||||
display: flex;
|
||||
|
||||
> .btn.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.server-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert .btn {
|
||||
vertical-align: baseline;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||
import { ServerInfo, AccountDataFlags } from '../../../common/interfaces';
|
||||
import { RequestError, delay, includes, hasFlag } from '../../../common/utils';
|
||||
import {
|
||||
WEBGL_CREATION_ERROR, ACCESS_ERROR, ACCOUNT_ERROR, BROWSER_NOT_SUPPORTED_ERROR, NAME_ERROR, VERSION_ERROR,
|
||||
OFFLINE_ERROR, PROTECTION_ERROR, NOT_AUTHENTICATED_ERROR, CHARACTER_LIMIT_ERROR
|
||||
} from '../../../common/errors';
|
||||
import { version } from '../../../client/data';
|
||||
import { GameService } from '../../services/gameService';
|
||||
import { Model } from '../../services/model';
|
||||
import { faSpinner, faExclamationCircle, faInfoCircle, faGlobe, faStar, faWrench } from '../../../client/icons';
|
||||
import { isBrowserOutdated, hardReload, isAndroidBrowser } from '../../../client/clientUtils';
|
||||
import { loadAndInitSpriteSheets } from '../../../client/spriteUtils';
|
||||
import { StorageService } from '../../services/storageService';
|
||||
import { ErrorReporter } from '../../services/errorReporter';
|
||||
import { REQUEST_DATE_OF_BIRTH } from '../../../common/constants';
|
||||
|
||||
const ignoredErrors = [
|
||||
WEBGL_CREATION_ERROR,
|
||||
BROWSER_NOT_SUPPORTED_ERROR,
|
||||
NAME_ERROR,
|
||||
OFFLINE_ERROR,
|
||||
VERSION_ERROR,
|
||||
ACCESS_ERROR,
|
||||
PROTECTION_ERROR,
|
||||
NOT_AUTHENTICATED_ERROR,
|
||||
CHARACTER_LIMIT_ERROR,
|
||||
'Saving in progress',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'play-box',
|
||||
templateUrl: 'play-box.pug',
|
||||
styleUrls: ['play-box.scss'],
|
||||
})
|
||||
export class PlayBox implements OnInit {
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly warningIcon = faExclamationCircle;
|
||||
readonly infoIcon = faInfoCircle;
|
||||
readonly requestBirthdate = REQUEST_DATE_OF_BIRTH;
|
||||
@Output() errorChange = new EventEmitter<string | undefined>();
|
||||
@Input() label?: string;
|
||||
joining = false;
|
||||
failedToLoadImages = false;
|
||||
birthdate = '';
|
||||
birthdateSet = false;
|
||||
private locked = false;
|
||||
constructor(
|
||||
public gameService: GameService,
|
||||
public model: Model,
|
||||
private storage: StorageService,
|
||||
private errorReporter: ErrorReporter,
|
||||
) {
|
||||
}
|
||||
@Input()
|
||||
get error() {
|
||||
return this.gameService.error;
|
||||
}
|
||||
set error(value: string | undefined) {
|
||||
if (this.gameService) {
|
||||
this.gameService.error = value;
|
||||
this.errorChange.emit(value);
|
||||
}
|
||||
}
|
||||
get server() {
|
||||
return this.gameService.server;
|
||||
}
|
||||
set server(value: ServerInfo | undefined) {
|
||||
this.gameService.server = value;
|
||||
}
|
||||
get servers() {
|
||||
return this.gameService.servers;
|
||||
}
|
||||
get offline(): boolean {
|
||||
return this.gameService.offline;
|
||||
}
|
||||
get updateWarning() {
|
||||
return this.gameService.updateWarning;
|
||||
}
|
||||
get invalidVersion(): boolean {
|
||||
return !!(this.gameService.versionError || this.error === VERSION_ERROR
|
||||
|| (this.gameService.version && this.gameService.version !== version));
|
||||
}
|
||||
get protectionError(): boolean {
|
||||
return this.gameService.protectionError || this.error === PROTECTION_ERROR;
|
||||
}
|
||||
get canPlay(): boolean {
|
||||
return !!this.server && this.gameService.canPlay && !this.locked && !this.invalidVersion && !this.failedToLoadImages;
|
||||
}
|
||||
get isAccessError(): boolean {
|
||||
return this.error === ACCESS_ERROR || this.error === ACCOUNT_ERROR;
|
||||
}
|
||||
get isWebGLError(): boolean {
|
||||
return this.error === WEBGL_CREATION_ERROR;
|
||||
}
|
||||
get isBrowserError(): boolean {
|
||||
return this.error === BROWSER_NOT_SUPPORTED_ERROR;
|
||||
}
|
||||
get isOtherError(): boolean {
|
||||
return !!this.error && !this.invalidVersion && !this.isAccessError && !this.isWebGLError && !this.isBrowserError;
|
||||
}
|
||||
get ponyLimit() {
|
||||
return this.model.characterLimit;
|
||||
}
|
||||
get hasTooManyPonies(): boolean {
|
||||
return this.model.ponies.length > this.ponyLimit;
|
||||
}
|
||||
get isMarkedForMultiples(): boolean {
|
||||
const account = this.model.account;
|
||||
return !!account && hasFlag(account.flags, AccountDataFlags.Duplicates);
|
||||
}
|
||||
get isAndroidBrowser() {
|
||||
return isAndroidBrowser;
|
||||
}
|
||||
get isBrowserOutdated() {
|
||||
return !isAndroidBrowser && isBrowserOutdated && !this.storage.getBoolean('dismiss-outdated-browser');
|
||||
}
|
||||
get leftMessage() {
|
||||
return this.gameService.leftMessage;
|
||||
}
|
||||
get accountAlert() {
|
||||
return this.model.accountAlert;
|
||||
}
|
||||
ngOnInit() {
|
||||
loadAndInitSpriteSheets()
|
||||
.then(loaded => this.failedToLoadImages = !loaded);
|
||||
}
|
||||
play() {
|
||||
if (this.canPlay) {
|
||||
this.joining = true;
|
||||
this.locked = true;
|
||||
this.error = undefined;
|
||||
|
||||
const delayTime = (!DEVELOPMENT && this.gameService.wasPlaying) ? 1500 : 10;
|
||||
|
||||
delay(delayTime) // delay joing if user reloaded the game instead of leaving cleanly
|
||||
.then(() => this.model.savePony(this.model.pony))
|
||||
.then(pony => this.joining ? this.gameService.join(pony.id) : Promise.resolve())
|
||||
.catch((e: RequestError) => {
|
||||
if (!/^Cancelled/.test(e.message)) {
|
||||
this.error = e.message;
|
||||
|
||||
if (!includes(ignoredErrors, e.message) && !/shader/.test(e.message)) {
|
||||
this.errorReporter.reportError(e, { status: e.status, text: e.text });
|
||||
}
|
||||
|
||||
DEVELOPMENT && console.error(e);
|
||||
}
|
||||
})
|
||||
.finally(() => this.joining = false)
|
||||
.then(() => delay(1500))
|
||||
.finally(() => this.locked = false);
|
||||
}
|
||||
}
|
||||
cancel() {
|
||||
this.gameService.leave('Cancelled joining');
|
||||
}
|
||||
reload() {
|
||||
location.reload(true);
|
||||
}
|
||||
hardReload() {
|
||||
hardReload();
|
||||
}
|
||||
hasFlag(server: ServerInfo) {
|
||||
return server.countryFlags && server.countryFlags.length;
|
||||
}
|
||||
getIcon(server: ServerInfo) {
|
||||
switch (server.flag) {
|
||||
case 'star': return faStar;
|
||||
case 'test': return faWrench;
|
||||
default: return faGlobe;
|
||||
}
|
||||
}
|
||||
dismissOutdatedBrowser() {
|
||||
this.storage.setBoolean('dismiss-outdated-browser', true);
|
||||
}
|
||||
saveBirthdate() {
|
||||
if (this.birthdate) {
|
||||
this.model.updateAccount({ birthdate: this.birthdate });
|
||||
this.birthdateSet = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.mx-auto.text-left.text-large(style="max-width: 400px;")
|
||||
h5 General rules
|
||||
ul.text-muted.list-rules
|
||||
li(*ngFor="let r of rules") {{r}}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { supporterLink } from '../../../client/data';
|
||||
import { GENERAL_RULES } from '../../../common/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'play-notice',
|
||||
templateUrl: 'play-notice.pug',
|
||||
})
|
||||
export class PlayNotice {
|
||||
readonly patreonLink = supporterLink;
|
||||
readonly rules = GENERAL_RULES;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.pony-box(*ngIf="pony")
|
||||
.pony-box-tags
|
||||
.pony-box-tag(*ngIf="special" [ngClass]="specialClass")
|
||||
| {{special}}
|
||||
.pony-box-tag.tag-friend(*ngIf="isFriend(pony)")
|
||||
| friend
|
||||
.pony-box-rect
|
||||
.pony-box-name
|
||||
| {{pony.name}}
|
||||
.pony-box-buttons
|
||||
site-info([site]="pony.site")
|
||||
|
||||
.pony-box-buttons-box
|
||||
.btn-group.btn-group-shadow.dropdown(dropdown (isOpenChange)="removingFriend = false")
|
||||
button.btn.btn-xs.dropdown-toggle(dropdownToggle [ngClass]="ignoredOrHidden ? 'btn-danger' : 'btn-default'")
|
||||
fa-icon([icon]="cogIcon")
|
||||
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
button.dropdown-item((click)="toggleIgnore()")
|
||||
fa-icon.mr-2([icon]="ignoreIcon" [fixedWidth]="true")
|
||||
| {{isIgnored(pony) ? 'Unignore player' : 'Ignore player'}}
|
||||
fa-icon.ml-1(*ngIf="isIgnored(pony)" [icon]="checkIcon")
|
||||
button.dropdown-item((click)="hidePlayer(1)")
|
||||
fa-icon.mr-2([icon]="hideIcon" [fixedWidth]="true")
|
||||
| Hide player #[span.text-muted (24 hours)]
|
||||
button.dropdown-item((click)="hidePlayer(0)")
|
||||
fa-icon.mr-2([icon]="hideIcon" [fixedWidth]="true")
|
||||
| Hide player #[span.text-muted (permanent)]
|
||||
|
||||
.dropdown-divider
|
||||
button.dropdown-item((click)="sendMessageTo()")
|
||||
fa-icon.mr-2([icon]="messageIcon" [fixedWidth]="true")
|
||||
| Send whisper
|
||||
button.dropdown-item(*ngIf="!isFriend(pony)" (click)="addFriend()")
|
||||
fa-icon.mr-2.text-success([icon]="addFriendIcon" [fixedWidth]="true")
|
||||
| Send friend request
|
||||
div(*ngIf="isFriend(pony)" style="position: relative;")
|
||||
button.dropdown-item((click)="removingFriend = true; $event.stopPropagation()")
|
||||
fa-icon.mr-2.text-danger([icon]="removeFriendIcon" [fixedWidth]="true")
|
||||
| Remove from friends
|
||||
.remove-confirmation.px-2.d-flex.justify-content-center(*ngIf="removingFriend")
|
||||
button.btn.btn-sm.btn-outline-success.flex-grow-1((click)="removingFriend = false; $event.stopPropagation()")
|
||||
| Cancel
|
||||
button.btn.btn-sm.btn-outline-danger.flex-grow-1.ml-1((click)="removeFriend()")
|
||||
| Confirm remove
|
||||
|
||||
div(*ngIf="canInviteToSupporterServers")
|
||||
h6.dropdown-header Supporter options
|
||||
button.dropdown-item((click)="inviteToSupporterServers()")
|
||||
fa-icon.mr-2.text-success([icon]="starIcon" [fixedWidth]="true")
|
||||
| Invite to supporter servers
|
||||
fa-icon.ml-1(*ngIf="isInvitedToSupporterServers" [icon]="checkIcon")
|
||||
|
||||
.btn-group.btn-group-shadow(*ngIf="canInviteToParty || canRemoveFromParty || canPromoteToLeader")
|
||||
button.btn.btn-xs.btn-default(*ngIf="canInviteToParty" (click)="inviteToParty()" tooltip="Invite to party")
|
||||
fa-icon([icon]="inviteIcon")
|
||||
button.btn.btn-xs.btn-default(*ngIf="canRemoveFromParty" (click)="removeFromParty()" tooltip="Remove from party")
|
||||
fa-icon([icon]="removeIcon")
|
||||
button.btn.btn-xs.btn-default(*ngIf="canPromoteToLeader" (click)="promoteToLeader()" tooltip="Promote to leader")
|
||||
fa-icon([icon]="leaderIcon")
|
||||
mod-box(*ngIf="isMod" [pony]="pony")
|
||||
|
||||
portrait-box.pony-box-avatar([pony]="paletteInfo")
|
||||
@@ -0,0 +1,113 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.pony-box {
|
||||
color: #333;
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.pony-box-tags {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pony-box-tag {
|
||||
background: #777;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
line-height: 14px;
|
||||
padding: 1px 10px 2px 10px;
|
||||
border-top-right-radius: 5px;
|
||||
border-top-left-radius: 5px;
|
||||
margin-left: 5px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 0;
|
||||
padding-left: 40px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.tag-dev {
|
||||
background: $dev;
|
||||
}
|
||||
|
||||
&.tag-mod {
|
||||
background: $mod;
|
||||
}
|
||||
|
||||
&.tag-sup1 {
|
||||
background: $supporter-1;
|
||||
}
|
||||
|
||||
&.tag-sup2 {
|
||||
background: darken($supporter-2, 7%);
|
||||
background: linear-gradient(to bottom, #ffcd99, #ffaa3b 10%, #c77305);
|
||||
color: #3b2f1a;
|
||||
}
|
||||
|
||||
&.tag-sup3 {
|
||||
background: darken($supporter-3, 7%);
|
||||
background: linear-gradient(to bottom, #fffda4, #ffde3b 10%, #fdbb0b);
|
||||
color: #49391c;
|
||||
}
|
||||
|
||||
&.tag-friend {
|
||||
background: $friends-color;
|
||||
}
|
||||
}
|
||||
|
||||
.pony-box-rect {
|
||||
background: white;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 50px;
|
||||
width: 250px;
|
||||
height: 60px;
|
||||
border-top-right-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
padding-left: 58px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.pony-box-name {
|
||||
font-size: 20px;
|
||||
padding-top: 3px;
|
||||
padding-right: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pony-box-buttons {
|
||||
padding-top: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pony-box-buttons-box {
|
||||
position: absolute;
|
||||
top: 65px;
|
||||
left: 48px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pony-box-avatar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.remove-confirmation {
|
||||
background: white;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { PlayerAction, Pony, EntityPlayerState, Entity } from '../../../common/interfaces';
|
||||
import { getPaletteInfo } from '../../../common/pony';
|
||||
import { Model } from '../../services/model';
|
||||
import { PonyTownGame } from '../../../client/game';
|
||||
import {
|
||||
partyLeaderIcon, faUserPlus, faUserTimes, faCheck, faMicrophoneSlash, faEyeSlash, faStar, faUserMinus,
|
||||
faUserCog, faComment
|
||||
} from '../../../client/icons';
|
||||
import { DAY } from '../../../common/constants';
|
||||
import { isPonyInParty, isPartyLeader } from '../../../client/partyUtils';
|
||||
import { getTag } from '../../../common/tags';
|
||||
import { isIgnored, isHidden, isFriend } from '../../../common/entityUtils';
|
||||
import { setFlag } from '../../../common/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'pony-box',
|
||||
templateUrl: 'pony-box.pug',
|
||||
styleUrls: ['pony-box.scss'],
|
||||
})
|
||||
export class PonyBox {
|
||||
readonly leaderIcon = partyLeaderIcon;
|
||||
readonly inviteIcon = faUserPlus;
|
||||
readonly removeIcon = faUserTimes;
|
||||
readonly cogIcon = faUserCog;
|
||||
readonly checkIcon = faCheck;
|
||||
readonly ignoreIcon = faMicrophoneSlash;
|
||||
readonly hideIcon = faEyeSlash;
|
||||
readonly starIcon = faStar;
|
||||
readonly addFriendIcon = faUserPlus;
|
||||
readonly removeFriendIcon = faUserMinus;
|
||||
readonly messageIcon = faComment;
|
||||
isIgnored = isIgnored;
|
||||
isFriend = isFriend;
|
||||
removingFriend = false;
|
||||
@Input() pony?: Pony;
|
||||
@Output() sendMessage = new EventEmitter<Entity>();
|
||||
constructor(private model: Model, private game: PonyTownGame) {
|
||||
}
|
||||
get ignoredOrHidden() {
|
||||
return this.pony && (isIgnored(this.pony) || isHidden(this.pony));
|
||||
}
|
||||
get isMod() {
|
||||
return this.model.isMod;
|
||||
}
|
||||
get canInviteToParty() {
|
||||
return this.pony && (!this.game.party || (isPartyLeader(this.game) && !isPonyInParty(this.game.party, this.pony, true)));
|
||||
}
|
||||
get canRemoveFromParty() {
|
||||
return this.pony && isPartyLeader(this.game) && isPonyInParty(this.game.party, this.pony, true);
|
||||
}
|
||||
get canPromoteToLeader() {
|
||||
return this.pony && isPartyLeader(this.game) && isPonyInParty(this.game.party, this.pony, false);
|
||||
}
|
||||
get special() {
|
||||
const tag = getTag(this.pony && this.pony.tag);
|
||||
return tag && tag.name;
|
||||
}
|
||||
get specialClass() {
|
||||
const tag = getTag(this.pony && this.pony.tag);
|
||||
return tag && tag.tagClass;
|
||||
}
|
||||
get paletteInfo() {
|
||||
return this.pony && getPaletteInfo(this.pony);
|
||||
}
|
||||
inviteToParty() {
|
||||
this.playerAction(PlayerAction.InviteToParty);
|
||||
}
|
||||
removeFromParty() {
|
||||
this.playerAction(PlayerAction.RemoveFromParty);
|
||||
}
|
||||
promoteToLeader() {
|
||||
this.playerAction(PlayerAction.PromotePartyLeader);
|
||||
}
|
||||
toggleIgnore() {
|
||||
if (this.pony) {
|
||||
const ignored = isIgnored(this.pony);
|
||||
this.playerAction(ignored ? PlayerAction.Unignore : PlayerAction.Ignore);
|
||||
this.pony.playerState = setFlag(this.pony.playerState, EntityPlayerState.Ignored, !ignored);
|
||||
}
|
||||
}
|
||||
hidePlayer(days: number) {
|
||||
this.playerAction(PlayerAction.HidePlayer, days * DAY);
|
||||
}
|
||||
addFriend() {
|
||||
this.playerAction(PlayerAction.AddFriend);
|
||||
}
|
||||
removeFriend() {
|
||||
this.playerAction(PlayerAction.RemoveFriend);
|
||||
}
|
||||
private playerAction(type: PlayerAction, param: any = undefined) {
|
||||
const ponyId = this.pony && this.pony.id;
|
||||
|
||||
if (ponyId) {
|
||||
this.game.send(server => server.playerAction(ponyId, type, param));
|
||||
}
|
||||
}
|
||||
sendMessageTo() {
|
||||
if (this.pony) {
|
||||
this.sendMessage.emit(this.pony);
|
||||
}
|
||||
}
|
||||
// supporter servers
|
||||
get canInviteToSupporterServers() {
|
||||
return false; // DEVELOPMENT; // TODO: check if ignored or hidden
|
||||
}
|
||||
get isInvitedToSupporterServers() {
|
||||
return false;
|
||||
}
|
||||
inviteToSupporterServers() {
|
||||
this.playerAction(PlayerAction.InviteToSupporterServers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.portrait-box([ngClass]="size")
|
||||
canvas(#canvas)
|
||||
.portrait-box-cover(*ngIf="!noBorder")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user