From 79d592b4318d0a9d69a3469db8a0ef8b35c3af90 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Fri, 8 Jun 2018 04:21:06 +0900
Subject: [PATCH] =?UTF-8?q?MisskeyDeck:=20=E3=82=AB=E3=83=A9=E3=83=A0?=
 =?UTF-8?q?=E3=82=92=E3=82=B9=E3=82=BF=E3=83=83=E3=82=AF=E3=81=A7=E3=81=8D?=
 =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/ja.yml                                |   2 +
 .../app/common/views/components/menu.vue      |  33 +++++-
 .../views/pages/deck/deck.column-core.vue     |  48 ++++++++
 .../desktop/views/pages/deck/deck.column.vue  |  61 +++++++---
 .../pages/deck/deck.notifications-column.vue  |  17 +--
 .../views/pages/deck/deck.tl-column.vue       |  39 +++----
 .../app/desktop/views/pages/deck/deck.vue     |  58 +++++++---
 .../views/pages/deck/deck.widgets-column.vue  | 107 ++++++++----------
 src/client/app/store.ts                       |  53 ++++++---
 9 files changed, 275 insertions(+), 143 deletions(-)
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.column-core.vue

diff --git a/locales/ja.yml b/locales/ja.yml
index 320da8e777..3161040ec8 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -88,6 +88,8 @@ common:
     remove: "カラムを削除"
     add-column: "カラムを追加"
     rename: "名前を変更"
+    stack-left: "左に重ねる"
+    pop-right: "右に出す"
 
 common/views/components/connect-failed.vue:
   title: "サーバーに接続できません"
diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue
index 73c8403ad3..8874e4c49b 100644
--- a/src/client/app/common/views/components/menu.vue
+++ b/src/client/app/common/views/components/menu.vue
@@ -1,7 +1,7 @@
 <template>
 <div class="mk-menu">
 	<div class="backdrop" ref="backdrop" @click="close"></div>
-	<div class="popover" :class="{ compact }" ref="popover">
+	<div class="popover" :class="{ hukidasi }" ref="popover">
 		<template v-for="item in items">
 			<div v-if="item == null"></div>
 			<button v-else @click="clicked(item.onClick)" v-html="item.content"></button>
