Skip to content
69 changes: 66 additions & 3 deletions src/components/Main/NavigationBar/AppList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,71 @@
@close="close"
/>
</div>
<div :class="$style.list">
<div ref="containerRef" :class="$style.list">
<AppListItem
v-for="app in apps"
v-for="app in displayApps"
:key="app.label"
class="js-sortable-item"
:data-id="app.label"
:icon-path="app.iconPath"
:label="app.label"
:app-link="app.appLink"
/>

<div
:class="$style.resetButtonContainer"
:style="{
gridColumnStart: resetButtonGridColumnStart
}"
>
<button :class="$style.resetButton" @click="resetOrder">
並び順をリセット
</button>
</div>
</div>
</div>
</ClickOutside>
</template>

<script lang="ts" setup>
import { computed } from 'vue'

import AppListItem from '/@/components/Main/NavigationBar/AppListItem.vue'
import ClickOutside from '/@/components/UI/ClickOutside'
import CloseButton from '/@/components/UI/CloseButton.vue'
import useGridLayout from '/@/composables/dom/useGridLayout'
import { useSortable } from '/@/composables/dom/useSortable'
import { isTouchDevice } from '/@/lib/dom/browser'
import { useAppList } from '/@/store/ui/appList'

const emit = defineEmits<{
(e: 'close'): void
}>()

const apps = window.traQConfig.services ?? []
const { displayApps, resetToDefaultAppOrder, updateAppOrder } = useAppList()
const { containerRef } = useSortable({
store: {
set: sortable => {
updateAppOrder(sortable.toArray())
}
},
delay: isTouchDevice() ? 200 : 0
})
const { columnCount } = useGridLayout(containerRef, { columnCount: 0 })

const close = () => {
emit('close')
}

const resetOrder = async () => {
if (!confirm('本当にサービスの並び順をリセットしますか?')) return
Comment thread
uni-kakurenbo marked this conversation as resolved.
await resetToDefaultAppOrder()
}

const resetButtonGridColumnStart = computed(() => {
if (!columnCount.value) return 'auto'
Comment thread
uni-kakurenbo marked this conversation as resolved.
Comment thread
uni-kakurenbo marked this conversation as resolved.
return (displayApps.value.length % columnCount.value) + 1
})
</script>

<style lang="scss" module>
Expand Down Expand Up @@ -75,4 +113,29 @@ const close = () => {
}
scrollbar-gutter: stable;
}

.resetButtonContainer {
display: flex;
justify-content: end;
align-items: end;
grid-column-end: -1;
}

