123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import { onKeyDown, useEventListener, whenever } from '@vueuse/core'
- import { storeToRefs } from 'pinia'
- import { useTemplateRef } from 'vue'
- import { computed, nextTick, type Ref, ref, toRef } from 'vue'
- import type { EditorButton } from '#shared/components/Form/fields/FieldEditor/useEditorActions.ts'
- import {
- getFieldEditorClasses,
- getFieldEditorProps,
- } from '#shared/components/Form/initializeFieldEditor.ts'
- import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
- import stopEvent from '#shared/utils/events.ts'
- // eslint-disable-next-line import/no-restricted-paths
- import { useThemeStore } from '#desktop/stores/theme.ts'
- import type { Editor } from '@tiptap/core'
- interface Props {
- actions: EditorButton[]
- editor?: Editor
- visible?: boolean
- isActive?: (type: string, attributes?: Record<string, unknown>) => boolean
- noGradient?: boolean
- }
- const actionBar = useTemplateRef('action-bar')
- const props = withDefaults(defineProps<Props>(), {
- visible: true,
- })
- const editor = toRef(props, 'editor')
- const emit = defineEmits<{
- hide: []
- blur: []
- 'click-action': [EditorButton, MouseEvent]
- }>()
- const classes = getFieldEditorClasses()
- const opacityGradientEnd = ref('0')
- const opacityGradientStart = ref('0')
- const restoreScroll = () => {
- const menuBar = actionBar.value as HTMLElement
- // restore scroll position, if needed
- menuBar.scroll(0, 0)
- }
- const hideAfterLeaving = () => {
- restoreScroll()
- emit('hide')
- }
- const recalculateOpacity = () => {
- const target = actionBar.value
- if (!target) {
- return
- }
- const scrollMin = 40
- const bottomMax = target.scrollWidth - target.clientWidth
- const bottomMin = bottomMax - scrollMin
- const { scrollLeft } = target
- opacityGradientStart.value = Math.min(1, scrollLeft / scrollMin).toFixed(2)
- const opacityPart = (scrollLeft - bottomMin) / scrollMin
- opacityGradientEnd.value = Math.min(1, 1 - opacityPart).toFixed(2)
- }
- const editorProps = getFieldEditorProps()
- useTraverseOptions(actionBar, { direction: 'horizontal', ignoreTabindex: true })
- onKeyDown(
- 'Escape',
- (e) => {
- stopEvent(e)
- emit('blur')
- },
- { target: actionBar as Ref<EventTarget> },
- )
- useEventListener('click', (e) => {
- if (!actionBar.value) return
- const target = e.target as HTMLElement
- if (!actionBar.value.contains(target) && !editor.value?.isFocused) {
- restoreScroll()
- emit('hide')
- }
- })
- whenever(
- () => props.visible,
- () => nextTick(recalculateOpacity),
- )
- const { currentTheme } = storeToRefs(useThemeStore())
- const checkCurrentTheme = (currentTheme: 'dark' | 'light' | 'auto') => {
- let theme: 'dark' | 'light' = 'dark'
- if (currentTheme === 'light') {
- theme = 'light'
- }
- if (currentTheme === 'auto') {
- const rootElement = document.documentElement
- theme = rootElement.getAttribute('data-theme') as 'dark' | 'light'
- }
- return theme
- }
- // Css lint rule for v-bind expects this naming convention
- const leftgradientbeforebackground = computed(() => {
- const theme = checkCurrentTheme(currentTheme.value)
- return theme === 'dark'
- ? classes.actionBar.leftGradient.before.background.dark
- : classes.actionBar.leftGradient.before.background.light
- })
- // Css lint rule for v-bind expects this naming convention
- const rightgradientbeforebackground = computed(() => {
- const theme = checkCurrentTheme(currentTheme.value)
- return theme === 'dark'
- ? classes.actionBar.rightGradient.before.background.dark
- : classes.actionBar.rightGradient.before.background.light
- })
- // Css lint rule for v-bind expects this naming convention
- const beforegradienttop = computed(
- () => classes.actionBar.shadowGradient.before.top,
- )
- // Css lint rule for v-bind expects this naming convention
- const beforegradientheight = computed(
- () => classes.actionBar.shadowGradient.before.height,
- )
- // Css lint rule for v-bind expects this naming convention
- const leftgradientvalue = computed(() => classes.actionBar.leftGradient.left)
- </script>
- <template>
- <div class="relative">
- <!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
- <div
- ref="action-bar"
- data-test-id="action-bar"
- class="Menubar relative flex max-w-full overflow-x-auto overflow-y-hidden"
- :class="[classes.actionBar.buttonContainer]"
- role="toolbar"
- tabindex="0"
- @keydown.tab="hideAfterLeaving"
- @scroll.passive="recalculateOpacity"
- >
- <template v-for="action in actions" :key="action.name">
- <button
- :title="action.label || action.name"
- type="button"
- :class="[
- classes.actionBar.button.base,
- action.class,
- {
- [classes.actionBar.button.active]: isActive?.(
- action.name,
- action.attributes,
- ),
- 'color-indicator': action.name === 'textColor',
- },
- ]"
- :disabled="action.disabled"
- :style="{
- '--color-indicator-background': editor?.getAttributes('textStyle')
- ?.color
- ? editor.getAttributes('textStyle').color
- : '#ffffff',
- }"
- :aria-label="action.label || action.name"
- :aria-pressed="isActive?.(action.name, action.attributes)"
- tabindex="-1"
- @click="
- (event) => {
- action.command?.(event)
- $emit('click-action', action, event)
- }
- "
- >
- <CommonIcon
- :name="action.icon"
- :size="editorProps.actionBar.button.icon.size"
- decorative
- />
- </button>
- <div v-if="action.showDivider">
- <hr class="h-full w-px border-0 bg-neutral-100 dark:bg-gray-900" />
- </div>
- </template>
- </div>
- <template v-if="!props.noGradient">
- <div
- class="ShadowGradient LeftGradient"
- :style="{ opacity: opacityGradientStart }"
- />
- <div
- class="ShadowGradient RightGradient"
- :style="{ opacity: opacityGradientEnd }"
- />
- </template>
- </div>
- </template>
- <style lang="postcss" scoped>
- .Menubar {
- -ms-overflow-style: none; /* Internet Explorer 10+ */
- scrollbar-width: none; /* Firefox */
- &::-webkit-scrollbar {
- display: none; /* Safari and Chrome */
- }
- }
- .ShadowGradient {
- @apply absolute h-full w-8;
- }
- .ShadowGradient::before {
- border-radius: 0 0 0.5rem;
- content: '';
- position: absolute;
- top: v-bind(beforegradienttop);
- height: v-bind(beforegradientheight);
- pointer-events: none;
- }
- .LeftGradient::before {
- border-radius: 0 0 0 0.5rem;
- left: v-bind(leftgradientvalue);
- right: 0;
- background: v-bind(leftgradientbeforebackground);
- }
- .RightGradient {
- right: 0;
- }
- .RightGradient::before {
- right: 0;
- left: 0;
- background: v-bind(rightgradientbeforebackground);
- }
- .color-indicator {
- --color-indicator-background: transparent;
- @apply relative;
- &::before {
- content: '';
- background: var(--color-indicator-background) !important;
- @apply absolute bottom-1 left-1/2 h-0.5 w-1/3 -translate-x-1/2 rounded-full bg-black;
- }
- }
- </style>
|