move code area to right on bigger screens

This commit is contained in:
undergroundwires
2021-02-22 16:46:06 +01:00
parent 1260eea690
commit cf39e6d254
25 changed files with 584 additions and 153 deletions

View File

@@ -31,6 +31,7 @@
"file-saver": "^2.0.5",
"inversify": "^5.0.5",
"liquor-tree": "^0.2.70",
"resize-observer-polyfill": "^1.5.1",
"v-tooltip": "2.0.2",
"vue": "^2.6.12",
"vue-class-component": "^7.2.6",

View File

@@ -3,8 +3,7 @@
<div class="wrapper">
<TheHeader class="row" />
<TheSearchBar class="row" />
<TheScripts class="row"/>
<TheCodeArea class="row" theme="xcode" />
<TheScriptArea class="row" />
<TheCodeButtons class="row code-buttons" />
<TheFooter />
</div>
@@ -15,17 +14,15 @@
import { Component, Vue } from 'vue-property-decorator';
import TheHeader from '@/presentation/TheHeader.vue';
import TheFooter from '@/presentation/TheFooter/TheFooter.vue';
import TheCodeArea from '@/presentation/TheCodeArea.vue';
import TheCodeButtons from '@/presentation/CodeButtons/TheCodeButtons.vue';
import TheCodeButtons from '@/presentation/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/TheSearchBar.vue';
import TheScripts from '@/presentation/Scripts/TheScripts.vue';
@Component({
components: {
TheHeader,
TheCodeArea,
TheCodeButtons,
TheScripts,
TheScriptArea,
TheSearchBar,
TheFooter,
},
@@ -38,6 +35,7 @@ export default class App extends Vue {
<style lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/media.scss";
* {
box-sizing: border-box;
@@ -49,12 +47,10 @@ body {
color: $slate;
}
#app {
margin-right: auto;
margin-left: auto;
max-width: 1500px;
max-width: 1600px;
.wrapper {
margin: 0% 2% 0% 2%;
background-color: white;
@@ -62,18 +58,15 @@ body {
padding: 2%;
display:flex;
flex-direction: column;
.row {
margin-bottom: 10px;
}
.code-buttons {
padding-bottom: 10px;
}
}
}
@import "@/presentation/styles/tooltip.scss";
@import "@/presentation/styles/tree.scss";
</style>

View File

@@ -7,7 +7,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faFolderOpen, faFolder, faSmile } from '@fortawesome/free-regular-svg-icons';
/** SOLID ICONS (PREFIX: fas (default)) */
import { faTimes, faFileDownload, faCopy, faSearch, faInfoCircle, faUserSecret, faDesktop,
faTag, faGlobe, faSave, faBatteryFull, faBatteryHalf, faPlay } from '@fortawesome/free-solid-svg-icons';
faTag, faGlobe, faSave, faBatteryFull, faBatteryHalf, faPlay, faArrowsAltH } from '@fortawesome/free-solid-svg-icons';
export class IconBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
@@ -26,7 +26,9 @@ export class IconBootstrapper implements IVueBootstrapper {
faPlay,
faSearch,
faBatteryFull, faBatteryHalf,
faInfoCircle);
faInfoCircle,
faArrowsAltH,
);
vue.component('font-awesome-icon', FontAwesomeIcon);
}
}

View File

