| |
| |
| |
| |
| import * as d3 from 'd3'; |
| import { countTokenCharacters } from '../utils/Util'; |
| import { tr } from '../lang/i18n-lite'; |
|
|
| export type DialogContentBuilder = ( |
| dialog: d3.Selection<HTMLDivElement, unknown, any, any>, |
| setConfirmButtonState?: (enabled: boolean) => void |
| ) => { |
| getValue?: () => any; |
| validate?: () => boolean; |
| focus?: () => void; |
| }; |
|
|
| export interface DialogOptions { |
| title: string; |
| content: DialogContentBuilder; |
| |
| onConfirm?: (value: any) => boolean | void | Promise<boolean | void>; |
| onCancel?: () => void; |
| |
| confirmText?: string | null; |
| cancelText?: string | null; |
| |
| loadingConfirmText?: string; |
| width?: string; |
| height?: string; |
| } |
|
|
| export type ConfirmButtonState = 'normal' | 'disabled' | 'queuing'; |
|
|
| |
| |
| |
| |
| export function showDialog(options: DialogOptions): { |
| setConfirmButtonState: (enabled: boolean, queuing?: boolean) => void; |
| } { |
| const { |
| title, |
| content, |
| onConfirm: onConfirmUser, |
| onCancel, |
| confirmText = tr('Confirm'), |
| cancelText, |
| loadingConfirmText = tr('Queuing...'), |
| width = 'clamp(300px, 90vw, 500px)', |
| height |
| } = options; |
|
|
| const onConfirm = onConfirmUser ?? (() => undefined); |
|
|
| |
| |
| const overlay = d3.select('body').append('div') |
| .attr('class', 'dialog-overlay'); |
|
|
| |
| const cleanup = () => { |
| |
| }; |
|
|
| |
| const dialog = overlay.append('div') |
| .attr('class', 'dialog') |
| .style('width', width); |
| |
| |
| if (height) { |
| dialog.style('height', height); |
| } |
|
|
| |
| dialog.append('div') |
| .attr('class', 'dialog-title') |
| .text(title); |
|
|
| |
| const contentArea = dialog.append('div') |
| .attr('class', 'dialog-content'); |
|
|
| |
| const buttonContainer = dialog.append('div') |
| .attr('class', 'dialog-buttons'); |
|
|
| |
| if (cancelText !== undefined && cancelText !== null) { |
| const cancelBtn = buttonContainer.append('button') |
| .attr('class', 'dialog-button cancel') |
| .text(cancelText) |
| .on('click', () => { |
| cleanup(); |
| overlay.remove(); |
| if (onCancel) { |
| onCancel(); |
| } |
| }); |
| } |
|
|
| const confirmBtn = confirmText != null |
| ? buttonContainer.append('button') |
| .attr('class', 'dialog-button confirm') |
| .text(confirmText) |
| : null; |
|
|
| |
| const originalButtonText = confirmText ?? ''; |
|
|
| |
| |
| const setConfirmButtonState = (enabled: boolean, queuing: boolean = false) => { |
| const btnNode = confirmBtn?.node() as HTMLButtonElement | null; |
| if (btnNode) { |
| btnNode.disabled = !enabled || queuing; |
| if (queuing) { |
| btnNode.setAttribute('data-state', 'queuing'); |
| confirmBtn!.classed('queuing', true); |
| btnNode.innerHTML = ` |
| <span class="queuing-text">${loadingConfirmText}</span> |
| <span class="queuing-spinner"></span> |
| `; |
| } else { |
| btnNode.setAttribute('data-state', enabled ? 'enabled' : 'disabled'); |
| confirmBtn!.classed('queuing', false); |
| btnNode.textContent = originalButtonText; |
| } |
| } |
| }; |
|
|
| |
| const contentControls = content(contentArea, setConfirmButtonState); |
|
|
| |
| confirmBtn?.on('click', async () => { |
| |
| if (contentControls.validate && !contentControls.validate()) { |
| return; |
| } |
| |
| const btnNode = confirmBtn?.node() as HTMLButtonElement | null; |
| if (btnNode && btnNode.getAttribute('data-state') === 'queuing') { |
| return; |
| } |
| |
| const value = contentControls.getValue ? contentControls.getValue() : undefined; |
| |
| |
| const shouldClose = await onConfirm(value); |
| |
| if (shouldClose !== false) { |
| cleanup(); |
| overlay.remove(); |
| } |
| }); |
|
|
| |
| |
| dialog.on('click', function(event) { |
| event.stopPropagation(); |
| }); |
|
|
| |
| const escHandler = (e: KeyboardEvent) => { |
| |
| if (e.isComposing) { |
| return; |
| } |
| if (e.key === 'Escape') { |
| cleanup(); |
| document.removeEventListener('keydown', escHandler); |
| overlay.remove(); |
| if (onCancel) { |
| onCancel(); |
| } |
| } |
| }; |
| document.addEventListener('keydown', escHandler); |
|
|
| |
| if (contentControls.focus) { |
| contentControls.focus(); |
| } |
|
|
| |
| return { |
| setConfirmButtonState |
| }; |
| } |
|
|
| |
| |
| |
| export function createInputContent( |
| label: string, |
| defaultValue: string = '', |
| placeholder?: string |
| ): DialogContentBuilder { |
| return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => { |
| const container = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| container.append('label') |
| .attr('class', 'dialog-label') |
| .text(label); |
|
|
| const input = container.append('input') |
| .attr('type', 'text') |
| .attr('class', 'dialog-input') |
| .attr('value', defaultValue) |
| .attr('placeholder', placeholder || ''); |
|
|
| |
| input.on('keydown', function(event) { |
| const keyboardEvent = event as KeyboardEvent; |
| |
| if (keyboardEvent.isComposing) { |
| return; |
| } |
| if (keyboardEvent.key === 'Enter') { |
| const inputNode = input.node() as HTMLInputElement; |
| const value = inputNode?.value?.trim() || ''; |
| if (value) { |
| const dialogElement = dialog.node()?.closest('.dialog'); |
| if (dialogElement) { |
| const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement; |
| if (confirmBtn && !confirmBtn.disabled) { |
| confirmBtn.click(); |
| } |
| } |
| } |
| } |
| }); |
|
|
| return { |
| getValue: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| return inputNode?.value?.trim() || ''; |
| }, |
| validate: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| return (inputNode?.value?.trim() || '').length > 0; |
| }, |
| focus: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| if (inputNode) { |
| inputNode.focus(); |
| inputNode.select(); |
| } |
| } |
| }; |
| }; |
| } |
|
|
| |
| |
| |
| export function createSelectContent( |
| label: string, |
| options: Array<{ value: string; text: string }>, |
| defaultValue?: string |
| ): DialogContentBuilder { |
| return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => { |
| const container = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| container.append('label') |
| .attr('class', 'dialog-label') |
| .text(label); |
|
|
| const select = container.append('select') |
| .attr('class', 'dialog-select folder-select'); |
|
|
| |
| select.selectAll('option') |
| .data(options) |
| .join('option') |
| .attr('value', d => d.value) |
| .text(d => d.text); |
|
|
| |
| if (defaultValue !== undefined) { |
| select.property('value', defaultValue); |
| } else if (options.length > 0) { |
| select.property('value', options[0].value); |
| } |
|
|
| return { |
| getValue: () => { |
| return select.property('value') || ''; |
| }, |
| validate: () => { |
| return select.property('value') !== ''; |
| } |
| }; |
| }; |
| } |
|
|
| |
| |
| |
| export function createConfirmContent(message: string): DialogContentBuilder { |
| return (dialog: d3.Selection<HTMLDivElement, unknown, any, any>, setConfirmButtonState?) => { |
| dialog.append('div') |
| .attr('class', 'dialog-message') |
| .text(message); |
|
|
| return { |
| getValue: () => true, |
| validate: () => true |
| }; |
| }; |
| } |
|
|
| |
| |
| |
| export function showConfirmDialog( |
| title: string, |
| message: string, |
| onConfirm: () => void, |
| onCancel?: () => void, |
| confirmText: string = tr('Confirm'), |
| cancelText: string = tr('Cancel') |
| ): void { |
| showDialog({ |
| title, |
| content: createConfirmContent(message), |
| onConfirm: () => { |
| onConfirm(); |
| }, |
| onCancel, |
| confirmText, |
| cancelText |
| }); |
| } |
|
|
| |
| |
| |
| export function showAlertDialog( |
| title: string, |
| message: string, |
| onClose?: () => void |
| ): void { |
| showDialog({ |
| title, |
| content: createConfirmContent(message), |
| onConfirm: () => { |
| if (onClose) { |
| onClose(); |
| } |
| }, |
| confirmText: tr('OK'), |
| cancelText: undefined |
| }); |
| } |
|
|
| |
| |
| |
| export function createCombinedContent( |
| inputLabel: string, |
| inputDefaultValue: string, |
| selectLabel: string, |
| selectOptions: Array<{ value: string; text: string }>, |
| selectDefaultValue?: string |
| ): DialogContentBuilder { |
| return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => { |
| |
| const inputContainer = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| inputContainer.append('label') |
| .attr('class', 'dialog-label') |
| .text(inputLabel); |
|
|
| const input = inputContainer.append('input') |
| .attr('type', 'text') |
| .attr('class', 'dialog-input') |
| .attr('value', inputDefaultValue); |
|
|
| |
| const selectContainer = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| selectContainer.append('label') |
| .attr('class', 'dialog-label') |
| .text(selectLabel); |
|
|
| const select = selectContainer.append('select') |
| .attr('class', 'dialog-select folder-select'); |
|
|
| |
| select.selectAll('option') |
| .data(selectOptions) |
| .join('option') |
| .attr('value', d => d.value) |
| .text(d => d.text); |
|
|
| |
| if (selectDefaultValue !== undefined) { |
| select.property('value', selectDefaultValue); |
| } else if (selectOptions.length > 0) { |
| select.property('value', selectOptions[0].value); |
| } |
|
|
| |
| input.on('keydown', function(event) { |
| const keyboardEvent = event as KeyboardEvent; |
| |
| if (keyboardEvent.isComposing) { |
| return; |
| } |
| if (keyboardEvent.key === 'Enter') { |
| const inputNode = input.node() as HTMLInputElement; |
| const value = inputNode?.value?.trim() || ''; |
| if (value) { |
| const dialogElement = dialog.node()?.closest('.dialog'); |
| if (dialogElement) { |
| const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement; |
| if (confirmBtn && !confirmBtn.disabled) { |
| confirmBtn.click(); |
| } |
| } |
| } |
| } |
| }); |
|
|
| return { |
| getValue: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| return { |
| input: inputNode?.value?.trim() || '', |
| select: select.property('value') || '' |
| }; |
| }, |
| validate: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| return (inputNode?.value?.trim() || '').length > 0; |
| }, |
| focus: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| if (inputNode) { |
| inputNode.focus(); |
| inputNode.select(); |
| } |
| } |
| }; |
| }; |
| } |
|
|
| |
| |
| |
| export function createNamePathTextContent( |
| inputLabel: string, |
| inputDefaultValue: string, |
| selectLabel: string, |
| selectOptions: Array<{ value: string; text: string }>, |
| selectDefaultValue: string, |
| textLabel: string, |
| textDefaultValue: string |
| ): DialogContentBuilder { |
| return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => { |
| |
| const inputContainer = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| inputContainer.append('label') |
| .attr('class', 'dialog-label') |
| .text(inputLabel); |
|
|
| const input = inputContainer.append('input') |
| .attr('type', 'text') |
| .attr('class', 'dialog-input') |
| .attr('value', inputDefaultValue); |
|
|
| |
| const selectContainer = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| selectContainer.append('label') |
| .attr('class', 'dialog-label') |
| .text(selectLabel); |
|
|
| const select = selectContainer.append('select') |
| .attr('class', 'dialog-select folder-select'); |
|
|
| select.selectAll('option') |
| .data(selectOptions) |
| .join('option') |
| .attr('value', d => d.value) |
| .text(d => d.text); |
|
|
| select.property('value', selectDefaultValue || (selectOptions[0]?.value ?? '/')); |
|
|
| |
| const textContainer = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| |
| const labelContainer = textContainer.append('div') |
| .attr('class', 'dialog-label-container'); |
|
|
| labelContainer.append('label') |
| .attr('class', 'dialog-label') |
| .text(textLabel); |
|
|
| |
| const textCountDisplay = labelContainer.append('div') |
| .attr('class', 'dialog-textarea-counter'); |
|
|
| const textarea = textContainer.append('textarea') |
| .attr('class', 'dialog-textarea') |
| .attr('rows', 6) |
| .text(textDefaultValue || ''); |
|
|
| |
| const updateTextCount = () => { |
| const textNode = textarea.node() as HTMLTextAreaElement; |
| const textValue = textNode?.value || ''; |
| const charCount = countTokenCharacters(textValue); |
| textCountDisplay.text(`${charCount} 字`); |
| }; |
|
|
| |
| textarea.on('input', updateTextCount); |
|
|
| |
| updateTextCount(); |
|
|
| |
| input.on('keydown', function(event) { |
| const keyboardEvent = event as KeyboardEvent; |
| |
| if (keyboardEvent.isComposing) { |
| return; |
| } |
| if (keyboardEvent.key === 'Enter') { |
| const inputNode = input.node() as HTMLInputElement; |
| const value = inputNode?.value?.trim() || ''; |
| if (value) { |
| const dialogElement = dialog.node()?.closest('.dialog'); |
| if (dialogElement) { |
| const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement; |
| if (confirmBtn && !confirmBtn.disabled) { |
| confirmBtn.click(); |
| } |
| } |
| } |
| } |
| }); |
|
|
| return { |
| getValue: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| const textNode = textarea.node() as HTMLTextAreaElement; |
| return { |
| input: inputNode?.value?.trim() || '', |
| select: select.property('value') || '', |
| text: textNode?.value ?? '' |
| }; |
| }, |
| validate: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| return (inputNode?.value?.trim() || '').length > 0; |
| }, |
| focus: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| if (inputNode) { |
| inputNode.focus(); |
| inputNode.select(); |
| } |
| } |
| }; |
| }; |
| } |
|
|
| |
| |
| |
| export function createUrlInputContent( |
| label: string, |
| defaultValue: string = '', |
| placeholder?: string |
| ): DialogContentBuilder { |
| return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => { |
| const container = dialog.append('div') |
| .attr('class', 'dialog-form-container'); |
|
|
| container.append('label') |
| .attr('class', 'dialog-label') |
| .text(label); |
|
|
| const input = container.append('input') |
| .attr('type', 'url') |
| .attr('class', 'dialog-input') |
| .attr('value', defaultValue) |
| .attr('placeholder', placeholder || 'https://example.com'); |
|
|
| |
| input.on('keydown', function(event) { |
| const keyboardEvent = event as KeyboardEvent; |
| |
| if (keyboardEvent.isComposing) { |
| return; |
| } |
| if (keyboardEvent.key === 'Enter') { |
| const inputNode = input.node() as HTMLInputElement; |
| const value = inputNode?.value?.trim() || ''; |
| if (value) { |
| const dialogElement = dialog.node()?.closest('.dialog'); |
| if (dialogElement) { |
| const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement; |
| if (confirmBtn && !confirmBtn.disabled) { |
| confirmBtn.click(); |
| } |
| } |
| } |
| } |
| }); |
|
|
| return { |
| getValue: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| return inputNode?.value?.trim() || ''; |
| }, |
| validate: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| const value = inputNode?.value?.trim() || ''; |
| |
| if (value.length === 0) { |
| return false; |
| } |
| try { |
| new URL(value); |
| return true; |
| } catch { |
| return false; |
| } |
| }, |
| focus: () => { |
| const inputNode = input.node() as HTMLInputElement; |
| if (inputNode) { |
| inputNode.focus(); |
| inputNode.select(); |
| } |
| } |
| }; |
| }; |
| } |
|
|
|
|