mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-31 17:49:14 +00:00
Compare commits
2 Commits
refactor/n
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7deb337683 | ||
|
|
ef477d0381 |
@@ -333,7 +333,7 @@ test.describe('Settings', () => {
|
||||
await editKeybindingButton.click()
|
||||
|
||||
// Set new keybinding
|
||||
const input = comfyPage.page.getByPlaceholder('Press keys for new binding')
|
||||
const input = comfyPage.page.getByPlaceholder('Enter your keybind')
|
||||
await input.press('Alt+n')
|
||||
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
@@ -345,7 +345,7 @@ test.describe('Settings', () => {
|
||||
|
||||
// Save keybinding
|
||||
const saveButton = comfyPage.page
|
||||
.getByLabel('New Blank Workflow')
|
||||
.getByLabel('Modify keybinding')
|
||||
.getByText('Save')
|
||||
await saveButton.click()
|
||||
|
||||
|
||||
@@ -25,15 +25,13 @@
|
||||
class: {
|
||||
'p-3 rounded-lg': true,
|
||||
'pointer-events-none':
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
},
|
||||
style: {
|
||||
color: 'var(--fg-color)',
|
||||
backgroundColor:
|
||||
bottomPanelStore.bottomPanelTabs.length === 1,
|
||||
'bg-secondary-background text-secondary-foreground':
|
||||
x.context.active &&
|
||||
bottomPanelStore.bottomPanelTabs.length > 1,
|
||||
'text-muted-foreground':
|
||||
!x.context.active ||
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
? ''
|
||||
: 'var(--bg-color)'
|
||||
bottomPanelStore.bottomPanelTabs.length <= 1
|
||||
}
|
||||
})
|
||||
"
|
||||
@@ -127,4 +125,8 @@ const closeBottomPanel = () => {
|
||||
:deep(.p-tablist-active-bar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.p-tab-active) {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,7 +59,15 @@
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="truncate" :title="slotProps.data.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 truncate"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
|
||||
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
|
||||
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
|
||||
/>
|
||||
{{ slotProps.data.label }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -93,44 +101,6 @@
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="editDialogVisible"
|
||||
class="min-w-96"
|
||||
modal
|
||||
:header="currentEditingCommand?.label"
|
||||
@hide="cancelEdit"
|
||||
>
|
||||
<div>
|
||||
<InputText
|
||||
ref="keybindingInput"
|
||||
class="mb-2 text-center"
|
||||
:model-value="newBindingKeyCombo?.toString() ?? ''"
|
||||
:placeholder="$t('g.pressKeysForNewBinding')"
|
||||
autocomplete="off"
|
||||
fluid
|
||||
@keydown.stop.prevent="captureKeybinding"
|
||||
/>
|
||||
<Message v-if="existingKeybindingOnCombo" severity="warn">
|
||||
{{ $t('g.keybindingAlreadyExists') }}
|
||||
<Tag
|
||||
severity="secondary"
|
||||
:value="existingKeybindingOnCombo.commandId"
|
||||
/>
|
||||
</Message>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
:variant="existingKeybindingOnCombo ? 'destructive' : 'primary'"
|
||||
autofocus
|
||||
@click="saveKeybinding"
|
||||
>
|
||||
<i
|
||||
:class="existingKeybindingOnCombo ? 'pi pi-pencil' : 'pi pi-check'"
|
||||
/>
|
||||
{{ existingKeybindingOnCombo ? $t('g.overwrite') : $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Button
|
||||
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
|
||||
class="mt-4 w-full"
|
||||
@@ -147,18 +117,14 @@
|
||||
import { FilterMatchMode } from '@primevue/core/api'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -195,50 +161,16 @@ const commandsData = computed<ICommandData[]>(() => {
|
||||
})
|
||||
|
||||
const selectedCommandData = ref<ICommandData | null>(null)
|
||||
const editDialogVisible = ref(false)
|
||||
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
|
||||
const currentEditingCommand = ref<ICommandData | null>(null)
|
||||
const keybindingInput = ref<InstanceType<typeof InputText> | null>(null)
|
||||
|
||||
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
|
||||
if (!currentEditingCommand.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If the new keybinding is the same as the current editing command, then don't show the error
|
||||
if (
|
||||
currentEditingCommand.value.keybinding?.combo?.equals(
|
||||
newBindingKeyCombo.value
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!newBindingKeyCombo.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return keybindingStore.getKeybinding(newBindingKeyCombo.value)
|
||||
})
|
||||
const editKeybindingDialog = useEditKeybindingDialog()
|
||||
|
||||
function editKeybinding(commandData: ICommandData) {
|
||||
currentEditingCommand.value = commandData
|
||||
newBindingKeyCombo.value = commandData.keybinding
|
||||
? commandData.keybinding.combo
|
||||
: null
|
||||
editDialogVisible.value = true
|
||||
editKeybindingDialog.show({
|
||||
commandId: commandData.id,
|
||||
commandLabel: commandData.label,
|
||||
currentCombo: commandData.keybinding?.combo ?? null
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (editDialogVisible.value) {
|
||||
// nextTick doesn't work here, so we use a timeout instead
|
||||
setTimeout(() => {
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
keybindingInput.value?.$el?.focus()
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
async function removeKeybinding(commandData: ICommandData) {
|
||||
if (commandData.keybinding) {
|
||||
keybindingStore.unsetKeybinding(commandData.keybinding)
|
||||
@@ -246,40 +178,6 @@ async function removeKeybinding(commandData: ICommandData) {
|
||||
}
|
||||
}
|
||||
|
||||
async function captureKeybinding(event: KeyboardEvent) {
|
||||
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
|
||||
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
cancelEdit()
|
||||
return
|
||||
case 'Enter':
|
||||
await saveKeybinding()
|
||||
return
|
||||
}
|
||||
}
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
newBindingKeyCombo.value = keyCombo
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editDialogVisible.value = false
|
||||
currentEditingCommand.value = null
|
||||
newBindingKeyCombo.value = null
|
||||
}
|
||||
|
||||
async function saveKeybinding() {
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
|
||||
await keybindingService.persistUserKeybindings()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex w-96 flex-col border-t border-border-default px-4">
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{{ $t('g.setAKeybindingForTheFollowing') }}
|
||||
</p>
|
||||
<div class="mb-4 text-sm text-base-foreground">
|
||||
{{ commandLabel }}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="text-foreground mb-4 w-full rounded-sm border border-border-default bg-secondary-background px-3 py-2 text-center shadow-none focus:outline-none"
|
||||
:value="dialogState.newCombo?.toString() ?? ''"
|
||||
:placeholder="$t('g.enterYourKeybind')"
|
||||
:aria-label="$t('g.enterYourKeybind')"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@keydown.stop.prevent="captureKeybinding"
|
||||
/>
|
||||
<div class="min-h-12">
|
||||
<p
|
||||
v-if="dialogState.newCombo?.isBrowserReserved"
|
||||
class="m-0 text-sm text-destructive-background"
|
||||
>
|
||||
{{ $t('g.browserReservedKeybinding') }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="existingKeybindingOnCombo"
|
||||
class="m-0 text-sm text-destructive-background"
|
||||
>
|
||||
{{ $t('g.keybindingAlreadyExists') }}
|
||||
{{ existingKeybindingOnCombo.commandId }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
|
||||
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
|
||||
|
||||
const { dialogState, onUpdateCombo, existingKeybindingOnCombo } = defineProps<{
|
||||
dialogState: EditKeybindingDialogState
|
||||
commandLabel: string
|
||||
onUpdateCombo: (combo: KeyComboImpl) => void
|
||||
existingKeybindingOnCombo: KeybindingImpl | null
|
||||
}>()
|
||||
|
||||
function captureKeybinding(event: KeyboardEvent) {
|
||||
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
if (event.key === 'Escape') return
|
||||
}
|
||||
onUpdateCombo(KeyComboImpl.fromEvent(event))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex w-full justify-end gap-2 px-4 py-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="md"
|
||||
class="text-muted-foreground"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="
|
||||
existingKeybindingOnCombo
|
||||
? 'destructive'
|
||||
: dialogState.newCombo?.isBrowserReserved
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
"
|
||||
size="md"
|
||||
:disabled="!dialogState.newCombo"
|
||||
class="px-4 py-2"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{
|
||||
existingKeybindingOnCombo
|
||||
? $t('g.overwrite')
|
||||
: dialogState.newCombo?.isBrowserReserved
|
||||
? $t('g.saveAnyway')
|
||||
: $t('g.save')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Reactive } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
|
||||
import { DIALOG_KEY } from '@/composables/useEditKeybindingDialog'
|
||||
|
||||
const { dialogState, existingKeybindingOnCombo } = defineProps<{
|
||||
dialogState: Reactive<EditKeybindingDialogState>
|
||||
existingKeybindingOnCombo: KeybindingImpl | null
|
||||
}>()
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function handleCancel() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const combo = dialogState.newCombo
|
||||
const commandId = dialogState.commandId
|
||||
if (!combo || !commandId) return
|
||||
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center gap-2 p-4">
|
||||
<p class="m-0 font-semibold">{{ $t('g.modifyKeybinding') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
57
src/components/ui/chart/ChartBar.stories.ts
Normal file
57
src/components/ui/chart/ChartBar.stories.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ChartBar from './ChartBar.vue'
|
||||
|
||||
const meta: Meta<typeof ChartBar> = {
|
||||
title: 'Components/Chart/ChartBar',
|
||||
component: ChartBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Bar chart example',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'BarName1',
|
||||
data: [10, 50, 35, 75],
|
||||
backgroundColor: '#ff8000'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleDatasets: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Bar chart with multiple datasets',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Series 1',
|
||||
data: [30, 60, 45, 80],
|
||||
backgroundColor: '#ff8000'
|
||||
},
|
||||
{
|
||||
label: 'Series 2',
|
||||
data: [50, 40, 70, 20],
|
||||
backgroundColor: '#4ade80'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/chart/ChartBar.vue
Normal file
34
src/components/ui/chart/ChartBar.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'bar'>
|
||||
options?: ChartOptions<'bar'>
|
||||
ariaLabel: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('bar'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
76
src/components/ui/chart/ChartLine.stories.ts
Normal file
76
src/components/ui/chart/ChartLine.stories.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ChartLine from './ChartLine.vue'
|
||||
|
||||
const meta: Meta<typeof ChartLine> = {
|
||||
title: 'Components/Chart/ChartLine',
|
||||
component: ChartLine,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Line chart example',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'LineName1',
|
||||
data: [10, 45, 25, 80],
|
||||
borderColor: '#4ade80',
|
||||
borderDash: [5, 5],
|
||||
fill: true,
|
||||
backgroundColor: '#4ade8033',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleLines: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Line chart with multiple lines',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'LineName1',
|
||||
data: [10, 45, 25, 80],
|
||||
borderColor: '#4ade80',
|
||||
borderDash: [5, 5],
|
||||
fill: true,
|
||||
backgroundColor: '#4ade8033',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'LineName2',
|
||||
data: [80, 60, 40, 10],
|
||||
borderColor: '#ff8000',
|
||||
fill: true,
|
||||
backgroundColor: '#ff800033',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'LineName3',
|
||||
data: [60, 70, 35, 40],
|
||||
borderColor: '#ef4444',
|
||||
fill: true,
|
||||
backgroundColor: '#ef444433',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/chart/ChartLine.vue
Normal file
34
src/components/ui/chart/ChartLine.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'line'>
|
||||
options?: ChartOptions<'line'>
|
||||
ariaLabel: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('line'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
196
src/components/ui/chart/useChart.ts
Normal file
196
src/components/ui/chart/useChart.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
function getCssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getDefaultOptions(type: ChartType): ChartOptions {
|
||||
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
|
||||
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: foreground,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 16,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
generateLabels(chart) {
|
||||
const datasets = chart.data.datasets
|
||||
return datasets.map((dataset, i) => {
|
||||
const color =
|
||||
(dataset as { borderColor?: string }).borderColor ??
|
||||
(dataset as { backgroundColor?: string }).backgroundColor ??
|
||||
'#888'
|
||||
return {
|
||||
text: dataset.label ?? '',
|
||||
fillStyle: color as string,
|
||||
strokeStyle: color as string,
|
||||
lineWidth: 0,
|
||||
pointStyle: 'circle' as const,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 4
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 8
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: muted + '33',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 4
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
}
|
||||
},
|
||||
...(type === 'bar' && {
|
||||
datasets: {
|
||||
bar: {
|
||||
borderRadius: { topLeft: 4, topRight: 4 },
|
||||
borderSkipped: false,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.8
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useChart(
|
||||
canvasRef: Ref<HTMLCanvasElement | null>,
|
||||
type: Ref<ChartType>,
|
||||
data: Ref<ChartData>,
|
||||
options?: Ref<ChartOptions | undefined>
|
||||
) {
|
||||
const chartInstance = ref<Chart | null>(null)
|
||||
|
||||
function createChart() {
|
||||
if (!canvasRef.value) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const defaults = getDefaultOptions(type.value)
|
||||
const merged = options?.value
|
||||
? deepMerge(defaults, options.value)
|
||||
: defaults
|
||||
|
||||
chartInstance.value = new Chart(canvasRef.value, {
|
||||
type: type.value,
|
||||
data: data.value,
|
||||
options: merged
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(createChart)
|
||||
|
||||
watch([type, data, options ?? ref(undefined)], () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.data = data.value
|
||||
chartInstance.value.options = options?.value
|
||||
? deepMerge(getDefaultOptions(type.value), options.value)
|
||||
: getDefaultOptions(type.value)
|
||||
chartInstance.value.update()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
chartInstance.value = null
|
||||
})
|
||||
|
||||
return { chartInstance }
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Record<string, unknown>
|
||||
): T {
|
||||
const result = { ...target } as Record<string, unknown>
|
||||
for (const key of Object.keys(source)) {
|
||||
const srcVal = source[key]
|
||||
const tgtVal = result[key]
|
||||
if (
|
||||
srcVal &&
|
||||
typeof srcVal === 'object' &&
|
||||
!Array.isArray(srcVal) &&
|
||||
tgtVal &&
|
||||
typeof tgtVal === 'object' &&
|
||||
!Array.isArray(tgtVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
tgtVal as Record<string, unknown>,
|
||||
srcVal as Record<string, unknown>
|
||||
)
|
||||
} else {
|
||||
result[key] = srcVal
|
||||
}
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
60
src/composables/useEditKeybindingDialog.ts
Normal file
60
src/composables/useEditKeybindingDialog.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import EditKeybindingContent from '@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue'
|
||||
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
|
||||
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
|
||||
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
export const DIALOG_KEY = 'edit-keybinding'
|
||||
|
||||
export interface EditKeybindingDialogState {
|
||||
commandId: string
|
||||
newCombo: KeyComboImpl | null
|
||||
currentCombo: KeyComboImpl | null
|
||||
}
|
||||
|
||||
export function useEditKeybindingDialog() {
|
||||
const { showSmallLayoutDialog } = useDialogService()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
function show(options: {
|
||||
commandId: string
|
||||
commandLabel: string
|
||||
currentCombo: KeyComboImpl | null
|
||||
}) {
|
||||
const dialogState = reactive<EditKeybindingDialogState>({
|
||||
commandId: options.commandId,
|
||||
newCombo: options.currentCombo,
|
||||
currentCombo: options.currentCombo
|
||||
})
|
||||
|
||||
const existingKeybindingOnCombo = computed(() => {
|
||||
if (!dialogState.newCombo) return null
|
||||
if (dialogState.currentCombo?.equals(dialogState.newCombo)) return null
|
||||
return keybindingStore.getKeybinding(dialogState.newCombo)
|
||||
})
|
||||
|
||||
function onUpdateCombo(combo: KeyComboImpl) {
|
||||
dialogState.newCombo = combo
|
||||
}
|
||||
|
||||
showSmallLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
headerComponent: EditKeybindingHeader,
|
||||
footerComponent: EditKeybindingFooter,
|
||||
component: EditKeybindingContent,
|
||||
props: {
|
||||
dialogState,
|
||||
onUpdateCombo,
|
||||
commandLabel: options.commandLabel,
|
||||
existingKeybindingOnCombo
|
||||
},
|
||||
headerProps: {},
|
||||
footerProps: { dialogState, existingKeybindingOnCombo }
|
||||
})
|
||||
}
|
||||
|
||||
return { show }
|
||||
}
|
||||
@@ -110,6 +110,7 @@
|
||||
"delete": "Delete",
|
||||
"rename": "Rename",
|
||||
"save": "Save",
|
||||
"saveAnyway": "Save Anyway",
|
||||
"saving": "Saving",
|
||||
"no": "No",
|
||||
"cancel": "Cancel",
|
||||
@@ -120,7 +121,6 @@
|
||||
"showRightPanel": "Show right panel",
|
||||
"hideRightPanel": "Hide right panel",
|
||||
"or": "or",
|
||||
"pressKeysForNewBinding": "Press keys for new binding",
|
||||
"defaultBanner": "default banner",
|
||||
"enableOrDisablePack": "Enable or disable pack",
|
||||
"openManager": "Open Manager",
|
||||
@@ -265,6 +265,11 @@
|
||||
"multiSelectDropdown": "Multi-select dropdown",
|
||||
"singleSelectDropdown": "Single-select dropdown",
|
||||
"progressCountOf": "of",
|
||||
"modifyKeybinding": "Modify keybinding",
|
||||
"setAKeybindingForTheFollowing": "Set a keybinding for the following:",
|
||||
"enterYourKeybind": "Enter your keybind",
|
||||
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
|
||||
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
|
||||
"keybindingAlreadyExists": "Keybinding already exists on",
|
||||
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
|
||||
"startRecording": "Start Recording",
|
||||
|
||||
39
src/platform/keybindings/keyCombo.test.ts
Normal file
39
src/platform/keybindings/keyCombo.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { KeyComboImpl } from './keyCombo'
|
||||
|
||||
describe('KeyComboImpl', () => {
|
||||
describe('isBrowserReserved', () => {
|
||||
it.each([
|
||||
{ key: 't', ctrl: true, label: 'Ctrl + t' },
|
||||
{ key: 'w', ctrl: true, label: 'Ctrl + w' },
|
||||
{ key: 'F12', label: 'F12' },
|
||||
{ key: 'n', ctrl: true, shift: true, label: 'Ctrl + Shift + n' },
|
||||
{ key: 'r', ctrl: true, label: 'Ctrl + r' },
|
||||
{ key: 'F5', label: 'F5' }
|
||||
])('returns true for $label', ({ key, ctrl, shift }) => {
|
||||
const combo = new KeyComboImpl({
|
||||
key,
|
||||
ctrl: ctrl ?? false,
|
||||
alt: false,
|
||||
shift: shift ?? false
|
||||
})
|
||||
expect(combo.isBrowserReserved).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'k', ctrl: true, label: 'Ctrl + k' },
|
||||
{ key: 's', alt: true, label: 'Alt + s' },
|
||||
{ key: 'z', ctrl: true, label: 'Ctrl + z' },
|
||||
{ key: 'F6', label: 'F6' }
|
||||
])('returns false for $label', ({ key, ctrl, alt }) => {
|
||||
const combo = new KeyComboImpl({
|
||||
key,
|
||||
ctrl: ctrl ?? false,
|
||||
alt: alt ?? false,
|
||||
shift: false
|
||||
})
|
||||
expect(combo.isBrowserReserved).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { RESERVED_BY_TEXT_INPUT } from './reserved'
|
||||
import { RESERVED_BY_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
|
||||
import type { KeyCombo } from './types'
|
||||
|
||||
export class KeyComboImpl implements KeyCombo {
|
||||
@@ -61,11 +61,15 @@ export class KeyComboImpl implements KeyCombo {
|
||||
return this.shift && this.modifierCount === 1
|
||||
}
|
||||
|
||||
get isBrowserReserved(): boolean {
|
||||
return RESERVED_BY_BROWSER.has(toNormalizedString(this))
|
||||
}
|
||||
|
||||
get isReservedByTextInput(): boolean {
|
||||
return (
|
||||
!this.hasModifier ||
|
||||
this.isShiftOnly ||
|
||||
RESERVED_BY_TEXT_INPUT.has(this.toString())
|
||||
RESERVED_BY_TEXT_INPUT.has(toNormalizedString(this))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,3 +88,12 @@ export class KeyComboImpl implements KeyCombo {
|
||||
return sequences
|
||||
}
|
||||
}
|
||||
|
||||
function toNormalizedString(combo: KeyComboImpl): string {
|
||||
const sequences: string[] = []
|
||||
if (combo.ctrl) sequences.push('Ctrl')
|
||||
if (combo.alt) sequences.push('Alt')
|
||||
if (combo.shift) sequences.push('Shift')
|
||||
sequences.push(combo.key.length === 1 ? combo.key.toLowerCase() : combo.key)
|
||||
return sequences.join(' + ')
|
||||
}
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
export const RESERVED_BY_BROWSER = new Set([
|
||||
'Ctrl + t', // New tab (all browsers)
|
||||
'Ctrl + w', // Close tab (all browsers)
|
||||
'Ctrl + n', // New window (all browsers)
|
||||
'Ctrl + Shift + n', // New incognito/private window (all browsers)
|
||||
'Ctrl + Tab', // Next tab (all browsers)
|
||||
'Ctrl + Shift + Tab', // Previous tab (all browsers)
|
||||
'Ctrl + Shift + Delete', // Clear browsing data (Chrome, Edge, Firefox)
|
||||
'Ctrl + h', // History (all browsers)
|
||||
'Ctrl + j', // Downloads (Chrome, Edge)
|
||||
'Ctrl + d', // Bookmark current page (all browsers)
|
||||
'Ctrl + Shift + b', // Toggle bookmarks bar (Chrome, Edge)
|
||||
'Ctrl + Shift + o', // Bookmarks manager (Chrome, Edge)
|
||||
'Ctrl + Shift + i', // DevTools (all browsers)
|
||||
'Ctrl + Shift + j', // DevTools console (Chrome, Edge)
|
||||
'F5', // Reload page (all browsers)
|
||||
'Ctrl + F5', // Hard reload (all browsers)
|
||||
'Ctrl + r', // Reload page (all browsers)
|
||||
'Ctrl + Shift + r', // Hard reload (all browsers)
|
||||
'F7', // Caret browsing (Firefox, Edge)
|
||||
'F11', // Toggle fullscreen (all browsers)
|
||||
'F12', // DevTools (all browsers)
|
||||
'Alt + F4' // Close window (Windows, all browsers)
|
||||
])
|
||||
|
||||
export const RESERVED_BY_TEXT_INPUT = new Set([
|
||||
'Ctrl + a',
|
||||
'Ctrl + c',
|
||||
|
||||
Reference in New Issue
Block a user