initial commit
This commit is contained in:
parent
cdaf3f9c50
commit
9850decf24
7 changed files with 463 additions and 74 deletions
40
src/App.vue
40
src/App.vue
|
@ -1,29 +1,37 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<img alt="Vue logo" src="./assets/logo.png">
|
||||
<HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/>
|
||||
</div>
|
||||
<div id="app">
|
||||
<picker @images="images = $event"/>
|
||||
<collage :images="images"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import HelloWorld from './components/HelloWorld.vue';
|
||||
import {Component, Vue} from "vue-property-decorator";
|
||||
import Picker from "@/components/Picker.vue";
|
||||
import Collage from "@/components/Collage.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
HelloWorld,
|
||||
},
|
||||
components: {
|
||||
Picker,
|
||||
Collage
|
||||
},
|
||||
})
|
||||
export default class App extends Vue {}
|
||||
export default class App extends Vue {
|
||||
private images: ImageBitmap[] = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0 2em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-top: 60px;
|
||||
font-family: monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
|
129
src/collages.ts
Normal file
129
src/collages.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
import {CollageMode} from "@/types";
|
||||
import {randint, shuffle} from "@/utils";
|
||||
|
||||
const collageModeType = ["grid", "row", "irow", "col", "icol"] as const;
|
||||
export type CollageModeType = typeof collageModeType[number];
|
||||
|
||||
function cleanDraw(ctx: CanvasRenderingContext2D, image: ImageBitmap,
|
||||
x: number, y: number, w: number, h: number) {
|
||||
const scaleRatio = Math.max(w / image.width, h / image.height);
|
||||
ctx.drawImage(image,
|
||||
image.width / 2 - w / scaleRatio / 2, image.height / 2 - h / scaleRatio / 2,
|
||||
w / scaleRatio, h / scaleRatio,
|
||||
x - w / 2, y - h / 2,
|
||||
w, h);
|
||||
}
|
||||
|
||||
const modes: { [key in CollageModeType]: CollageMode } = {
|
||||
"grid": {
|
||||
name: "Regular Grid",
|
||||
minImages: 4,
|
||||
forceConfig: {
|
||||
numImages: 4
|
||||
},
|
||||
place: (ctx, images, config) => {
|
||||
const quadrantSize = [ctx.canvas.width / 2, ctx.canvas.height / 2];
|
||||
const selectedImages = shuffle(images).slice(0, 4);
|
||||
shuffle(selectedImages.map((image, idx) => [image, idx] as [ImageBitmap, number]))
|
||||
.forEach(([image, idx]) => {
|
||||
let x!: number, y!: number;
|
||||
switch (idx) {
|
||||
case 0:
|
||||
x = ctx.canvas.width * .25;
|
||||
y = ctx.canvas.height * .25;
|
||||
break;
|
||||
case 1:
|
||||
x = ctx.canvas.width * .75;
|
||||
y = ctx.canvas.height * .25;
|
||||
break;
|
||||
case 2:
|
||||
x = ctx.canvas.width * .25;
|
||||
y = ctx.canvas.height * .75;
|
||||
break;
|
||||
case 3:
|
||||
x = ctx.canvas.width * .75;
|
||||
y = ctx.canvas.height * .75;
|
||||
break;
|
||||
}
|
||||
if (config.cleanCrops) {
|
||||
cleanDraw(ctx, image, x, y, quadrantSize[0], quadrantSize[1]);
|
||||
} else {
|
||||
const scaleRatio = Math.max(quadrantSize[0] / image.width, quadrantSize[1] / image.height);
|
||||
ctx.drawImage(image,
|
||||
x - (image.width * scaleRatio / 2), y - (image.height * scaleRatio / 2),
|
||||
image.width * scaleRatio, image.height * scaleRatio);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
"row": {
|
||||
name: "Regular Row",
|
||||
minImages: 2,
|
||||
forceConfig: {
|
||||
cleanCrops: true
|
||||
},
|
||||
place: (ctx, images, config) => {
|
||||
const selectedImages = shuffle(images).slice(0, config.numImages || randint(4) + 2);
|
||||
const quadrantSize = [ctx.canvas.width / selectedImages.length, ctx.canvas.height];
|
||||
selectedImages.forEach((image, idx) => {
|
||||
const x = idx * quadrantSize[0] + quadrantSize[0] / 2;
|
||||
const y = quadrantSize[1] / 2;
|
||||
cleanDraw(ctx, image, x, y, quadrantSize[0], quadrantSize[1]);
|
||||
});
|
||||
}
|
||||
},
|
||||
"irow": {
|
||||
name: "Irregular Row",
|
||||
minImages: 2,
|
||||
forceConfig: {
|
||||
cleanCrops: true
|
||||
},
|
||||
place: (ctx, images, config) => {
|
||||
const selectedImages = shuffle(images).slice(0, config.numImages || randint(4) + 2);
|
||||
const quadrantSize = [ctx.canvas.width / selectedImages.length, ctx.canvas.height];
|
||||
selectedImages.forEach((image, idx) => {
|
||||
const x = idx * quadrantSize[0] + quadrantSize[0] / 2;
|
||||
const y = quadrantSize[1] / 2;
|
||||
const w = Math.min(ctx.canvas.width / 2,
|
||||
quadrantSize[1] + Math.random() * (quadrantSize[1] - (quadrantSize[1] / image.height) * image.width));
|
||||
cleanDraw(ctx, image, x, y, w, quadrantSize[1]);
|
||||
});
|
||||
}
|
||||
},
|
||||
"col": {
|
||||
name: "Regular Column",
|
||||
minImages: 2,
|
||||
forceConfig: {
|
||||
cleanCrops: true
|
||||
},
|
||||
place: (ctx, images, config) => {
|
||||
const selectedImages = shuffle(images).slice(0, config.numImages || randint(4) + 2);
|
||||
const quadrantSize = [ctx.canvas.width, ctx.canvas.height / selectedImages.length];
|
||||
selectedImages.forEach((image, idx) => {
|
||||
const x = quadrantSize[0] / 2;
|
||||
const y = idx * quadrantSize[1] + quadrantSize[1] / 2;
|
||||
cleanDraw(ctx, image, x, y, quadrantSize[0], quadrantSize[1]);
|
||||
});
|
||||
}
|
||||
},
|
||||
"icol": {
|
||||
name: "Irregular Column",
|
||||
minImages: 2,
|
||||
forceConfig: {
|
||||
cleanCrops: true
|
||||
},
|
||||
place: (ctx, images, config) => {
|
||||
const selectedImages = shuffle(images).slice(0, config.numImages || randint(4) + 2);
|
||||
const quadrantSize = [ctx.canvas.width, ctx.canvas.height / selectedImages.length];
|
||||
selectedImages.forEach((image, idx) => {
|
||||
const x = quadrantSize[0] / 2;
|
||||
const y = idx * quadrantSize[1] + quadrantSize[1] / 2;
|
||||
const h = Math.min(ctx.canvas.height / 2,
|
||||
quadrantSize[0] + Math.random() * (quadrantSize[0] - (quadrantSize[0] / image.width) * image.height));
|
||||
cleanDraw(ctx, image, x, y, quadrantSize[0], h);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default modes;
|
190
src/components/Collage.vue
Normal file
190
src/components/Collage.vue
Normal file
|
@ -0,0 +1,190 @@
|
|||
<template>
|
||||
<div class="collage">
|
||||
<div class="canvas">
|
||||
<canvas id="canvas" ref="canvas" :width="canvas.width" :height="canvas.height"></canvas>
|
||||
<div class="canvas-size">
|
||||
<label>
|
||||
Width:
|
||||
<input type="number" step="16" min="128" v-model="canvas.width">
|
||||
</label>
|
||||
<label>
|
||||
Height:
|
||||
<input type="number" step="16" min="128" v-model="canvas.height">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="modes">
|
||||
<label v-for="(mode, idx) in modes"
|
||||
:class="{disabled: images.length < mode.minImages,
|
||||
selected: idx === currentModeType,
|
||||
lastActive: idx === lastActiveModeType}">
|
||||
{{mode.name}} ({{mode.minImages}})
|
||||
<input type="radio" :value="idx" v-model="currentModeType">
|
||||
</label>
|
||||
</div>
|
||||
<button :disabled="images.length < currentMode.minImages" @click="renderCollage">REPAINT</button>
|
||||
<hr>
|
||||
<div class="config">
|
||||
<label class="config-numimages">
|
||||
#N of images:
|
||||
<input type="number" :min="currentMode.minImages" :max="images.length"
|
||||
placeholder="RND"
|
||||
:disabled="Object.keys(forceConfig).includes('numImages')"
|
||||
v-model="forceConfig.numImages || collageConfig.numImages">
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
:disabled="Object.keys(forceConfig).includes('cleanCrops')"
|
||||
v-model="forceConfig.cleanCrops || collageConfig.cleanCrops">
|
||||
Crop cleanly
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue, Watch} from "vue-property-decorator";
|
||||
import collageModes, {CollageModeType} from "../collages";
|
||||
import {CollageConfig, CollageMode} from "@/types";
|
||||
|
||||
type DisplayCollageModeType = CollageModeType | & "shuffle";
|
||||
|
||||
@Component
|
||||
export default class Collage extends Vue {
|
||||
@Prop({required: true}) private images!: ImageBitmap[];
|
||||
private context!: CanvasRenderingContext2D;
|
||||
private canvas = {
|
||||
width: 640,
|
||||
height: 640
|
||||
};
|
||||
private collageConfig: CollageConfig = {
|
||||
numImages: undefined,
|
||||
cleanCrops: false
|
||||
};
|
||||
private currentModeType: DisplayCollageModeType = "shuffle";
|
||||
private lastActiveModeType: DisplayCollageModeType | null = null;
|
||||
private modes: { [key in DisplayCollageModeType]: CollageMode } = {
|
||||
...collageModes,
|
||||
"shuffle": {
|
||||
name: "Shuffle all!",
|
||||
minImages: Math.min(...Object.values(collageModes).map(m => m.minImages)),
|
||||
place: (ctx, images, config) => {
|
||||
const permissibleModeKeys = Object.keys(collageModes)
|
||||
.filter(k => collageModes[k as CollageModeType].minImages <= images.length) as CollageModeType[];
|
||||
const randomModeType = permissibleModeKeys[Math.floor(Math.random() * permissibleModeKeys.length)];
|
||||
const randomMode = collageModes[randomModeType];
|
||||
this.setLastActiveModeType(randomModeType);
|
||||
randomMode.place(ctx, images, config);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// wtf vue?
|
||||
private setLastActiveModeType(lastActiveModeType: any) {
|
||||
this.lastActiveModeType = lastActiveModeType;
|
||||
}
|
||||
|
||||
private get currentMode() {
|
||||
return this.modes[this.currentModeType];
|
||||
}
|
||||
|
||||
private get lastMode() {
|
||||
return this.lastActiveModeType ? this.modes[this.lastActiveModeType] : undefined;
|
||||
}
|
||||
|
||||
private get forceConfig() {
|
||||
return this.lastMode ? this.lastMode.forceConfig || {} : {};
|
||||
}
|
||||
|
||||
|
||||
private mounted() {
|
||||
const canvas = (this.$refs.canvas as HTMLCanvasElement);
|
||||
this.context = canvas.getContext("2d") as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
@Watch("images")
|
||||
@Watch("currentMode")
|
||||
@Watch("collageConfig", {deep: true})
|
||||
private renderCollage() {
|
||||
if (this.images.length >= this.currentMode.minImages) {
|
||||
this.lastActiveModeType = this.currentModeType;
|
||||
this.currentMode.place(this.context, this.images, this.collageConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
.collage {
|
||||
margin: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.canvas-size {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
font-size: 14pt;
|
||||
cursor: pointer;
|
||||
margin: .25rem;
|
||||
}
|
||||
|
||||
.modes input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls button, .controls hr, .controls .config {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.controls .config {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.config label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.config-numimages input {
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: gray;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.lastActive {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
|
@ -1,58 +0,0 @@
|
|||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For a guide and recipes on how to configure / customize this project,<br>
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
|
||||
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
|
||||
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
|
||||
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
|
||||
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
|
||||
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
|
||||
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
|
||||
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class HelloWorld extends Vue {
|
||||
@Prop() private msg!: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
90
src/components/Picker.vue
Normal file
90
src/components/Picker.vue
Normal file
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<div class="picker">
|
||||
<div class="images">
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<img v-for="file in files" :alt="file.name" :src="dataURLs[file.name]" @click="confirmDelete(file)"/>
|
||||
</div>
|
||||
<input class="browse" type="file" accept="image/*" multiple @change="loadImages($event)">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Vue, Watch} from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class Picker extends Vue {
|
||||
private files: File[] = [];
|
||||
private dataURLs: { [key: string]: string } = {};
|
||||
private bitmaps: { [key: string]: ImageBitmap } = {};
|
||||
|
||||
private loadImages(event: Event) {
|
||||
Object.values(this.dataURLs).forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
this.dataURLs = {};
|
||||
|
||||
this.files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
this.files.forEach((file: File) => {
|
||||
this.dataURLs[file.name] = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
const bitmapPromises: Promise<[string, ImageBitmap]>[] =
|
||||
Object.entries(this.dataURLs).map(([filename, url]) => {
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
createImageBitmap(image).then((bitmap) => {
|
||||
resolve([filename, bitmap]);
|
||||
});
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(bitmapPromises).then((bitmaps) => {
|
||||
bitmaps.forEach(([filename, bitmap]) => {
|
||||
this.bitmaps[filename] = bitmap;
|
||||
});
|
||||
this.$emit("images", Object.values(this.bitmaps));
|
||||
});
|
||||
}
|
||||
|
||||
private confirmDelete(file: File) {
|
||||
if (window.confirm(`Remove ${file.name}?`)) {
|
||||
this.files.splice(this.files.indexOf(file), 1);
|
||||
URL.revokeObjectURL(this.dataURLs[file.name]);
|
||||
delete this.dataURLs[file.name];
|
||||
delete this.bitmaps[file.name];
|
||||
|
||||
this.$emit("images", Object.values(this.bitmaps));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
.picker {
|
||||
border: 1px solid black;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.images {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.images img {
|
||||
height: 120px;
|
||||
margin: .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse {
|
||||
min-width: 16em;
|
||||
margin: 2em;
|
||||
}
|
||||
</style>
|
11
src/types.d.ts
vendored
Normal file
11
src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
export interface CollageMode {
|
||||
name: string;
|
||||
minImages: number;
|
||||
place: (ctx: CanvasRenderingContext2D, images: ImageBitmap[], config: CollageConfig) => void;
|
||||
forceConfig?: CollageConfig;
|
||||
}
|
||||
|
||||
export interface CollageConfig {
|
||||
numImages?: number;
|
||||
cleanCrops?: boolean;
|
||||
}
|
19
src/utils.ts
Normal file
19
src/utils.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Shuffles array in place.
|
||||
* @param {Array} a items An array containing the items.
|
||||
*/
|
||||
export function shuffle<T>(a: T[]): T[] {
|
||||
let j, x, i;
|
||||
let b = Array.from(a);
|
||||
for (i = b.length - 1; i > 0; i--) {
|
||||
j = Math.floor(Math.random() * (i + 1));
|
||||
x = b[i];
|
||||
b[i] = b[j];
|
||||
b[j] = x;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
export function randint(n: number) {
|
||||
return Math.floor(Math.random() * n);
|
||||
}
|
Loading…
Reference in a new issue