.resetButton {
@include color-ui-secondary;
@include background-secondary;

cursor: pointer;
font-weight: bold;
border-radius: 4px;
width: 100%;
margin-top: 12px;
padding-top: 6px;
padding-bottom: 6px;

&:hover {
@include color-ui-primary;
@include background-tertiary;
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</div>
<div
v-if="stampIdsModel.length !== 0"
ref="stampListRef"
ref="containerRef"
:class="$style.stampList"
>
<!-- FIXME: スタンプの総数が多い時に重くなる -->
Expand Down Expand Up @@ -43,12 +43,11 @@
</template>

<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'

import Sortable, { type SortableEvent } from 'sortablejs'
import { nextTick, ref, watch } from 'vue'

import AStamp from '/@/components/UI/AStamp.vue'
import IconButton from '/@/components/UI/IconButton.vue'
import { useSortable } from '/@/composables/dom/useSortable'
import type { StampId } from '/@/types/entity-ids'

import StampPaletteEditorLimitIndicator from './StampPaletteEditorLimitIndicator.vue'
Expand All @@ -73,41 +72,14 @@ const removeSelectedStamps = () => {
selectedStampIds.value = []
}

const stampListRef = ref<HTMLElement | null>(null)
let sortableInstance: Sortable | null = null

const setupSortable = () => {
if (sortableInstance) return
if (!stampListRef.value) return
if (stampIdsModel.value.length === 0) return

sortableInstance = Sortable.create(stampListRef.value, {
animation: 150,
draggable: '.js-sortable-item',
onUpdate: (event: SortableEvent) => {
if (
event.newDraggableIndex === undefined ||
event.oldDraggableIndex === undefined
)
return
const newStampIds = [...stampIdsModel.value]
const movedStampId = newStampIds.splice(event.oldDraggableIndex, 1)[0]
if (movedStampId === undefined) return
newStampIds.splice(event.newDraggableIndex, 0, movedStampId)
stampIdsModel.value = newStampIds
const { containerRef, setupSortable, destroySortableInstance } = useSortable({
store: {
set: sortable => {
stampIdsModel.value = sortable.toArray()
}
})
}

const destroySortableInstance = () => {
if (sortableInstance) {
sortableInstance.destroy()
sortableInstance = null
}
}
})

onMounted(setupSortable)
onUnmounted(destroySortableInstance)
watch(
() => stampIdsModel.value.length,
(newLength, oldLength) => {
Expand Down
55 changes: 55 additions & 0 deletions src/composables/dom/useGridLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type ShallowRef, onMounted, onUnmounted, ref } from 'vue'

export const useGridLayout = (
elementRef: ShallowRef<HTMLElement | null>,
fallback?: {
columnCount?: number
rowCount?: number
}
) => {
const columnCount = ref<number>(fallback?.columnCount ?? NaN)
const rowCount = ref<number>(fallback?.rowCount ?? NaN)

let resizeObserver: ResizeObserver | null = null

const updateLayout = () => {
if (!elementRef.value) return

const computedStyle = getComputedStyle(elementRef.value)

rowCount.value = computedStyle
.getPropertyValue('grid-template-rows')
.split(' ')
.filter(size => size !== '0px').length
Comment thread
uni-kakurenbo marked this conversation as resolved.

columnCount.value = computedStyle
.getPropertyValue('grid-template-columns')
.split(' ')
.filter(size => size !== '0px').length
}

onMounted(() => {
if (!elementRef.value) return

resizeObserver = new ResizeObserver(() => {
updateLayout()
})

resizeObserver.observe(elementRef.value)
updateLayout()
})

onUnmounted(() => {
if (!resizeObserver) return

resizeObserver.disconnect()
resizeObserver = null
})

return {
columnCount,
rowCount
}
}

export default useGridLayout
42 changes: 42 additions & 0 deletions src/composables/dom/useSortable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { onMounted, onUnmounted, shallowRef } from 'vue'

import Sortable from 'sortablejs'

type Options = Omit<Sortable.Options, 'store'> & {
store?: {
get?: (sortable: Sortable) => string[]
set?: (sortable: Sortable) => void
}
}

export const useSortable = ({ store, ...options }: Options = {}) => {
const containerRef = shallowRef<HTMLElement | null>(null)
let sortableInstance: Sortable | null = null

const setupSortable = () => {
if (sortableInstance) return
if (!containerRef.value) return

sortableInstance = Sortable.create(containerRef.value, {
animation: 150,
draggable: '.js-sortable-item',
store: {
get: store?.get ?? (() => []),
set: store?.set ?? (() => void 0)
},
...options
})
}

const destroySortableInstance = () => {
if (sortableInstance) {
sortableInstance.destroy()
sortableInstance = null
}
}

onMounted(setupSortable)
onUnmounted(destroySortableInstance)

return { setupSortable, destroySortableInstance, containerRef }
}
72 changes: 72 additions & 0 deletions src/store/ui/appList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { computed, toRefs } from 'vue'

import { acceptHMRUpdate, defineStore } from 'pinia'

import useIndexedDbValue from '/@/composables/utils/useIndexedDbValue'
import { convertToRefsStore } from '/@/store/utils/convertToRefsStore'

export interface AppItem {
label: string
iconPath: string
appLink: string
}

type State = {
customOrder: string[]
}

const useAppListPinia = defineStore('ui/appList', () => {
const services = window.traQConfig.services ?? []

const initialValue: State = {
customOrder: []
}

const [state, restoring, restoringPromise] = useIndexedDbValue(
'store/ui/appList',
1,
{},
initialValue
)

const updateAppOrder = async (newOrder: ReadonlyArray<string>) => {
await restoringPromise.value
state.customOrder = [...newOrder]
}

const resetToDefaultAppOrder = async () => {
await restoringPromise.value
state.customOrder = []
}

const displayApps = computed((): ReadonlyArray<AppItem> => {
if (state.customOrder.length <= 0) return services ?? []

return [
...state.customOrder
.map(item => services?.find(({ label }) => label === item))
.filter((item): item is AppItem => !!item),
...services.filter(({ label }) => !state.customOrder.includes(label))
]
})

const hasCustomOrder = computed(() => {
return state.customOrder.length > 0
})

return {
...toRefs(state),
restoring,
restoringPromise,
updateAppOrder,
resetToDefaultAppOrder,
displayApps,
hasCustomOrder
}
})

export const useAppList = convertToRefsStore(useAppListPinia)

if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAppListPinia, import.meta.hot))
}