@@ -16,6 +16,11 @@ import * as anime from 'animejs';
 
 export default Vue.extend({
 	props: ['source', 'compact', 'items'],
+	data() {
+		return {
+			hukidasi: !this.compact
+		};
+	},
 	mounted() {
 		this.$nextTick(() => {
 			const popover = this.$refs.popover as any;
@@ -24,18 +29,34 @@ export default Vue.extend({
 			const width = popover.offsetWidth;
 			const height = popover.offsetHeight;
 
+			let left;
+			let top;
+
 			if (this.compact) {
 				const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
 				const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
-				popover.style.left = (x - (width / 2)) + 'px';
-				popover.style.top = (y - (height / 2)) + 'px';
+				left = (x - (width / 2));
+				top = (y - (height / 2));
 			} else {
 				const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
 				const y = rect.top + window.pageYOffset + this.source.offsetHeight;
-				popover.style.left = (x - (width / 2)) + 'px';
-				popover.style.top = y + 'px';
+				left = (x - (width / 2));
+				top = y;
 			}
 
+			if (left + width > window.innerWidth) {
+				left = window.innerWidth - width;
+				this.hukidasi = false;
+			}
+
+			if (top + height > window.innerHeight) {
+				top = window.innerHeight - height;
+				this.hukidasi = false;
+			}
+
+			popover.style.left = left + 'px';
+			popover.style.top = top + 'px';
+
 			anime({
 				targets: this.$refs.backdrop,
 				opacity: 1,
@@ -113,7 +134,7 @@ $border-color = rgba(27, 31, 35, 0.15)
 
 		$balloon-size = 16px
 
-		&:not(.compact)
+		&.hukidasi
 			margin-top $balloon-size
 			transform-origin center -($balloon-size)
 
diff --git a/src/client/app/desktop/views/pages/deck/deck.column-core.vue b/src/client/app/desktop/views/pages/deck/deck.column-core.vue
new file mode 100644
index 0000000000..836ce3ac9e
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.column-core.vue
@@ -0,0 +1,48 @@
+<template>
+<x-widgets-column v-if="column.type == 'widgets'"/>
+<x-notifications-column v-else-if="column.type == 'notifications'"/>
+<x-tl-column v-else-if="column.type == 'home'"/>
+<x-tl-column v-else-if="column.type == 'local'"/>
+<x-tl-column v-else-if="column.type == 'global'"/>
+<x-tl-column v-else-if="column.type == 'list'"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XTlColumn from './deck.tl-column.vue';
+import XNotificationsColumn from './deck.notifications-column.vue';
+import XWidgetsColumn from './deck.widgets-column.vue';
+
+export default Vue.extend({
+	components: {
+		XTlColumn,
+		XNotificationsColumn,
+		XWidgetsColumn
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		isActive: {
+			type: Boolean,
+			required: false,
+			default: true
+		}
+	},
+
+	provide() {
+		return {
+			column: this.column,
+			isStacked: this.isStacked,
+			isActive: this.isActive
+		};
+	}
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue
index 4e37c3cbdb..172880df6e 100644
--- a/src/client/app/desktop/views/pages/deck/deck.column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.column.vue
@@ -1,10 +1,10 @@
 <template>
-<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow }">
-	<header :class="{ indicate }">
+<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow, isActive, isStacked }">
+	<header :class="{ indicate }" @click="toggleActive">
 		<slot name="header"></slot>
-		<button ref="menu" @click="showMenu">%fa:caret-down%</button>
+		<button ref="menu" @click.stop="showMenu">%fa:caret-down%</button>
 	</header>
-	<div ref="body">
+	<div ref="body" v-show="isActive">
 		<slot></slot>
 	</div>
 </div>
@@ -16,17 +16,14 @@ import Menu from '../../../../common/views/components/menu.vue';
 
 export default Vue.extend({
 	props: {
-		id: {
-			type: String,
-			required: false
-		},
 		name: {
 			type: String,
 			required: false
 		},
 		menu: {
 			type: Array,
-			required: false
+			required: false,
+			default: null
 		},
 		naked: {
 			type: Boolean,
@@ -40,9 +37,17 @@ export default Vue.extend({
 		}
 	},
 
+	inject: {
+		column: { from: 'column' },
+		_isActive: { from: 'isActive' },
+		isStacked: { from: 'isStacked' },
+		getColumnVm: { from: 'getColumnVm' }
+	},
+
 	data() {
 		return {
-			indicate: false
+			indicate: false,
+			isActive: this._isActive
 		};
 	},
 
@@ -62,6 +67,13 @@ export default Vue.extend({
 	},
 
 	methods: {
+		toggleActive() {
+			if (!this.isStacked) return;
+			const vms = this.$store.state.settings.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id));
+			if (this.isActive && vms.filter(vm => vm.$el.classList.contains('isActive')).length == 1) return;
+			this.isActive = !this.isActive;
+		},
+
 		isScrollTop() {
 			return this.$refs.body.scrollTop == 0;
 		},
@@ -86,23 +98,33 @@ export default Vue.extend({
 						default: this.name,
 						allowEmpty: false
 					}).then(name => {
-						this.$store.dispatch('settings/renameDeckColumn', { id: this.id, name });
+						this.$store.dispatch('settings/renameDeckColumn', { id: this.column.id, name });
 					});
 				}
 			}, null, {
 				content: '%fa:arrow-left% %i18n:common.deck.swap-left%',
 				onClick: () => {
-					this.$store.dispatch('settings/swapLeftDeckColumn', this.id);
+					this.$store.dispatch('settings/swapLeftDeckColumn', this.column.id);
 				}
 			}, {
 				content: '%fa:arrow-right% %i18n:common.deck.swap-right%',
 				onClick: () => {
-					this.$store.dispatch('settings/swapRightDeckColumn', this.id);
+					this.$store.dispatch('settings/swapRightDeckColumn', this.column.id);
+				}
+			}, null, {
+				content: '%fa:window-restore R% %i18n:common.deck.stack-left%',
+				onClick: () => {
+					this.$store.dispatch('settings/stackLeftDeckColumn', this.column.id);
+				}
+			}, {
+				content: '%fa:window-restore R% %i18n:common.deck.pop-right%',
+				onClick: () => {
+					this.$store.dispatch('settings/popRightDeckColumn', this.column.id);
 				}
 			}, null, {
 				content: '%fa:trash-alt R% %i18n:common.deck.remove%',
 				onClick: () => {
-					this.$store.dispatch('settings/removeDeckColumn', this.id);
+					this.$store.dispatch('settings/removeDeckColumn', this.column.id);
 				}
 			}];
 
@@ -128,14 +150,20 @@ root(isDark)
 	$header-height = 42px
 
 	width 330px
+	min-width 330px
 	height 100%
 	background isDark ? #282C37 : #fff
 	border-radius 6px
 	box-shadow 0 2px 16px rgba(#000, 0.1)
 	overflow hidden
 
-	&.narrow
+	&:not(.isActive)
+		flex-basis $header-height
+		min-height $header-height
+
+	&:not(.isStacked).narrow
 		width 285px
+		min-width 285px
 
 	&.naked
 		background rgba(#000, isDark ? 0.25 : 0.1)
@@ -157,6 +185,9 @@ root(isDark)
 		background isDark ? #313543 : #fff
 		box-shadow 0 1px rgba(#000, 0.15)
 
+		&, *
+			user-select none
+
 		&.indicate
 			box-shadow 0 3px 0 0 $theme-color
 
diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue b/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue
index e01f91c24d..87f16211fc 100644
--- a/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue
@@ -1,11 +1,9 @@
 <template>
-<div>
-	<x-column :id="column.id" :name="name">
-		<span slot="header">%fa:bell R%{{ name }}</span>
+<x-column :name="name">
+	<span slot="header">%fa:bell R%{{ name }}</span>
 
-		<x-notifications/>
-	</x-column>
-</div>
+	<x-notifications/>
+</x-column>
 </template>
 
 <script lang="ts">
@@ -19,12 +17,7 @@ export default Vue.extend({
 		XNotifications
 	},
 
-	props: {
-		column: {
-			type: Object,
-			required: true
-		}
-	},
+	inject: ['column'],
 
 	computed: {
 		name(): string {
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
index 1a5075396b..46d56bb055 100644
--- a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
@@ -1,22 +1,20 @@
 <template>
-<div>
-	<x-column :id="column.id" :menu="menu" :name="name">
-		<span slot="header">
-			<template v-if="column.type == 'home'">%fa:home%</template>
-			<template v-if="column.type == 'local'">%fa:R comments%</template>
-			<template v-if="column.type == 'global'">%fa:globe%</template>
-			<template v-if="column.type == 'list'">%fa:list%</template>
-			<span>{{ name }}</span>
-		</span>
+<x-column :menu="menu" :name="name">
+	<span slot="header">
+		<template v-if="column.type == 'home'">%fa:home%</template>
+		<template v-if="column.type == 'local'">%fa:R comments%</template>
+		<template v-if="column.type == 'global'">%fa:globe%</template>
+		<template v-if="column.type == 'list'">%fa:list%</template>
+		<span>{{ name }}</span>
+	</span>
 
-		<div class="editor" v-if="edit">
-			<mk-switch v-model="column.isMediaOnly" @change="onChangeSettings" text="%i18n:@is-media-only%"/>
-			<mk-switch v-model="column.isMediaView" @change="onChangeSettings" text="%i18n:@is-media-view%"/>
-		</div>
-		<x-list-tl v-if="column.type == 'list'" :list="column.list" :media-only="column.isMediaOnly"/>
-		<x-tl v-else :src="column.type" :media-only="column.isMediaOnly"/>
-	</x-column>
-</div>
+	<div class="editor" v-if="edit">
+		<mk-switch v-model="column.isMediaOnly" @change="onChangeSettings" text="%i18n:@is-media-only%"/>
+		<mk-switch v-model="column.isMediaView" @change="onChangeSettings" text="%i18n:@is-media-view%"/>
+	</div>
+	<x-list-tl v-if="column.type == 'list'" :list="column.list" :media-only="column.isMediaOnly"/>
+	<x-tl v-else :src="column.type" :media-only="column.isMediaOnly"/>
+</x-column>
 </template>
 
 <script lang="ts">
@@ -32,12 +30,7 @@ export default Vue.extend({
 		XListTl
 	},
 
-	props: {
-		column: {
-			type: Object,
-			required: true
-		}
-	},
+	inject: ['column'],
 
 	data() {
 		return {
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
index 57ec214c3a..fa2d90ed0e 100644
--- a/src/client/app/desktop/views/pages/deck/deck.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -1,13 +1,13 @@
 <template>
 <mk-ui :class="$style.root">
 	<div class="qlvquzbjribqcaozciifydkngcwtyzje" :data-darkmode="$store.state.device.darkmode">
-		<template v-for="column in columns">
-			<x-widgets-column v-if="column.type == 'widgets'" :key="column.id" :column="column"/>
-			<x-notifications-column v-if="column.type == 'notifications'" :key="column.id" :column="column"/>
-			<x-tl-column v-if="column.type == 'home'" :key="column.id" :column="column"/>
-			<x-tl-column v-if="column.type == 'local'" :key="column.id" :column="column"/>
-			<x-tl-column v-if="column.type == 'global'" :key="column.id" :column="column"/>
-			<x-tl-column v-if="column.type == 'list'" :key="column.id" :column="column"/>
+		<template v-for="ids in layout">
+			<div v-if="ids.length > 1" class="folder">
+				<template v-for="id, i in ids">
+					<x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true" :is-active="i == 0"/>
+				</template>
+			</div>
+			<x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])"/>
 		</template>
 		<button ref="add" @click="add" title="%i18n:common.deck.add-column%">%fa:plus%</button>
 	</div>
@@ -16,27 +16,34 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import XTlColumn from './deck.tl-column.vue';
-import XNotificationsColumn from './deck.notifications-column.vue';
-import XWidgetsColumn from './deck.widgets-column.vue';
+import XColumnCore from './deck.column-core.vue';
 import Menu from '../../../../common/views/components/menu.vue';
 import MkUserListsWindow from '../../components/user-lists-window.vue';
 import * as uuid from 'uuid';
 
 export default Vue.extend({
 	components: {
-		XTlColumn,
-		XNotificationsColumn,
-		XWidgetsColumn
+		XColumnCore
 	},
 
 	computed: {
-		columns() {
+		columns(): any[] {
 			if (this.$store.state.settings.deck == null) return [];
 			return this.$store.state.settings.deck.columns;
+		},
+		layout(): any[] {
+			if (this.$store.state.settings.deck == null) return [];
+			if (this.$store.state.settings.deck.layout == null) return this.$store.state.settings.deck.columns.map(c => [c.id]);
+			return this.$store.state.settings.deck.layout;
 		}
 	},
 
+	provide() {
+		return {
+			getColumnVm: this.getColumnVm
+		};
+	},
+
 	created() {
 		if (this.$store.state.settings.deck == null) {
 			const deck = {
@@ -58,11 +65,23 @@ export default Vue.extend({
 				}]
 			};
 
+			deck.layout = deck.columns.map(c => [c.id]);
+
 			this.$store.dispatch('settings/set', {
 				key: 'deck',
 				value: deck
 			});
 		}
+
+		// 互換性のため
+		if (this.$store.state.settings.deck != null && this.$store.state.settings.deck.layout == null) {
+			this.$store.dispatch('settings/set', {
+				key: 'deck',
+				value: Object.assign({}, this.$store.state.settings.deck, {
+					layout: this.$store.state.settings.deck.columns.map(c => [c.id])
+				})
+			});
+		}
 	},
 
 	mounted() {
@@ -74,6 +93,10 @@ export default Vue.extend({
 	},
 
 	methods: {
+		getColumnVm(id) {
+			return this.$refs[id][0];
+		},
+
 		add() {
 			this.os.new(Menu, {
 				source: this.$refs.add,
@@ -159,6 +182,13 @@ root(isDark)
 		&:last-of-type
 			margin-right 0
 
+		&.folder
+			display flex
+			flex-direction column
+
+			> *:not(:last-child)
+				margin-bottom 8px
+
 	> *
 		&:first-child
 			margin-left auto
diff --git a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue
index 6bcebd07cc..1f8fd8a9bf 100644
--- a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue
@@ -1,57 +1,55 @@
 <template>
-<div class="wtdtxvecapixsepjtcupubtsmometobz">
-	<x-column :id="column.id" :menu="menu" :naked="true" :narrow="true" :name="name">
-		<span slot="header">%fa:calculator%{{ name }}</span>
+<x-column :menu="menu" :naked="true" :narrow="true" :name="name" class="wtdtxvecapixsepjtcupubtsmometobz">
+	<span slot="header">%fa:calculator%{{ name }}</span>
 
-		<div class="gqpwvtwtprsbmnssnbicggtwqhmylhnq">
-			<template v-if="edit">
-				<header>
-					<select v-model="widgetAdderSelected">
-						<option value="profile">%i18n:common.widgets.profile%</option>
-						<option value="analog-clock">%i18n:common.widgets.analog-clock%</option>
-						<option value="calendar">%i18n:common.widgets.calendar%</option>
-						<option value="timemachine">%i18n:common.widgets.timemachine%</option>
-						<option value="activity">%i18n:common.widgets.activity%</option>
-						<option value="rss">%i18n:common.widgets.rss%</option>
-						<option value="trends">%i18n:common.widgets.trends%</option>
-						<option value="photo-stream">%i18n:common.widgets.photo-stream%</option>
-						<option value="slideshow">%i18n:common.widgets.slideshow%</option>
-						<option value="version">%i18n:common.widgets.version%</option>
-						<option value="broadcast">%i18n:common.widgets.broadcast%</option>
-						<option value="notifications">%i18n:common.widgets.notifications%</option>
-						<option value="users">%i18n:common.widgets.users%</option>
-						<option value="polls">%i18n:common.widgets.polls%</option>
-						<option value="post-form">%i18n:common.widgets.post-form%</option>
-						<option value="messaging">%i18n:common.widgets.messaging%</option>
-						<option value="memo">%i18n:common.widgets.memo%</option>
-						<option value="server">%i18n:common.widgets.server%</option>
-						<option value="donation">%i18n:common.widgets.donation%</option>
-						<option value="nav">%i18n:common.widgets.nav%</option>
-						<option value="tips">%i18n:common.widgets.tips%</option>
-					</select>
-					<button @click="addWidget">%i18n:@add%</button>
-				</header>
-				<x-draggable
-					:list="column.widgets"
-					:options="{ handle: '.handle', animation: 150 }"
-					@sort="onWidgetSort"
-				>
-					<div v-for="widget in column.widgets" class="customize-container" :key="widget.id">
-						<header>
-							<span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
-						</header>
-						<div @click="widgetFunc(widget.id)">
-							<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="deck"/>
-						</div>
+	<div class="gqpwvtwtprsbmnssnbicggtwqhmylhnq">
+		<template v-if="edit">
+			<header>
+				<select v-model="widgetAdderSelected">
+					<option value="profile">%i18n:common.widgets.profile%</option>
+					<option value="analog-clock">%i18n:common.widgets.analog-clock%</option>
+					<option value="calendar">%i18n:common.widgets.calendar%</option>
+					<option value="timemachine">%i18n:common.widgets.timemachine%</option>
+					<option value="activity">%i18n:common.widgets.activity%</option>
+					<option value="rss">%i18n:common.widgets.rss%</option>
+					<option value="trends">%i18n:common.widgets.trends%</option>
+					<option value="photo-stream">%i18n:common.widgets.photo-stream%</option>
+					<option value="slideshow">%i18n:common.widgets.slideshow%</option>
+					<option value="version">%i18n:common.widgets.version%</option>
+					<option value="broadcast">%i18n:common.widgets.broadcast%</option>
+					<option value="notifications">%i18n:common.widgets.notifications%</option>
+					<option value="users">%i18n:common.widgets.users%</option>
+					<option value="polls">%i18n:common.widgets.polls%</option>
+					<option value="post-form">%i18n:common.widgets.post-form%</option>
+					<option value="messaging">%i18n:common.widgets.messaging%</option>
+					<option value="memo">%i18n:common.widgets.memo%</option>
+					<option value="server">%i18n:common.widgets.server%</option>
+					<option value="donation">%i18n:common.widgets.donation%</option>
+					<option value="nav">%i18n:common.widgets.nav%</option>
+					<option value="tips">%i18n:common.widgets.tips%</option>
+				</select>
+				<button @click="addWidget">%i18n:@add%</button>
+			</header>
+			<x-draggable
+				:list="column.widgets"
+				:options="{ handle: '.handle', animation: 150 }"
+				@sort="onWidgetSort"
+			>
+				<div v-for="widget in column.widgets" class="customize-container" :key="widget.id">
+					<header>
+						<span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
+					</header>
+					<div @click="widgetFunc(widget.id)">
+						<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="deck"/>
 					</div>
-				</x-draggable>
-			</template>
-			<template v-else>
-				<component class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="deck"/>
-			</template>
-		</div>
-	</x-column>
-</div>
+				</div>
+			</x-draggable>
+		</template>
+		<template v-else>
+			<component class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="deck"/>
+		</template>
+	</div>
+</x-column>
 </template>
 
 <script lang="ts">
@@ -66,12 +64,7 @@ export default Vue.extend({
 		XDraggable
 	},
 
-	props: {
-		column: {
-			type: Object,
-			required: true
-		}
-	},
+	inject: ['column'],
 
 	data() {
 		return {
diff --git a/src/client/app/store.ts b/src/client/app/store.ts
index d51a3abad1..e78d941d8c 100644
--- a/src/client/app/store.ts
+++ b/src/client/app/store.ts
@@ -173,23 +173,22 @@ export default (os: MiOS) => new Vuex.Store({
 				},
 
 				addDeckColumn(state, column) {
-					if (state.deck.columns == null) state.deck.columns = [];
 					state.deck.columns.push(column);
+					state.deck.layout.push([column.id]);
 				},
 
 				removeDeckColumn(state, id) {
-					if (state.deck.columns == null) return;
 					state.deck.columns = state.deck.columns.filter(c => c.id != id);
+					state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
 				},
 
 				swapLeftDeckColumn(state, id) {
-					if (state.deck.columns == null) return;
-					state.deck.columns.some((c, i) => {
-						if (c.id == id) {
-							const left = state.deck.columns[i - 1];
+					state.deck.layout.some((ids, i) => {
+						if (ids.indexOf(id) != -1) {
+							const left = state.deck.layout[i - 1];
 							if (left) {
-								state.deck.columns[i - 1] = state.deck.columns[i];
-								state.deck.columns[i] = left;
+								state.deck.layout[i - 1] = state.deck.layout[i];
+								state.deck.layout[i] = left;
 							}
 							return true;
 						}
@@ -197,28 +196,40 @@ export default (os: MiOS) => new Vuex.Store({
 				},
 
 				swapRightDeckColumn(state, id) {
-					if (state.deck.columns == null) return;
-					state.deck.columns.some((c, i) => {
-						if (c.id == id) {
-							const right = state.deck.columns[i + 1];
+					state.deck.layout.some((ids, i) => {
+						if (ids.indexOf(id) != -1) {
+							const right = state.deck.layout[i + 1];
 							if (right) {
-								state.deck.columns[i + 1] = state.deck.columns[i];
-								state.deck.columns[i] = right;
+								state.deck.layout[i + 1] = state.deck.layout[i];
+								state.deck.layout[i] = right;
 							}
 							return true;
 						}
 					});
 				},
 
+				stackLeftDeckColumn(state, id) {
+					const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
+					state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
+					const left = state.deck.layout[i - 1];
+					if (left) state.deck.layout[i - 1].push(id);
+					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
+				},
+
+				popRightDeckColumn(state, id) {
+					const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
+					state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
+					state.deck.layout.splice(i + 1, 0, [id]);
+					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
+				},
+
 				addDeckWidget(state, x) {
-					if (state.deck.columns == null) return;
 					const column = state.deck.columns.find(c => c.id == x.id);
 					if (column == null) return;
 					column.widgets.unshift(x.widget);
 				},
 
 				removeDeckWidget(state, x) {
-					if (state.deck.columns == null) return;
 					const column = state.deck.columns.find(c => c.id == x.id);
 					if (column == null) return;
 					column.widgets = column.widgets.filter(w => w.id != x.widget.id);
@@ -277,6 +288,16 @@ export default (os: MiOS) => new Vuex.Store({
 					ctx.dispatch('saveDeck');
 				},
 
+				stackLeftDeckColumn(ctx, id) {
+					ctx.commit('stackLeftDeckColumn', id);
+					ctx.dispatch('saveDeck');
+				},
+
+				popRightDeckColumn(ctx, id) {
+					ctx.commit('popRightDeckColumn', id);
+					ctx.dispatch('saveDeck');
+				},
+
 				addDeckWidget(ctx, x) {
 					ctx.commit('addDeckWidget', x);
 					ctx.dispatch('saveDeck');