diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/directives | |
| parent | wip: retention for dashboard (diff) | |
| download | sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/directives')
| -rw-r--r-- | packages/frontend/src/directives/adaptive-border.ts | 24 | ||||
| -rw-r--r-- | packages/frontend/src/directives/anim.ts | 18 | ||||
| -rw-r--r-- | packages/frontend/src/directives/appear.ts | 22 | ||||
| -rw-r--r-- | packages/frontend/src/directives/click-anime.ts | 31 | ||||
| -rw-r--r-- | packages/frontend/src/directives/follow-append.ts | 35 | ||||
| -rw-r--r-- | packages/frontend/src/directives/get-size.ts | 54 | ||||
| -rw-r--r-- | packages/frontend/src/directives/hotkey.ts | 24 | ||||
| -rw-r--r-- | packages/frontend/src/directives/index.ts | 28 | ||||
| -rw-r--r-- | packages/frontend/src/directives/panel.ts | 24 | ||||
| -rw-r--r-- | packages/frontend/src/directives/ripple.ts | 18 | ||||
| -rw-r--r-- | packages/frontend/src/directives/size.ts | 123 | ||||
| -rw-r--r-- | packages/frontend/src/directives/tooltip.ts | 93 | ||||
| -rw-r--r-- | packages/frontend/src/directives/user-preview.ts | 118 |
13 files changed, 612 insertions, 0 deletions
diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts new file mode 100644 index 0000000000..619c9f0b6d --- /dev/null +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = window.getComputedStyle(src).backgroundColor; + + if (parentBg === myBg) { + src.style.borderColor = 'var(--divider)'; + } else { + src.style.borderColor = myBg; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/anim.ts b/packages/frontend/src/directives/anim.ts new file mode 100644 index 0000000000..04e1c6a404 --- /dev/null +++ b/packages/frontend/src/directives/anim.ts @@ -0,0 +1,18 @@ +import { Directive } from 'vue'; + +export default { + beforeMount(src, binding, vn) { + src.style.opacity = '0'; + src.style.transform = 'scale(0.9)'; + // ページネーションと相性が悪いので + //if (typeof binding.value === 'number') src.style.transitionDelay = `${binding.value * 30}ms`; + src.classList.add('_zoom'); + }, + + mounted(src, binding, vn) { + window.setTimeout(() => { + src.style.opacity = '1'; + src.style.transform = 'none'; + }, 1); + }, +} as Directive; diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts new file mode 100644 index 0000000000..7fa43fc34a --- /dev/null +++ b/packages/frontend/src/directives/appear.ts @@ -0,0 +1,22 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const fn = binding.value; + if (fn == null) return; + + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + fn(); + } + }); + + observer.observe(src); + + src._observer_ = observer; + }, + + unmounted(src, binding, vn) { + if (src._observer_) src._observer_.disconnect(); + }, +} as Directive; diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts new file mode 100644 index 0000000000..e2f514b7ca --- /dev/null +++ b/packages/frontend/src/directives/click-anime.ts @@ -0,0 +1,31 @@ +import { Directive } from 'vue'; +import { defaultStore } from '@/store'; + +export default { + mounted(el, binding, vn) { + /* + if (!defaultStore.state.animation) return; + + el.classList.add('_anime_bounce_standBy'); + + el.addEventListener('mousedown', () => { + el.classList.add('_anime_bounce_standBy'); + el.classList.add('_anime_bounce_ready'); + + el.addEventListener('mouseleave', () => { + el.classList.remove('_anime_bounce_ready'); + }); + }); + + el.addEventListener('click', () => { + el.classList.add('_anime_bounce'); + }); + + el.addEventListener('animationend', () => { + el.classList.remove('_anime_bounce_ready'); + el.classList.remove('_anime_bounce'); + el.classList.add('_anime_bounce_standBy'); + }); + */ + }, +} as Directive; diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts new file mode 100644 index 0000000000..62e0ac3b94 --- /dev/null +++ b/packages/frontend/src/directives/follow-append.ts @@ -0,0 +1,35 @@ +import { Directive } from 'vue'; +import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; + +export default { + mounted(src, binding, vn) { + if (binding.value === false) return; + + let isBottom = true; + + const container = getScrollContainer(src)!; + container.addEventListener('scroll', () => { + const pos = getScrollPosition(container); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + isBottom = (pos + viewHeight > height - 32); + }, { passive: true }); + container.scrollTop = container.scrollHeight; + + const ro = new ResizeObserver((entries, observer) => { + if (isBottom) { + const height = container.scrollHeight; + container.scrollTop = height; + } + }); + + ro.observe(src); + + // TODO: 新たにプロパティを作るのをやめMapを使う + src._ro_ = ro; + }, + + unmounted(src, binding, vn) { + if (src._ro_) src._ro_.unobserve(src); + }, +} as Directive; diff --git a/packages/frontend/src/directives/get-size.ts b/packages/frontend/src/directives/get-size.ts new file mode 100644 index 0000000000..ff3bdd78ac --- /dev/null +++ b/packages/frontend/src/directives/get-size.ts @@ -0,0 +1,54 @@ +import { Directive } from 'vue'; + +const mountings = new Map<Element, { + resize: ResizeObserver; + intersection?: IntersectionObserver; + fn: (w: number, h: number) => void; +}>(); + +function calc(src: Element) { + const info = mountings.get(src); + const height = src.clientHeight; + const width = src.clientWidth; + + if (!info) return; + + // アクティベート前などでsrcが描画されていない場合 + if (!height) { + // IntersectionObserverで表示検出する + if (!info.intersection) { + info.intersection = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) calc(src); + }); + } + info.intersection.observe(src); + return; + } + if (info.intersection) { + info.intersection.disconnect(); + delete info.intersection; + } + + info.fn(width, height); +} + +export default { + mounted(src, binding, vn) { + const resize = new ResizeObserver((entries, observer) => { + calc(src); + }); + resize.observe(src); + + mountings.set(src, { resize, fn: binding.value }); + calc(src); + }, + + unmounted(src, binding, vn) { + binding.value(0, 0); + const info = mountings.get(src); + if (!info) return; + info.resize.disconnect(); + if (info.intersection) info.intersection.disconnect(); + mountings.delete(src); + }, +} as Directive<Element, (w: number, h: number) => void>; diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts new file mode 100644 index 0000000000..dfc5f646a4 --- /dev/null +++ b/packages/frontend/src/directives/hotkey.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; +import { makeHotkey } from '../scripts/hotkey'; + +export default { + mounted(el, binding) { + el._hotkey_global = binding.modifiers.global === true; + + el._keyHandler = makeHotkey(binding.value); + + if (el._hotkey_global) { + document.addEventListener('keydown', el._keyHandler); + } else { + el.addEventListener('keydown', el._keyHandler); + } + }, + + unmounted(el) { + if (el._hotkey_global) { + document.removeEventListener('keydown', el._keyHandler); + } else { + el.removeEventListener('keydown', el._keyHandler); + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts new file mode 100644 index 0000000000..401a917cba --- /dev/null +++ b/packages/frontend/src/directives/index.ts @@ -0,0 +1,28 @@ +import { App } from 'vue'; + +import userPreview from './user-preview'; +import size from './size'; +import getSize from './get-size'; +import ripple from './ripple'; +import tooltip from './tooltip'; +import hotkey from './hotkey'; +import appear from './appear'; +import anim from './anim'; +import clickAnime from './click-anime'; +import panel from './panel'; +import adaptiveBorder from './adaptive-border'; + +export default function(app: App) { + app.directive('userPreview', userPreview); + app.directive('user-preview', userPreview); + app.directive('size', size); + app.directive('get-size', getSize); + app.directive('ripple', ripple); + app.directive('tooltip', tooltip); + app.directive('hotkey', hotkey); + app.directive('appear', appear); + app.directive('anim', anim); + app.directive('click-anime', clickAnime); + app.directive('panel', panel); + app.directive('adaptive-border', adaptiveBorder); +} diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts new file mode 100644 index 0000000000..d31dc41ed4 --- /dev/null +++ b/packages/frontend/src/directives/panel.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel'); + + if (parentBg === myBg) { + src.style.backgroundColor = 'var(--bg)'; + } else { + src.style.backgroundColor = 'var(--panel)'; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts new file mode 100644 index 0000000000..d32f7ab441 --- /dev/null +++ b/packages/frontend/src/directives/ripple.ts @@ -0,0 +1,18 @@ +import Ripple from '@/components/MkRipple.vue'; +import { popup } from '@/os'; + +export default { + mounted(el, binding, vn) { + // 明示的に false であればバインドしない + if (binding.value === false) return; + + el.addEventListener('click', () => { + const rect = el.getBoundingClientRect(); + + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + + popup(Ripple, { x, y }, {}, 'end'); + }); + }, +}; diff --git a/packages/frontend/src/directives/size.ts b/packages/frontend/src/directives/size.ts new file mode 100644 index 0000000000..da8bd78ea1 --- /dev/null +++ b/packages/frontend/src/directives/size.ts @@ -0,0 +1,123 @@ +import { Directive } from 'vue'; + +type Value = { max?: number[]; min?: number[]; }; + +//const observers = new Map<Element, ResizeObserver>(); +const mountings = new Map<Element, { + value: Value; + resize: ResizeObserver; + intersection?: IntersectionObserver; + previousWidth: number; + twoPreviousWidth: number; +}>(); + +type ClassOrder = { + add: string[]; + remove: string[]; +}; + +const isContainerQueriesSupported = ('container' in document.documentElement.style); + +const cache = new Map<string, ClassOrder>(); + +function getClassOrder(width: number, queue: Value): ClassOrder { + const getMaxClass = (v: number) => `max-width_${v}px`; + const getMinClass = (v: number) => `min-width_${v}px`; + + return { + add: [ + ...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []), + ], + remove: [ + ...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []), + ], + }; +} + +function applyClassOrder(el: Element, order: ClassOrder) { + el.classList.add(...order.add); + el.classList.remove(...order.remove); +} + +function getOrderName(width: number, queue: Value): string { + return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`; +} + +function calc(el: Element) { + const info = mountings.get(el); + const width = el.clientWidth; + + if (!info || info.previousWidth === width) return; + + // アクティベート前などでsrcが描画されていない場合 + if (!width) { + // IntersectionObserverで表示検出する + if (!info.intersection) { + info.intersection = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) calc(el); + }); + } + info.intersection.observe(el); + return; + } + if (info.intersection) { + info.intersection.disconnect(); + delete info.intersection; + } + + mountings.set(el, { ...info, ...{ previousWidth: width, twoPreviousWidth: info.previousWidth }}); + + // Prevent infinite resizing + // https://github.com/misskey-dev/misskey/issues/9076 + if (info.twoPreviousWidth === width) { + return; + } + + const cached = cache.get(getOrderName(width, info.value)); + if (cached) { + applyClassOrder(el, cached); + } else { + const order = getClassOrder(width, info.value); + cache.set(getOrderName(width, info.value), order); + applyClassOrder(el, order); + } +} + +export default { + mounted(src, binding, vn) { + if (isContainerQueriesSupported) return; + + const resize = new ResizeObserver((entries, observer) => { + calc(src); + }); + + mountings.set(src, { + value: binding.value, + resize, + previousWidth: 0, + twoPreviousWidth: 0, + }); + + calc(src); + resize.observe(src); + }, + + updated(src, binding, vn) { + if (isContainerQueriesSupported) return; + + mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value })); + calc(src); + }, + + unmounted(src, binding, vn) { + if (isContainerQueriesSupported) return; + + const info = mountings.get(src); + if (!info) return; + info.resize.disconnect(); + if (info.intersection) info.intersection.disconnect(); + mountings.delete(src); + }, +} as Directive<Element, Value>; diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts new file mode 100644 index 0000000000..5d13497b5f --- /dev/null +++ b/packages/frontend/src/directives/tooltip.ts @@ -0,0 +1,93 @@ +// TODO: useTooltip関数使うようにしたい +// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明 + +import { defineAsyncComponent, Directive, ref } from 'vue'; +import { isTouchUsing } from '@/scripts/touch'; +import { popup, alert } from '@/os'; + +const start = isTouchUsing ? 'touchstart' : 'mouseover'; +const end = isTouchUsing ? 'touchend' : 'mouseleave'; + +export default { + mounted(el: HTMLElement, binding, vn) { + const delay = binding.modifiers.noDelay ? 0 : 100; + + const self = (el as any)._tooltipDirective_ = {} as any; + + self.text = binding.value as string; + self._close = null; + self.showTimer = null; + self.hideTimer = null; + self.checkTimer = null; + + self.close = () => { + if (self._close) { + window.clearInterval(self.checkTimer); + self._close(); + self._close = null; + } + }; + + if (binding.arg === 'dialog') { + el.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + alert({ + type: 'info', + text: binding.value, + }); + return false; + }); + } + + self.show = () => { + if (!document.body.contains(el)) return; + if (self._close) return; + if (self.text == null) return; + + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { + showing, + text: self.text, + asMfm: binding.modifiers.mfm, + direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top', + targetElement: el, + }, {}, 'closed'); + + self._close = () => { + showing.value = false; + }; + }; + + el.addEventListener('selectstart', ev => { + ev.preventDefault(); + }); + + el.addEventListener(start, () => { + window.clearTimeout(self.showTimer); + window.clearTimeout(self.hideTimer); + self.showTimer = window.setTimeout(self.show, delay); + }, { passive: true }); + + el.addEventListener(end, () => { + window.clearTimeout(self.showTimer); + window.clearTimeout(self.hideTimer); + self.hideTimer = window.setTimeout(self.close, delay); + }, { passive: true }); + + el.addEventListener('click', () => { + window.clearTimeout(self.showTimer); + self.close(); + }); + }, + + updated(el, binding) { + const self = el._tooltipDirective_; + self.text = binding.value as string; + }, + + unmounted(el, binding, vn) { + const self = el._tooltipDirective_; + window.clearInterval(self.checkTimer); + }, +} as Directive; diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts new file mode 100644 index 0000000000..ed5f00ca65 --- /dev/null +++ b/packages/frontend/src/directives/user-preview.ts @@ -0,0 +1,118 @@ +import { defineAsyncComponent, Directive, ref } from 'vue'; +import autobind from 'autobind-decorator'; +import { popup } from '@/os'; + +export class UserPreview { + private el; + private user; + private showTimer; + private hideTimer; + private checkTimer; + private promise; + + constructor(el, user) { + this.el = el; + this.user = user; + + this.attach(); + } + + @autobind + private show() { + if (!document.body.contains(this.el)) return; + if (this.promise) return; + + const showing = ref(true); + + popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), { + showing, + q: this.user, + source: this.el, + }, { + mouseover: () => { + window.clearTimeout(this.hideTimer); + }, + mouseleave: () => { + window.clearTimeout(this.showTimer); + this.hideTimer = window.setTimeout(this.close, 500); + }, + }, 'closed'); + + this.promise = { + cancel: () => { + showing.value = false; + }, + }; + + this.checkTimer = window.setInterval(() => { + if (!document.body.contains(this.el)) { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.close(); + } + }, 1000); + } + + @autobind + private close() { + if (this.promise) { + window.clearInterval(this.checkTimer); + this.promise.cancel(); + this.promise = null; + } + } + + @autobind + private onMouseover() { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.showTimer = window.setTimeout(this.show, 500); + } + + @autobind + private onMouseleave() { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.hideTimer = window.setTimeout(this.close, 500); + } + + @autobind + private onClick() { + window.clearTimeout(this.showTimer); + this.close(); + } + + @autobind + public attach() { + this.el.addEventListener('mouseover', this.onMouseover); + this.el.addEventListener('mouseleave', this.onMouseleave); + this.el.addEventListener('click', this.onClick); + } + + @autobind + public detach() { + this.el.removeEventListener('mouseover', this.onMouseover); + this.el.removeEventListener('mouseleave', this.onMouseleave); + this.el.removeEventListener('click', this.onClick); + window.clearInterval(this.checkTimer); + } +} + +export default { + mounted(el: HTMLElement, binding, vn) { + if (binding.value == null) return; + + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + const self = (el as any)._userPreviewDirective_ = {} as any; + + self.preview = new UserPreview(el, binding.value); + }, + + unmounted(el, binding, vn) { + if (binding.value == null) return; + + const self = el._userPreviewDirective_; + self.preview.detach(); + }, +} as Directive; |