move code area to right on bigger screens
This commit is contained in:
@@ -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",
|
||||
|
||||
27
src/App.vue
27
src/App.vue
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="wrapper">
|
||||
<TheHeader class="row" />
|
||||
<TheSearchBar class="row" />
|
||||
<TheScripts class="row"/>
|
||||
<TheCodeArea class="row" theme="xcode" />
|
||||
<TheCodeButtons class="row code-buttons" />
|
||||
<TheFooter />
|
||||
<TheHeader class="row" />
|
||||
<TheSearchBar class="row" />
|
||||
<TheScriptArea class="row" />
|
||||
<TheCodeButtons class="row code-buttons" />
|
||||
<TheFooter />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
min-height: 200px;
|
||||
overflow: auto;
|
||||
&__highlight {
|
||||
background-color:$accent;
|
||||
opacity: 0.2; // having procent fails in production (minified) build
|
||||
position:absolute;
|
||||
}
|
||||
::v-deep .code-area {
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
&__highlight {
|
||||
background-color: $accent;
|
||||
opacity: 0.2; // having procent fails in production (minified) build
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
75
src/presentation/Responsive.vue
Normal file
75
src/presentation/Responsive.vue
Normal 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>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="categoryIds != null && categoryIds.length > 0" class="cards">
|
||||
<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;
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
.card:nth-of-type(3n+3) .card__expander {
|
||||
margin-left: calc(-200% - (#{$card-line-break-width} * 2));
|
||||
}
|
||||
.card:nth-of-type(3n+4) {
|
||||
clear: left;
|
||||
@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});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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>
|
||||
@@ -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>
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-for="os in this.allOses" :key="os.name">
|
||||
<span
|
||||
class="name"
|
||||
v-bind:class="{ 'current': currentOs === os.os }"
|
||||
v-on:click="changeOsAsync(os.os)">
|
||||
{{ os.name }}
|
||||
</span>
|
||||
<!-- <div>OS:</div> -->
|
||||
<div class="os-list">
|
||||
<div v-for="os in this.allOses" :key="os.name">
|
||||
<span
|
||||
class="os-name"
|
||||
v-bind:class="{ 'current': currentOs === os.os }"
|
||||
v-on:click="changeOsAsync(os.os)">
|
||||
{{ os.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,20 +58,24 @@ function renderOsName(os: OperatingSystem): string {
|
||||
font-family: $normal-font;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
div + div::before {
|
||||
content: "|";
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.name {
|
||||
&:not(.current) {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.os-list {
|
||||
display: flex;
|
||||
margin-left: 0.25rem;
|
||||
div + div::before {
|
||||
content: "|";
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
&.current {
|
||||
color: $gray;
|
||||
.os-name {
|
||||
&:not(.current) {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&.current {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/presentation/Scripts/Menu/TheScriptsMenu.vue
Normal file
77
src/presentation/Scripts/Menu/TheScriptsMenu.vue
Normal 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>
|
||||
71
src/presentation/Scripts/Slider/Handle.vue
Normal file
71
src/presentation/Scripts/Slider/Handle.vue
Normal 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>
|
||||
53
src/presentation/Scripts/Slider/HorizontalResizeSlider.vue
Normal file
53
src/presentation/Scripts/Slider/HorizontalResizeSlider.vue
Normal 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>
|
||||
49
src/presentation/Scripts/TheScriptArea.vue
Normal file
49
src/presentation/Scripts/TheScriptArea.vue
Normal 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>
|
||||
@@ -1,52 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="heading">
|
||||
<TheSelector class="item"/>
|
||||
<TheOsChanger class="item"/>
|
||||
<TheGrouper
|
||||
class="item"
|
||||
v-on:groupingChanged="onGroupingChanged($event)"
|
||||
v-if="!this.isSearching" />
|
||||
<div class="scripts">
|
||||
<div v-if="!isSearching">
|
||||
<CardList v-if="grouping === Grouping.Cards"/>
|
||||
<div class="tree" v-if="grouping === Grouping.None">
|
||||
<ScriptsTree />
|
||||
</div>
|
||||
</div>
|
||||
<div class="scripts">
|
||||
<div v-if="!isSearching">
|
||||
<CardList v-if="currentGrouping === Grouping.Cards"/>
|
||||
<div class="tree" v-if="currentGrouping === Grouping.None">
|
||||
<ScriptsTree />
|
||||
<div v-else> <!-- Searching -->
|
||||
<div class="search">
|
||||
<div class="search__query">
|
||||
<div>Searching for "{{this.searchQuery | threeDotsTrim}}"</div>
|
||||
<div class="search__query__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
v-on:click="clearSearchQueryAsync()"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!searchHasMatches" class="search-no-matches">
|
||||
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞</div>
|
||||
<div>Feel free to extend the scripts <a :href="repositoryUrl" target="_blank" class="child github" >here</a> ✨</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else> <!-- Searching -->
|
||||
<div class="search">
|
||||
<div class="search__query">
|
||||
<div>Searching for "{{this.searchQuery | threeDotsTrim}}"</div>
|
||||
<div class="search__query__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
v-on:click="clearSearchQueryAsync()"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!searchHasMatches" class="search-no-matches">
|
||||
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞</div>
|
||||
<div>Feel free to extend the scripts <a :href="repositoryUrl" target="_blank" class="child github" >here</a> ✨</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="searchHasMatches" class="tree tree--searching">
|
||||
<ScriptsTree />
|
||||
</div>
|
||||
<div v-if="searchHasMatches" class="tree tree--searching">
|
||||
<ScriptsTree />
|
||||
</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>
|
||||
@@ -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: "";
|
||||
|
||||
30
src/presentation/Throttle.ts
Normal file
30
src/presentation/Throttle.ts
Normal 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(),
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
$big-screen-width: 992px;
|
||||
$medium-screen-width: 768px;
|
||||
$small-screen-width: 380px;
|
||||
$small-screen-width: 380px;
|
||||
|
||||
$vertical-view-breakpoint: 992px;
|
||||
76
tests/unit/presentation/Throttle.spec.ts
Normal file
76
tests/unit/presentation/Throttle.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user