@@ -1,10 +1,15 @@
<template>
<div :id="editorId" class="code-area" ></div>
<Responsive v-on:sizeChanged="sizeChanged()">
<div
:id="editorId"
class="code-area"
></div>
</Responsive>
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import { StatefulVue } from '@/presentation/StatefulVue';
import ace from 'ace-builds';
import 'ace-builds/webpack-resolver';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
@@ -12,8 +17,13 @@ import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
import Responsive from '@/presentation/Responsive.vue';
@Component
@Component({
components: {
Responsive,
},
})
export default class TheCodeArea extends StatefulVue {
public readonly editorId = 'codeEditor';
@@ -25,6 +35,11 @@ export default class TheCodeArea extends StatefulVue {
public destroyed() {
this.destroyEditor();
}
public sizeChanged() {
if (this.editor) {
this.editor.resize();
}
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.destroyEditor();
@@ -44,7 +59,6 @@ export default class TheCodeArea extends StatefulVue {
return;
}
this.editor.setValue(event.code, 1);
if (event.addedScripts && event.addedScripts.length) {
this.reactToChanges(event, event.addedScripts);
} else if (event.changedScripts && event.changedScripts.length) {
@@ -83,6 +97,7 @@ export default class TheCodeArea extends StatefulVue {
private destroyEditor() {
if (this.editor) {
this.editor.destroy();
this.editor = undefined;
}
}
}
@@ -95,6 +110,7 @@ function initializeEditor(theme: string, editorId: string, language: ScriptingLa
editor.setTheme(`ace/theme/${theme}`);
editor.setReadOnly(true);
editor.setAutoScrollEditorIntoView(true);
editor.setShowPrintMargin(false); // hides vertical line
editor.getSession().setUseWrapMode(true); // So code is readable on mobile
return editor;
}
@@ -129,17 +145,17 @@ function getDefaultCode(language: ScriptingLanguage): string {
</script>
<style lang="scss">
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
.code-area {
width: 100%;
max-height: 1000px;
::v-deep .code-area {
min-height: 200px;
width: 100%;
height: 100%;
overflow: auto;
&__highlight {
background-color:$accent;
background-color: $accent;
opacity: 0.2; // having procent fails in production (minified) build
position:absolute;
position: absolute;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div ref="containerElement" class="container">
<slot ref="containerElement"></slot>
</div>
</template>
<script lang="ts">
import { Component, Vue, Emit } from 'vue-property-decorator';
import ResizeObserver from 'resize-observer-polyfill';
import { throttle } from './Throttle';
@Component
export default class Responsive extends Vue {
private width: number;
private height: number;
private observer: ResizeObserver;
private get container(): HTMLElement { return this.$refs.containerElement as HTMLElement; }
public mounted() {
this.width = this.container.offsetWidth;
this.height = this.container.offsetHeight;
const resizeCallback = throttle(() => this.updateSize(), 200);
this.observer = new ResizeObserver(resizeCallback);
this.observer.observe(this.container);
this.fireChangeEvents();
}
public updateSize() {
let sizeChanged = false;
if (this.isWidthChanged()) {
this.updateWidth(this.container.offsetWidth);
sizeChanged = true;
}
if (this.isHeightChanged()) {
this.updateHeight(this.container.offsetHeight);
sizeChanged = true;
}
if (sizeChanged) {
this.$emit('sizeChanged');
}
}
@Emit('widthChanged') public updateWidth(width: number) {
this.width = width;
}
@Emit('heightChanged') public updateHeight(height: number) {
this.height = height;
}
public destroyed() {
if (this.observer) {
this.observer.disconnect();
}
}
private fireChangeEvents() {
this.updateWidth(this.container.offsetWidth);
this.updateHeight(this.container.offsetHeight);
this.$emit('sizeChanged');
}
private isWidthChanged(): boolean {
return this.width !== this.container.offsetWidth;
}
private isHeightChanged(): boolean {
return this.height !== this.container.offsetHeight;
}
}
</script>
<style scoped lang="scss">
.container {
width: 100%;
height: 100%;
display: inline-block; // if inline then it has no height or weight
}
</style>

View File

@@ -1,8 +1,17 @@
<template>
<div>
<Responsive v-on:widthChanged="width = $event">
<!-- <div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size: <span v-if="width <= 500">small</span><span v-if="width > 500 && width < 750">medium</span><span v-if="width >= 750">big</span>
</div> -->
<div v-if="categoryIds != null && categoryIds.length > 0" class="cards">
<CardListItem
class="card"
v-bind:class="{
'small-screen': width <= 500,
'medium-screen': width > 500 && width < 750,
'big-screen': width >= 750
}"
v-for="categoryId of categoryIds"
:data-category="categoryId"
v-bind:key="categoryId"
@@ -12,12 +21,13 @@
</CardListItem>
</div>
<div v-else class="error">Something went bad 😢</div>
</div>
</Responsive>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import CardListItem from './CardListItem.vue';
import Responsive from '@/presentation/Responsive.vue';
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { ICategory } from '@/domain/ICategory';
import { hasDirective } from './NonCollapsingDirective';
@@ -26,9 +36,11 @@ import { ICategoryCollectionState } from '@/application/Context/State/ICategoryC
@Component({
components: {
CardListItem,
Responsive,
},
})
export default class CardList extends StatefulVue {
public width: number = 0;
public categoryIds: number[] = [];
public activeCategoryId?: number = null;
@@ -75,6 +87,7 @@ export default class CardList extends StatefulVue {
flex-flow: row wrap;
font-family: $main-font;
}
.error {
width: 100%;
text-align: center;

View File

@@ -106,12 +106,7 @@ $expanded-margin-top: 30px;
.card {
margin: 15px;
width: calc((100% / 3) - #{$card-line-break-width});
transition: all 0.2s ease-in-out;
// Media queries for stacking cards
@media screen and (max-width: $big-screen-width) { width: calc((100% / 2) - #{$card-line-break-width}); }
@media screen and (max-width: $medium-screen-width) { width: 100%; }
@media screen and (max-width: $small-screen-width) { width: 90%; }
&__inner {
padding: $card-padding $card-padding 0 $card-padding;
@@ -241,31 +236,32 @@ $expanded-margin-top: 30px;
}
}
}
@media screen and (min-width: $big-screen-width) { // when 3 cards in a row
.card:nth-of-type(3n+2) .card__expander {
margin-left: calc(-100% - #{$card-line-break-width});
@mixin adaptive-card($cards-in-row) {
&.card {
width: calc((100% / #{$cards-in-row}) - #{$card-line-break-width});
@for $nth-card from 2 through $cards-in-row {
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
.card__expander {
$card-left: -100% * ($nth-card - 1);
$additional-space: $card-line-break-width * ($nth-card - 1);
margin-left: calc(#{$card-left} - #{$additional-space});
}
.card:nth-of-type(3n+3) .card__expander {
margin-left: calc(-200% - (#{$card-line-break-width} * 2));
}
.card:nth-of-type(3n+4) {
}
// Ensure new line after last row
$card-after-last: $cards-in-row + 1;
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
clear: left;
}
}
.card__expander {
width: calc(300% + (#{$card-line-break-width} * 2));
$all-cards-width: 100% * $cards-in-row;
$card-padding: $card-line-break-width * ($cards-in-row - 1);
width: calc(#{$all-cards-width} + #{$card-padding});
}
}
@media screen and (min-width: $medium-screen-width) and (max-width: $big-screen-width) { // when 2 cards in a row
.card:nth-of-type(2n+2) .card__expander {
margin-left: calc(-100% - #{$card-line-break-width});
}
.card:nth-of-type(2n+3) {
clear: left;
}
.card__expander {
width: calc(200% + #{$card-line-break-width});
}
}
.big-screen { @include adaptive-card(3); }
.medium-screen { @include adaptive-card(2); }
.small-screen { @include adaptive-card(1); }
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="container">
<div class="part select">Select:</div>
<div class="part">Select:</div>
<div class="part">
<div class="part">
<SelectableOption
@@ -173,5 +173,4 @@ function areAllSelected(
}
font-family: $normal-font;
}
</style>

View File

@@ -1,14 +1,17 @@
<template>
<div class="container">
<!-- <div>OS:</div> -->
<div class="os-list">
<div v-for="os in this.allOses" :key="os.name">
<span
class="name"
class="os-name"
v-bind:class="{ 'current': currentOs === os.os }"
v-on:click="changeOsAsync(os.os)">
{{ os.name }}
</span>
</div>
</div>
</div>
</template>
<script lang="ts">
@@ -55,11 +58,14 @@ function renderOsName(os: OperatingSystem): string {
font-family: $normal-font;
display: flex;
align-items: center;
.os-list {
display: flex;
margin-left: 0.25rem;
div + div::before {
content: "|";
margin-left: 0.5rem;
}
.name {
.os-name {
&:not(.current) {
cursor: pointer;
&:hover {
@@ -71,5 +77,6 @@ function renderOsName(os: OperatingSystem): string {
color: $gray;
}
}
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div id="container">
<TheSelector class="item" />
<TheOsChanger class="item" />
<TheGrouper
class="item"
v-on:groupingChanged="$emit('groupingChanged', $event)"
v-if="!this.isSearching" />
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import TheOsChanger from './TheOsChanger.vue';
import TheSelector from './Selector/TheSelector.vue';
import TheGrouper from './Grouping/TheGrouper.vue';
import { StatefulVue } from '@/presentation/StatefulVue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
@Component({
components: {
TheSelector,
TheOsChanger,
TheGrouper,
},
})
export default class TheScriptsMenu extends StatefulVue {
public isSearching = false;
private listeners = new Array<IEventSubscription>();
public destroyed() {
this.unsubscribeAll();
}
protected initialize(): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.subscribe(newState);
}
private subscribe(state: ICategoryCollectionState) {
this.listeners.push(state.filter.filterRemoved.on(() => {
this.isSearching = false;
}));
state.filter.filtered.on(() => {
this.isSearching = true;
});
}
private unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
}
}
</script>
<style scoped lang="scss">
#container {
display: flex;
flex-wrap: wrap;
.item {
flex: 1;
white-space: nowrap;
display: flex;
justify-content: center;
margin: 0 5px 0 5px;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div
class="handle"
:style="{ cursor: cursorCssValue }"
@mousedown="startResize">
<div class="line"></div>
<font-awesome-icon
class="image"
:icon="['fas', 'arrows-alt-h']"
/> <!-- exchange-alt arrows-alt-h-->
<div class="line"></div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class Handle extends Vue {
public readonly cursorCssValue = 'ew-resize';
private initialX: number = undefined;
public startResize(event: MouseEvent): void {
this.initialX = event.clientX;
document.body.style.setProperty('cursor', this.cursorCssValue);
document.addEventListener('mousemove', this.resize);
window.addEventListener('mouseup', this.stopResize);
event.stopPropagation();
event.preventDefault();
}
public resize(event: MouseEvent): void {
const displacementX = event.clientX - this.initialX;
this.$emit('resized', displacementX);
this.initialX = event.clientX;
}
public stopResize(): void {
document.body.style.removeProperty('cursor');
document.removeEventListener('mousemove', this.resize);
window.removeEventListener('mouseup', this.stopResize);
}
}
</script>
<style lang="scss" scoped>
@import "@/presentation/styles/colors.scss";
.handle {
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
.line {
background: $gray;
}
.image {
color: $gray;
}
}
.line {
flex: 1;
background: $dark-gray;
width: 3px;
}
.image {
color: $dark-gray;
}
margin-right: 10px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="slider">
<div class="left" ref="leftElement">
<slot name="left"></slot>
</div>
<Handle class="handle" @resized="onResize($event)" />
<div class="right">
<slot name="right"></slot>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import Handle from './Handle.vue';
@Component({
components: {
Handle,
},
})
export default class HorizontalResizeSlider extends Vue {
private get left(): HTMLElement { return this.$refs.leftElement as HTMLElement; }
public onResize(displacementX: number): void {
const leftWidth = this.left.offsetWidth + displacementX;
this.left.style.width = `${leftWidth}px`;
}
}
</script>
<style lang="scss" scoped>
@import "@/presentation/styles/media.scss";
.slider {
display: flex;
flex-direction: row;
.right {
flex: 1;
}
}
@media screen and (max-width: $vertical-view-breakpoint) {
.slider {
flex-direction: column;
.left {
width: auto !important;
}
.handle {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="scripts">
<TheScriptsMenu v-on:groupingChanged="grouping = $event" />
<HorizontalResizeSlider class="row">
<template v-slot:left>
<TheScriptsList :grouping="grouping" />
</template>
<template v-slot:right>
<TheCodeArea theme="xcode" />
</template>
</HorizontalResizeSlider>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TheCodeArea from '@/presentation/Code/TheCodeArea.vue';
import TheScriptsList from '@/presentation/Scripts/TheScriptsList.vue';
import TheScriptsMenu from '@/presentation/Scripts/Menu/TheScriptsMenu.vue';
import HorizontalResizeSlider from '@/presentation/Scripts/Slider/HorizontalResizeSlider.vue';
import { Grouping } from '@/presentation/Scripts/Menu/Grouping/Grouping';
@Component({
components: {
TheCodeArea,
TheScriptsList,
TheScriptsMenu,
HorizontalResizeSlider,
},
})
export default class TheScriptArea extends Vue {
public grouping = Grouping.Cards;
}
</script>
<style scoped lang="scss">
.scripts {
> * + * {
margin-top: 15px;
}
}
::v-deep .left {
width: 55%; // initial width
min-width: 20%;
}
::v-deep .right {
min-width: 20%;
}
</style>

View File

@@ -1,17 +1,8 @@
<template>
<div>
<div class="heading">
<TheSelector class="item"/>
<TheOsChanger class="item"/>
<TheGrouper
class="item"
v-on:groupingChanged="onGroupingChanged($event)"
v-if="!this.isSearching" />
</div>
<div class="scripts">
<div v-if="!isSearching">
<CardList v-if="currentGrouping === Grouping.Cards"/>
<div class="tree" v-if="currentGrouping === Grouping.None">
<CardList v-if="grouping === Grouping.Cards"/>
<div class="tree" v-if="grouping === Grouping.None">
<ScriptsTree />
</div>
</div>
@@ -35,18 +26,15 @@
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import TheGrouper from '@/presentation/Scripts/Grouping/TheGrouper.vue';
import TheOsChanger from '@/presentation/Scripts/TheOsChanger.vue';
import TheSelector from '@/presentation/Scripts/Selector/TheSelector.vue';
import TheGrouper from '@/presentation/Scripts/Menu/Grouping/TheGrouper.vue';
import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/Scripts/Cards/CardList.vue';
import { Component } from 'vue-property-decorator';
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Grouping } from './Grouping/Grouping';
import { Grouping } from '@/presentation/Scripts/Menu/Grouping/Grouping';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@@ -55,10 +43,8 @@ import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component({
components: {
TheGrouper,
TheSelector,
ScriptsTree,
CardList,
TheOsChanger,
},
filters: {
threeDotsTrim(query: string) {
@@ -70,10 +56,11 @@ filters: {
},
},
})
export default class TheScripts extends StatefulVue {
export default class TheScriptsList extends StatefulVue {
@Prop() public grouping: Grouping;
public repositoryUrl = '';
public Grouping = Grouping; // Make it accessible from view
public currentGrouping = Grouping.Cards;
public Grouping = Grouping; // Make it accessible from the view
public searchQuery = '';
public isSearching = false;
public searchHasMatches = false;
@@ -87,9 +74,6 @@ export default class TheScripts extends StatefulVue {
const filter = context.state.filter;
filter.removeFilter();
}
public onGroupingChanged(group: Grouping) {
this.currentGrouping = group;
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.events.unsubscribeAll();
@@ -114,11 +98,16 @@ export default class TheScripts extends StatefulVue {
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/media.scss";
$inner-margin: 4px;
.scripts {
margin-top: $inner-margin;
@media screen and (min-width: $vertical-view-breakpoint) { // so the current code is always visible
overflow: auto;
max-height: 70vh;
}
.tree {
padding-left: 3%;
padding-top: 15px;
@@ -167,22 +156,4 @@ $inner-margin: 4px;
}
}
.heading {
margin-top: $inner-margin;
display: flex;
flex-wrap: wrap;
.item {
flex: 1;
white-space: nowrap;
display: flex;
justify-content: center;
margin: 0 5px 0 5px;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
}
</style>

View File

@@ -99,13 +99,13 @@ export default class TheFooter extends Vue {
.footer {
display: flex;
justify-content: space-between;
@media (max-width: $big-screen-width) {
@media screen and (max-width: $big-screen-width) {
flex-direction: column;
align-items: center;
}
&__section {
display: flex;
@media (max-width: $big-screen-width) {
@media screen and (max-width: $big-screen-width) {
justify-content: space-around;
width:100%;
&:not(:first-child) {
@@ -129,7 +129,7 @@ export default class TheFooter extends Vue {
content: "|";
padding: 0 5px;
}
@media (max-width: $big-screen-width) {
@media screen and (max-width: $big-screen-width) {
margin-top: 3px;
&::before {
content: "";

View File

@@ -0,0 +1,30 @@
export function throttle<T extends []>(
callback: (..._: T) => void, wait: number,
timer: ITimer = NodeTimer): (..._: T) => void {
let queuedToRun: ReturnType<typeof setTimeout>;
let previouslyRun: number;
return function invokeFn(...args: T) {
const now = timer.dateNow();
if (queuedToRun) {
queuedToRun = timer.clearTimeout(queuedToRun) as undefined;
}
if (!previouslyRun || (now - previouslyRun >= wait)) {
callback(...args);
previouslyRun = now;
} else {
queuedToRun = timer.setTimeout(invokeFn.bind(null, ...args), wait - (now - previouslyRun));
}
};
}
export interface ITimer {
setTimeout: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimeout: (timeoutId: ReturnType<typeof setTimeout>) => void;
dateNow(): number;
}
const NodeTimer: ITimer = {
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
dateNow: () => Date.now(),
};

View File

@@ -1,3 +1,5 @@
$big-screen-width: 992px;
$medium-screen-width: 768px;
$small-screen-width: 380px;
$vertical-view-breakpoint: 992px;

View File

@@ -0,0 +1,76 @@
import 'mocha';
import { expect } from 'chai';
import { throttle, ITimer } from '@/presentation/Throttle';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
describe('throttle', () => {
it('should call the callback immediately', () => {
// arrange
const timer = new TimerMock();
let totalRuns = 0;
const callback = () => totalRuns++;
const throttleFunc = throttle(callback, 500, timer);
// act
throttleFunc();
// assert
expect(totalRuns).to.equal(1);
});
it('should call the callback again after the timeout', () => {
// arrange
const timer = new TimerMock();
let totalRuns = 0;
const callback = () => totalRuns++;
const throttleFunc = throttle(callback, 500, timer);
// act
throttleFunc();
totalRuns--;
throttleFunc();
timer.tick(500);
// assert
expect(totalRuns).to.equal(1);
});
it('calls the callback at most once at given time', () => {
// arrange
const timer = new TimerMock();
let totalRuns = 0;
const callback = () => totalRuns++;
const waitInMs = 500;
const totalCalls = 10;
const throttleFunc = throttle(callback, waitInMs, timer);
// act
for (let i = 0; i < totalCalls; i++) {
timer.tick(waitInMs / totalCalls * i);
throttleFunc();
}
// assert
expect(totalRuns).to.equal(2); // initial and at the end
});
});
class TimerMock implements ITimer {
private timeChanged = new EventSource<number>();
private subscriptions = new Array<IEventSubscription>();
private currentTime = 0;
public setTimeout(callback: () => void, ms: number): NodeJS.Timeout {
const runTime = this.currentTime + ms;
const subscription = this.timeChanged.on((time) => {
if (time >= runTime) {
callback();
subscription.unsubscribe();
}
});
this.subscriptions.push(subscription);
return (this.subscriptions.length - 1) as any;
}
public clearTimeout(timeoutId: NodeJS.Timeout): void {
this.subscriptions[timeoutId as any].unsubscribe();
}
public dateNow(): number {
return this.currentTime;
}
public tick(ms: number): void {
this.currentTime = ms;
this.timeChanged.notify(this.currentTime);
}
}