From 02ff45814e2fae4abbe4343f1ac22620710f156a Mon Sep 17 00:00:00 2001 From: yuanyxh <15766118362@139.com> Date: Fri, 1 Sep 2023 04:43:25 +0800 Subject: [PATCH 1/3] feat: add some components --- BundleRoute.js | 24 ++- src/components/Button/Button.tsx | 6 +- src/components/Card/Card.tsx | 4 +- src/components/Dialog/Dialog.tsx | 4 +- src/components/Input/Input.tsx | 42 ++-- .../InputNumber/InputNumber.module.css | 78 +++++++ src/components/InputNumber/InputNumber.tsx | 113 ++++++++++ src/components/Loading/Loading.tsx | 7 +- src/components/LoadingIcon/LoadingIcon.tsx | 4 +- src/components/MessageBox/MessageBox.tsx | 5 +- src/components/Overlay/Overlay.tsx | 4 +- src/components/Progress/Progress.tsx | 4 +- src/components/Select/Select.module.css | 119 +++++++++++ src/components/Select/Select.tsx | 127 +++++++++++ src/components/Switch/Switch.module.css | 92 ++++++++ src/components/Switch/Switch.tsx | 61 ++++++ src/components/Text/Text.tsx | 6 +- src/components/Upload/Upload.tsx | 3 +- src/hooks/useTransition.ts | 13 +- src/index.css | 2 +- .../GIF-Explorer/GIF-Explorer.module.css | 3 + src/pages/GIF-Explorer/GIF-Explorer.tsx | 13 ++ src/pages/GIF-Explorer/GIFVideo.module.css | 36 ++++ src/pages/GIF-Explorer/GIFVideo.tsx | 197 ++++++++++++++++++ .../component/TimePicker.module.css | 3 + .../GIF-Explorer/component/TimePicker.tsx | 104 +++++++++ src/pages/GIF-Explorer/types.ts | 1 + src/pages/GIF-Explorer/utils.ts | 1 + src/pages/VisualEdit/Button.tsx | 2 +- src/pages/VisualEdit/Input.tsx | 4 +- src/router/index.tsx | 15 +- src/types/index.d.ts | 7 +- src/types/props.d.ts | 11 + 33 files changed, 1061 insertions(+), 54 deletions(-) create mode 100644 src/components/InputNumber/InputNumber.module.css create mode 100644 src/components/InputNumber/InputNumber.tsx create mode 100644 src/components/Select/Select.module.css create mode 100644 src/components/Select/Select.tsx create mode 100644 src/components/Switch/Switch.module.css create mode 100644 src/components/Switch/Switch.tsx create mode 100644 src/pages/GIF-Explorer/GIF-Explorer.module.css create mode 100644 src/pages/GIF-Explorer/GIF-Explorer.tsx create mode 100644 src/pages/GIF-Explorer/GIFVideo.module.css create mode 100644 src/pages/GIF-Explorer/GIFVideo.tsx create mode 100644 src/pages/GIF-Explorer/component/TimePicker.module.css create mode 100644 src/pages/GIF-Explorer/component/TimePicker.tsx create mode 100644 src/pages/GIF-Explorer/types.ts create mode 100644 src/pages/GIF-Explorer/utils.ts create mode 100644 src/types/props.d.ts diff --git a/BundleRoute.js b/BundleRoute.js index 6cddf3c..ac429aa 100644 --- a/BundleRoute.js +++ b/BundleRoute.js @@ -56,6 +56,20 @@ function resolve(...paths) { return path.resolve(...paths); } +/** + * + * @description 转换名字 + * @param {string} name + * @returns + */ +function transformPageName(name) { + if (name.includes('-')) { + name = name.replace(/-/g, ''); + } + + return name; +} + /** * * @description Array.prototype.forEach 别名 @@ -91,6 +105,8 @@ function getPageName(way) { * @param {string} pageName */ function getPath(pageName) { + if (pageName.includes('-')) return pageName.toLocaleLowerCase(); + return pageName.replace(/[A-Z]/g, (char, index) => { if (index === 0) return char.toLowerCase(); @@ -132,7 +148,9 @@ function getImage(source) { * @param {string} pageName */ function generateImport(pageName) { - return `const ${pageName} = lazy(() => import('@/pages/${pageName}/${pageName}'));`; + let variable = transformPageName(pageName); + + return `const ${variable} = lazy(() => import('@/pages/${pageName}/${pageName}'));`; } /** @@ -166,7 +184,7 @@ function createPage(way) { pageCount--; - const pageName = getPageName(way); + let pageName = getPageName(way); const path = getPath(pageName); const title = getTitle(data)[0].trim(); const image = getImage(data)?.[0].trim() || ''; @@ -177,7 +195,7 @@ function createPage(way) { path: '${path}', title: '${title}', image: '${image}', - element: <${pageName} /> + element: <${transformPageName(pageName)} /> }, `; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 01ec9f1..5a02df9 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -9,7 +9,7 @@ type NativeType = 'button' | 'submit' | 'reset'; type Size = 'default' | 'large' | 'small'; -interface ButtonProps extends ChildProps { +interface ButtonProps extends ButtonChildProps { type?: Type; plain?: boolean; round?: boolean; @@ -41,6 +41,7 @@ export default function Button(props: Readonly) { block = false, link = false, nativeType = 'button', + className = '', onClick, ...nativeProps } = props; @@ -71,10 +72,11 @@ export default function Button(props: Readonly) { buttonClass, buttonStyle, buttonStatus, - nativeProps.className || '' + className )} style={nativeProps.style} onClick={onClick} + {...nativeProps} > {isRenderElement(loading) && ( diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 44c1f02..ffdff81 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -38,7 +38,8 @@ export default function Card(props: CardProps) { bodyStyle, onClick, className = '', - style: _style + style: _style, + ...nativeProps } = props; const _isSlots = isSlots(children); @@ -52,6 +53,7 @@ export default function Card(props: CardProps) { className={composeClass(cardClass, className)} style={_style} onClick={onClick} + {...nativeProps} > {header ?
{header}
: header} diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 7b265d5..ebf2023 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -48,7 +48,8 @@ export default function Dialog(props: DialogProps) { showClose = true, closeOnClickModal = true, className = '', - style: _style + style: _style, + ...nativeProps } = props; const overlayRef = useRef(null); @@ -92,6 +93,7 @@ export default function Dialog(props: DialogProps) {
{isDialogSlots(children) && children.header ? ( diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index b87d5bd..57d0f99 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -1,17 +1,19 @@ -import React, { useRef, useImperativeHandle, forwardRef } from 'react'; +import React, { forwardRef } from 'react'; import { classnames, composeClass, isNumber, isRenderElement } from '@/utils'; import style from './Input.module.css'; +type Props = InputProps & TextAreaProps; + export interface InputExpose { select(): void; } -type InputSlots = { +interface InputSlots { prefix?(): React.ReactNode | React.ReactNode[]; suffix?(): React.ReactNode | React.ReactNode[]; -}; +} -interface InputProps extends Props { +interface _InputProps extends Props { change: (val: string) => void; focus?: | React.FocusEventHandler @@ -25,6 +27,7 @@ interface InputProps extends Props { type?: React.HTMLInputTypeAttribute; name?: string; size?: 'default' | 'large' | 'small'; + id?: string; readonly?: boolean; placeholder?: string; disabled?: boolean; @@ -46,8 +49,8 @@ const generateClass = classnames(style); * @description input 表单输入 */ export default forwardRef(function Input( - props: InputProps, - ref: React.ForwardedRef + props: _InputProps, + ref: React.ForwardedRef ) { const { value = '', @@ -61,6 +64,7 @@ export default forwardRef(function Input( disabled = false, autofocus = false, tabIndex = 0, + id, readonly = false, selectInFocus = false, name, @@ -72,21 +76,10 @@ export default forwardRef(function Input( form, children, style, - className = '' + className = '', + ...nativeProps } = props; - const inputRef = useRef(null); - - useImperativeHandle( - ref, - () => ({ - select() { - inputRef.current?.select(); - } - }), - [] - ); - const inputClass = generateClass(['input', `input-${size}`]); const inputStyle = generateClass({ 'is-disabled': disabled }); @@ -107,8 +100,6 @@ export default forwardRef(function Input( const clickHandle: React.MouseEventHandler = (e) => { e.stopPropagation(); - - inputRef.current?.focus(); }; const enterHandle: React.KeyboardEventHandler = (e) => { @@ -118,11 +109,12 @@ export default forwardRef(function Input( }; const input = ( -
+
{isRenderElement(prefix) && ( @@ -133,7 +125,8 @@ export default forwardRef(function Input( )} } onKeyUp={enterHandle} + {...nativeProps} /> {isRenderElement(suffix) && ( @@ -178,6 +172,7 @@ export default forwardRef(function Input( textareaInnerStyle, className )} + id={id} style={style} form={form} name={name} @@ -198,6 +193,7 @@ export default forwardRef(function Input( }} onFocus={focus as React.FocusEventHandler} onBlur={blur as React.FocusEventHandler} + {...nativeProps} >
); diff --git a/src/components/InputNumber/InputNumber.module.css b/src/components/InputNumber/InputNumber.module.css new file mode 100644 index 0000000..3a331b7 --- /dev/null +++ b/src/components/InputNumber/InputNumber.module.css @@ -0,0 +1,78 @@ +.input-number { + --input-number-width: 150px; + --input-number-line-height: 30px; + --input-number-btn-width: 32px; + --input-number-btn-font-size: 13px; + + position: relative; + display: inline-flex; + width: var(--input-number-width); + line-height: var(--input-number-line-height); +} + +.input-number-large { + --input-number-width: 180px; + --input-number-line-height: 38px; + --input-number-btn-width: 40px; + --input-number-btn-font-size: 14px; +} + +.input-number-small { + --input-number-width: 120px; + --input-number-line-height: 22px; + --input-number-btn-width: 24px; + --input-number-btn-font-size: 12px; +} + +.input-number-increase { + right: 1px; + border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0; + border-left: var(--border); +} + +.input-number-increase, +.input-number-decrease { + display: flex; + justify-content: center; + align-items: center; + height: auto; + position: absolute; + z-index: 1; + top: 1px; + bottom: 1px; + width: var(--input-number-btn-width); + background: var(--fill-color-light); + color: var(--text-color-regular); + cursor: pointer; + font-size: var(--input-number-btn-font-size); + user-select: none; +} + +.input-number-decrease { + left: 1px; + border-radius: var(--border-radius-base) 0 0 var(--border-radius-base); + border-right: var(--border); +} + +.input-number-decrease.is-disabled, +.input-number-increase.is-disabled { + color: var(--disabled-text-color); + cursor: not-allowed; +} + +.input-number.is-disabled .input-number-increase, +.input-number.is-disabled .input-number-decrease { + border-color: var(--disabled-border-color); + color: var(--disabled-border-color); + cursor: not-allowed; +} + +.input-number-wrapper { + padding-inline: 42px; +} + +.input-number-wrapper > input { + appearance: none; + text-align: center; + line-height: 1; +} diff --git a/src/components/InputNumber/InputNumber.tsx b/src/components/InputNumber/InputNumber.tsx new file mode 100644 index 0000000..6fed341 --- /dev/null +++ b/src/components/InputNumber/InputNumber.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { isNumber, composeClass, classnames } from '@/utils'; +import Input from '@/components/Input/Input'; +import style from './InputNumber.module.css'; + +interface InputNumberProps extends InputProps { + value: number | string; + change: React.Dispatch>; + size?: 'large' | 'default' | 'small'; + disabled?: boolean; + step?: number; + stepStrictly?: boolean; + precision?: number; + min?: number; + max?: number; +} + +const generateClass = classnames(style); + +export default function InputNumber(props: InputNumberProps) { + const { + value, + change, + id, + step = 1, + min = -window.Infinity, + max = window.Infinity, + precision, + size = 'default', + disabled + } = props; + + const _change = (e: string) => { + if (e.trim() === '') return change(e); + + if (/\d+/.test(e)) { + let num = window.parseInt(e); + + const distance = num % step; + + if (distance !== 0) { + num = num + step - distance; + } + + if (num < min || num > max) return; + + change(isNumber(precision) ? num : num.toFixed(precision)); + } + }; + + const decrease = () => { + const num = isNumber(value) + ? value + : window.parseInt(value.trim() === '' ? '0' : value); + + if (num <= min) return; + + change(isNumber(precision) ? (num - step).toFixed(precision) : num - step); + }; + + const increase = () => { + const num = isNumber(value) + ? value + : window.parseInt(value.trim() === '' ? '0' : value); + + if (num >= max) return; + + change(isNumber(precision) ? (num + step).toFixed(precision) : num + step); + }; + + const inputNumberClass = generateClass([`input-number-${size}`]); + const inputNumberStyle = generateClass({ 'is-disabled': !!disabled }); + + const decreaseStyle = generateClass({ 'is-disabled': +value === min }); + const increaseStyle = generateClass({ 'is-disabled': +value === max }); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx index ba00d01..fa2aa11 100644 --- a/src/components/Loading/Loading.tsx +++ b/src/components/Loading/Loading.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import style from './Loading.module.css'; import { classnames } from '@/utils'; -interface LoadingProps { +interface LoadingProps extends Props { /** 是否将对应的元素添加至 body 中 */ appendBody?: boolean; /** @@ -23,6 +23,8 @@ let timer: number | null = null; * @description loading 效果 */ export default function Loading(props: Readonly) { + const { appendBody, delay, ...nativeProps } = props; + const [loading, setLoading] = useState(false); useEffect(() => { @@ -36,8 +38,6 @@ export default function Loading(props: Readonly) { }; }, []); - const { appendBody, delay } = props; - const element = (
) { 'loading-wrapper': true })} onClick={stopPropagation} + {...nativeProps} >
diff --git a/src/components/LoadingIcon/LoadingIcon.tsx b/src/components/LoadingIcon/LoadingIcon.tsx index 693b64e..4fffcb9 100644 --- a/src/components/LoadingIcon/LoadingIcon.tsx +++ b/src/components/LoadingIcon/LoadingIcon.tsx @@ -17,7 +17,8 @@ export default function LoadingIcon(props: LoadingIcon) { size = 'default', color = '#fff', className = '', - style = {} + style = {}, + ...nativeProps } = props; const loadingIconClass = generateClass([ @@ -31,6 +32,7 @@ export default function LoadingIcon(props: LoadingIcon) {
diff --git a/src/components/MessageBox/MessageBox.tsx b/src/components/MessageBox/MessageBox.tsx index 1b8f6ef..d325a8c 100644 --- a/src/components/MessageBox/MessageBox.tsx +++ b/src/components/MessageBox/MessageBox.tsx @@ -54,7 +54,9 @@ export function _MessageBox(props: _MessageBoxProps) { distinguishCancelAndClose = false, beforeClose, inputValidator, - onAction + onAction, + appendTo, + ...nativeProps } = props; const [init, setInit] = useState(true); @@ -192,6 +194,7 @@ export function _MessageBox(props: _MessageBoxProps) { className={messageBoxClass} tabIndex={-1} onClick={stopPropagation} + {...nativeProps} > {isRenderElement(title) && (
diff --git a/src/components/Overlay/Overlay.tsx b/src/components/Overlay/Overlay.tsx index 8aee0bd..ee0d3af 100644 --- a/src/components/Overlay/Overlay.tsx +++ b/src/components/Overlay/Overlay.tsx @@ -19,7 +19,8 @@ export default forwardRef(function Overlay( onClick, children, className = '', - style: _style + style: _style, + ...nativeProps } = props; const { @@ -36,6 +37,7 @@ export default forwardRef(function Overlay( onMouseDown={onMouseDown} onMouseUp={onMouseUp} onClick={click} + {...nativeProps} > {children}
diff --git a/src/components/Progress/Progress.tsx b/src/components/Progress/Progress.tsx index 65dc44a..b20366c 100644 --- a/src/components/Progress/Progress.tsx +++ b/src/components/Progress/Progress.tsx @@ -31,7 +31,8 @@ export default function Progress(props: ProgressProps) { duration = 3, className = '', style: _style, - format + format, + ...nativeProps } = props; const value = (Math.abs(percentage) + 100) % 100; @@ -55,6 +56,7 @@ export default function Progress(props: ProgressProps) { aria-valuemax={100} className={composeClass(progressClass, className)} style={_style} + {...nativeProps} >
input { + cursor: pointer; +} + +.select-icon { + transition: transform var(--transition-duration); +} + +.select:has(input:focus) .select-icon { + transform: rotate(-180deg); +} + +.select-options { + display: none; + position: absolute; + top: 110%; + background-color: #fff; + border: 1px solid var(--border-color-light); + box-shadow: var(--box-shadow-light); + border-radius: var(--border-radius-base); + padding-block: 5px; + font-size: 12px; + line-height: 20px; + width: 100%; + word-wrap: break-word; + z-index: 10; +} + +.select-options-item { + padding-inline: 11px; + font-size: var(--font-size-base); + padding: 0 32px 0 20px; + position: relative; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-color-regular); + height: 34px; + line-height: 34px; + box-sizing: border-box; + cursor: pointer; + transition: all 0.3s linear; +} + +.select-options-item:hover { + background-color: var(--fill-color-light); +} + +.select-options-item.selected { + color: var(--color-primary); + font-weight: 700; + background-color: var(--fill-color-light); +} + +.select-options-enter-active { + animation: zoom-in var(--transition-duration) + var(--transition-function-fast-bezier); + transform-origin: center top; +} + +.select-options-leave-active { + animation: zoom-out var(--transition-duration) + var(--transition-function-fast-bezier); + transform-origin: center top; +} + +@keyframes zoom-in { + 0% { + opacity: 0; + transform: scaleY(0); + } + + 100% { + opacity: 1; + transform: scaleY(1); + } +} + +@keyframes zoom-out { + 0% { + opacity: 1; + transform: scaleY(1); + } + + 100% { + opacity: 0; + transform: scaleY(0); + } +} diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx new file mode 100644 index 0000000..dfbd453 --- /dev/null +++ b/src/components/Select/Select.tsx @@ -0,0 +1,127 @@ +import React, { useRef, useMemo, useState, useEffect } from 'react'; +import { useTransition } from '@/hooks'; +import { classnames } from '@/utils'; +import Input from '@/components/Input/Input'; +import style from './Select.module.css'; + +const generateClass = classnames(style); + +interface Options { + label: string; + value: any; +} + +interface SelectProps extends Props { + options: Options[]; + value: any; + change: React.Dispatch>; + id?: string; + size?: 'large' | 'default' | 'small'; + placeholder?: string; +} + +const enter: TransitionClass = { + active: style['select-options-enter-active'] +}; + +const leave: TransitionClass = { + active: style['select-options-leave-active'] +}; + +export default function Select(props: SelectProps) { + const { + value: modelValue, + change, + options, + id, + size = 'default', + placeholder = 'Select' + } = props; + + const selectRef = useRef(null); + const inputRef = useRef(null); + + const [isFoucs, setIsFoucs] = useState(false); + + useTransition(isFoucs, selectRef, enter, leave); + + const label = useMemo( + () => options.find((item) => item.value === modelValue), + [modelValue] + ); + + useEffect(() => { + window.addEventListener('click', otherClick); + + return () => { + window.removeEventListener('click', otherClick); + }; + }, []); + + const selectClass = generateClass(['select', `select-${size}`]); + + const otherClick = () => { + setIsFoucs(false); + }; + + const handleClick = () => { + if (isFoucs) { + setIsFoucs(false); + inputRef.current?.blur(); + } else { + setIsFoucs(true); + inputRef.current?.focus(); + } + }; + + return ( +
+ { + /* */ + }} + onClick={(e) => { + e.stopPropagation(); + + handleClick(); + }} + > + {{ + suffix() { + return ( + + ); + } + }} + + +
+ {options.map(({ label, value }) => ( +
{ + e.stopPropagation(); + + change(value); + + setIsFoucs(false); + }} + > + {label} +
+ ))} +
+
+ ); +} diff --git a/src/components/Switch/Switch.module.css b/src/components/Switch/Switch.module.css new file mode 100644 index 0000000..bd85ad6 --- /dev/null +++ b/src/components/Switch/Switch.module.css @@ -0,0 +1,92 @@ +.switch { + --switch-on-color: var(--color-primary); + --switch-off-color: var(--border-color); + --switch-font-size: 14px; + --switch-line-height: 20px; + --switch-height: 32px; + --switch-core-min-width: 40px; + --switch-core-height: 20px; + --switch-core-border-radius: 10px; + + display: inline-flex; + align-items: center; + position: relative; + font-size: var(--switch-font-size); + line-height: var(--switch-line-height); + height: var(--switch-height); + vertical-align: middle; +} + +.switch.is-disabled { + opacity: 0.6; +} + +.switch-large { + --switch-font-size: 14px; + --switch-line-height: 24px; + --switch-height: 40px; + --switch-core-min-width: 50px; + --switch-core-height: 24px; + --switch-core-border-radius: 12px; +} + +.switch-small { + --switch-font-size: 12px; + --switch-line-height: 16px; + --switch-height: 24px; + --switch-core-min-width: 30px; + --switch-core-height: 16px; + --switch-core-border-radius: 8px; +} + +.switch-input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; +} + +.switch-core { + display: inline-flex; + position: relative; + align-items: center; + min-width: var(--switch-core-min-width); + height: var(--switch-core-height); + border: 1px solid var(--switch-border-color, var(--switch-off-color)); + outline: none; + border-radius: var(--switch-core-border-radius); + box-sizing: border-box; + background: var(--switch-off-color); + cursor: pointer; + transition: border-color var(--transition-duration), + background-color var(--transition-duration); +} + +.switch.is-checked .switch-core { + border-color: var(--switch-border-color, var(--switch-on-color)); + background-color: var(--switch-on-color); +} + +.switch.is-disabled .switch-core { + cursor: not-allowed; +} + +.switch-core .switch-action { + position: absolute; + left: 1px; + border-radius: var(--border-radius-circle); + transition: all var(--transition-duration); + width: 16px; + height: 16px; + background-color: var(--color-white); + display: flex; + justify-content: center; + align-items: center; + color: var(--switch-off-color); +} + +.switch.is-checked .switch-core .switch-action { + left: calc(100% - 17px); + color: var(--switch-on-color); +} diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx new file mode 100644 index 0000000..112903c --- /dev/null +++ b/src/components/Switch/Switch.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { classnames, composeClass, isUndef, isBoolean } from '@/utils'; +import style from './Switch.module.css'; + +const generateClass = classnames(style); + +interface SwitchProps extends Props { + value: T; + change: React.Dispatch>; + id?: string; + size?: 'large' | 'default' | 'small'; + disabled?: boolean; + inactiveValue?: T extends undefined ? string | boolean | number : T; + activeValue?: T extends undefined ? string | boolean | number : T; +} + +export default function Switch(props: SwitchProps) { + const { + value, + change, + activeValue, + inactiveValue, + id, + disabled, + size = 'default' + } = props; + + const switchClass = generateClass(['switch', `switch-${size}`]); + + const switchStyle = generateClass({ + 'is-checked': value === activeValue, + 'is-disabled': !!disabled + }); + + const handleCheck = () => { + if (disabled) return; + + if (isBoolean(value)) { + change((prev) => !prev as T); + } else if (!(isUndef(activeValue) || isUndef(inactiveValue))) { + value === activeValue + ? change(inactiveValue as T) + : change(activeValue as T); + } + }; + + return ( +
+ + + +
+
+
+ ); +} diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index ca933cf..29bc5bc 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { classnames, composeClass } from '@/utils'; import style from './Text.module.css'; -interface TextProps extends ChildProps { +interface TextProps extends ChildProps { type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger'; size?: 'default' | 'small' | 'large'; block?: boolean; @@ -22,7 +22,8 @@ export default function Text(props: TextProps) { type = 'default', size = 'default', block = false, - truncated = false + truncated = false, + ...nativeProps } = props; const textType = generateClass(['text', `text-${type}`, `text-${size}`]); @@ -36,6 +37,7 @@ export default function Text(props: TextProps) { {children} diff --git a/src/components/Upload/Upload.tsx b/src/components/Upload/Upload.tsx index 38e068a..d825766 100644 --- a/src/components/Upload/Upload.tsx +++ b/src/components/Upload/Upload.tsx @@ -253,7 +253,8 @@ export default function Upload(props: Readonly) { style: nativeProps.style, onClick: triggerUpload, onDragOver: (e) => e.preventDefault(), - onDrop: drag ? dropFile : undefined + onDrop: drag ? dropFile : undefined, + ...nativeProps }, defaultSlot )} diff --git a/src/hooks/useTransition.ts b/src/hooks/useTransition.ts index 1f9d93c..e44b208 100644 --- a/src/hooks/useTransition.ts +++ b/src/hooks/useTransition.ts @@ -52,15 +52,22 @@ export const useTransition = ( const active = getClass(state ? enter : leave, 'active', 'to'); from && ele.classList.remove(from); - active && ele.classList.add(active); + + const values = (active || '').split(' '); + + values.length && ele.classList.add(...values); }); }, [state]); useEffect(() => { const end = () => { port.exec((ele) => { - ele.classList.remove(getClass(enter, 'active', 'from', 'to')); - ele.classList.remove(getClass(leave, 'active', 'from', 'to')); + ele.classList.remove( + ...getClass(enter, 'active', 'from', 'to').split(' ') + ); + ele.classList.remove( + ...getClass(leave, 'active', 'from', 'to').split(' ') + ); state ? port.show() : port.hide(); }); diff --git a/src/index.css b/src/index.css index 067a041..d62361c 100644 --- a/src/index.css +++ b/src/index.css @@ -2,7 +2,7 @@ @import url('./styles/variable.css'); @import url('./styles/theme.css'); @import url('./styles/preset.css'); -@import url('//at.alicdn.com/t/c/font_3329662_ynwd12yh07k.css'); +@import url('//at.alicdn.com/t/c/font_3329662_718am7u5dne.css'); body { margin: 0; diff --git a/src/pages/GIF-Explorer/GIF-Explorer.module.css b/src/pages/GIF-Explorer/GIF-Explorer.module.css new file mode 100644 index 0000000..7388ef8 --- /dev/null +++ b/src/pages/GIF-Explorer/GIF-Explorer.module.css @@ -0,0 +1,3 @@ +.gif-explorer { + padding: 10px; +} diff --git a/src/pages/GIF-Explorer/GIF-Explorer.tsx b/src/pages/GIF-Explorer/GIF-Explorer.tsx new file mode 100644 index 0000000..a7a9cbf --- /dev/null +++ b/src/pages/GIF-Explorer/GIF-Explorer.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import GIFVideo from './GIFVideo'; +import style from './GIF-Explorer.module.css'; + +// --title: GIF 编解码-- + +export default function GIFExplorer() { + return ( +
+ +
+ ); +} diff --git a/src/pages/GIF-Explorer/GIFVideo.module.css b/src/pages/GIF-Explorer/GIFVideo.module.css new file mode 100644 index 0000000..36828a5 --- /dev/null +++ b/src/pages/GIF-Explorer/GIFVideo.module.css @@ -0,0 +1,36 @@ +.gif-transfer { + display: flex; +} + +.gif-video-container { + position: relative; + margin-bottom: 20px; + padding: 5px; + width: 40%; + border: var(--border); + border-color: var(--color-primary); + border-radius: var(--border-radius-base); +} + +.gif-video { + width: 100%; + cursor: pointer; +} + +.gif-transfer-form { + margin-left: 30px; +} + +.gif-transfer-form-item { + display: flex; + align-items: center; + column-gap: 20px; + margin-bottom: 20px; +} + +.gif-transfer-form-item > label { + flex-shrink: 0; + font-weight: 600; + width: 100px; + white-space: nowrap; +} diff --git a/src/pages/GIF-Explorer/GIFVideo.tsx b/src/pages/GIF-Explorer/GIFVideo.tsx new file mode 100644 index 0000000..667db21 --- /dev/null +++ b/src/pages/GIF-Explorer/GIFVideo.tsx @@ -0,0 +1,197 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { useModel } from '@/hooks'; +import Card from '@/components/Card/Card'; +import Button from '@/components/Button/Button'; +import MessageBox from '@/components/MessageBox/MessageBox'; +import InputNumber from '@/components/InputNumber/InputNumber'; +import Select from '@/components/Select/Select'; +import Switch from '@/components/Switch/Switch'; +import Text from '@/components/Text/Text'; +import TimePicker from './component/TimePicker'; +import style from './GIFVideo.module.css'; + +const instructOptions = [ + { label: '不处理', value: 0 }, + { label: '下一帧覆盖当前帧', value: 4 }, + { label: '恢复到背景颜色', value: 8 }, + { label: '恢复到渲染当前帧之前', value: 12 } +]; + +export default function GIFVideo() { + const inputRef = useRef(null); + const videoRef = useRef(null); + + const [blobObject, setBlobObject] = useState(''); + + const [timer, setTimer] = useState(null); + + const timeModel = useModel('00 : 00 : 05'); + + const delay = useModel(50); + + const disposalMethod = useModel(0); + + const userInputModel = useModel(0); + + const transparencyModel = useModel(0); + + const transparencyIndexModel = useModel(0); + + useEffect(() => { + return () => { + if (timer) window.clearInterval(timer); + }; + }, []); + + const handleSelect = () => inputRef.current?.click(); + + const handleChange: React.ChangeEventHandler = (e) => { + const { files } = e.target; + + if (files == null || files.length === 0) return; + + if (blobObject) { + window.URL.revokeObjectURL(blobObject); + } + + setBlobObject(window.URL.createObjectURL(files[0])); + }; + + const handleError = () => { + MessageBox.alert({ + type: 'error', + title: '播放异常', + message: '视频播放失败, 请检查视频格式' + }); + }; + + const handlePlay = () => { + if (videoRef.current === null) return; + + if (videoRef.current.readyState !== HTMLMediaElement.HAVE_ENOUGH_DATA) { + return MessageBox.alert({ + type: 'warning', + title: '视频数据不满足条件', + message: '请确认当前视频数据是否已加载' + }); + } + + videoRef.current.load(); + + loop(1000 / 24); + }; + + const loop = (millisecond: number) => { + const val = window.setInterval(captureScreen, Math.ceil(millisecond)); + + if (timer) window.clearInterval(timer); + + setTimer(val); + }; + + const captureScreen = () => { + if (videoRef.current === null) return; + + // const width = videoRef.current.videoWidth; + // const height = videoRef.current.videoHeight; + }; + + return ( + <> + +
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + + + +

+ + 前端转换较慢,限制 gif 时长为 60 + 秒;色盘在编码时确认,无法提前选择透明索引。 + +

+
+ + + + ); +} diff --git a/src/pages/GIF-Explorer/component/TimePicker.module.css b/src/pages/GIF-Explorer/component/TimePicker.module.css new file mode 100644 index 0000000..3592fee --- /dev/null +++ b/src/pages/GIF-Explorer/component/TimePicker.module.css @@ -0,0 +1,3 @@ +.time-picker { + display: block; +} diff --git a/src/pages/GIF-Explorer/component/TimePicker.tsx b/src/pages/GIF-Explorer/component/TimePicker.tsx new file mode 100644 index 0000000..ef82a61 --- /dev/null +++ b/src/pages/GIF-Explorer/component/TimePicker.tsx @@ -0,0 +1,104 @@ +import React, { useRef, useState } from 'react'; +import Input from '@/components/Input/Input'; + +interface TimePickerProps { + id?: string; + value: string; + change: React.Dispatch>; +} + +export default function TimePicker(props: TimePickerProps) { + const { id, value, change } = props; + + const inputRef = useRef(null); + + const [cache, setCache] = useState('00 : 00 : 05'); + + const handleSelect = () => { + if (inputRef.current === null) return; + + const len = inputRef.current.value.length; + + inputRef.current.setSelectionRange(len - 2, len); + }; + + const handleBlur = () => { + const values = value.split(':'); + + if (values.length > 3) { + return change(cache); + } + + let overflow = false; + + const valid = values.every((char) => /\d{2}/.test(char.trim())); + + const [hour, minute, second] = values; + + const _hour = window.parseInt(hour); + const _minute = window.parseInt(minute); + const _second = window.parseInt(second); + + if (_hour > 24 || _minute > 60 || _second > 60) { + overflow = true; + } else if (_hour === 24 && (_minute > 0 || _second > 0)) { + overflow = true; + } + + if (valid && overflow === false) { + setCache(value); + } else { + change(cache); + } + }; + + const handleKeyDown: React.KeyboardEventHandler & + React.KeyboardEventHandler = (e) => { + if (inputRef.current === null) return; + + const key = ['ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key); + + if (key === false) return; + + const start = inputRef.current.selectionStart || 0; + const end = inputRef.current.selectionEnd || 0; + + const offset = [ + [0, 2], + [5, 7], + [10, 12] + ]; + + const timerIndex = offset.findIndex( + ([_start, _end]) => start === _start && _end === end + ); + + if (timerIndex === -1) return; + + let value = [0, 0]; + + e.preventDefault(); + + if (e.key === 'ArrowLeft') { + value = + timerIndex === 0 ? offset[offset.length - 1] : offset[timerIndex - 1]; + } else { + value = + timerIndex === offset.length - 1 ? offset[0] : offset[timerIndex + 1]; + } + + inputRef.current.setSelectionRange(value[0], value[1]); + }; + + return ( + + ); +} diff --git a/src/pages/GIF-Explorer/types.ts b/src/pages/GIF-Explorer/types.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/pages/GIF-Explorer/types.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/pages/GIF-Explorer/utils.ts b/src/pages/GIF-Explorer/utils.ts new file mode 100644 index 0000000..cc798ff --- /dev/null +++ b/src/pages/GIF-Explorer/utils.ts @@ -0,0 +1 @@ +export const a = 1; diff --git a/src/pages/VisualEdit/Button.tsx b/src/pages/VisualEdit/Button.tsx index d5bff1c..7828773 100644 --- a/src/pages/VisualEdit/Button.tsx +++ b/src/pages/VisualEdit/Button.tsx @@ -1,7 +1,7 @@ import React from 'react'; import style from './Button.module.css'; -interface ButtonProps extends ChildProps { +interface ButtonProps extends ButtonChildProps { dragstart: React.DragEventHandler; } diff --git a/src/pages/VisualEdit/Input.tsx b/src/pages/VisualEdit/Input.tsx index eab2cf9..4eeb410 100644 --- a/src/pages/VisualEdit/Input.tsx +++ b/src/pages/VisualEdit/Input.tsx @@ -1,11 +1,11 @@ import React from 'react'; import style from './Input.module.css'; -interface InputProps extends Props { +interface _InputProps extends InputProps { dragstart: React.DragEventHandler; } -export default function Input({ dragstart }: Readonly) { +export default function Input({ dragstart }: Readonly<_InputProps>) { return ( ); diff --git a/src/router/index.tsx b/src/router/index.tsx index cb2ba03..ae865e9 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -9,8 +9,9 @@ const Base64 = lazy(() => import('@/pages/Base64/Base64')); const FileSystemAccess = lazy( () => import('@/pages/FileSystemAccess/FileSystemAccess') ); -const PdfParser = lazy(() => import('@/pages/PdfParser/PdfParser')); +const GIFExplorer = lazy(() => import('@/pages/GIF-Explorer/GIF-Explorer')); const Test = lazy(() => import('@/pages/Test/Test')); +const PdfParser = lazy(() => import('@/pages/PdfParser/PdfParser')); const UploadFile = lazy(() => import('@/pages/UploadFile/UploadFile')); const VisualEdit = lazy(() => import('@/pages/VisualEdit/VisualEdit')); @@ -43,10 +44,10 @@ export const routes: Route.CustomRouteObject[] = [ element: }, { - path: 'pdf-parser', - title: 'PDF 解析', + path: 'gif-explorer', + title: 'GIF 编解码', image: '', - element: + element: }, { path: 'test', @@ -54,6 +55,12 @@ export const routes: Route.CustomRouteObject[] = [ image: '', element: }, + { + path: 'pdf-parser', + title: 'PDF 解析', + image: '', + element: + }, { path: 'upload-file', title: '文件上传', diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 85e8ec9..35bb3d8 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,4 +1,5 @@ -declare interface CommonProps { +declare interface CommonProps + extends Omit, 'children'> { /** css class */ readonly className?: string; /** inline style */ @@ -8,9 +9,9 @@ declare interface CommonProps { /** * 公用组件 Props 参数类型定义 */ -declare type Props = CommonProps; +declare type Props = CommonProps; -declare interface ChildProps extends CommonProps { +declare interface ChildProps extends CommonProps { /** function slot */ readonly children?: React.ReactNode | React.ReactNode[]; } diff --git a/src/types/props.d.ts b/src/types/props.d.ts new file mode 100644 index 0000000..f2c6e34 --- /dev/null +++ b/src/types/props.d.ts @@ -0,0 +1,11 @@ +declare type ButtonProps = Props; +declare type ButtonChildProps = ChildProps; + +declare type InputProps = Props; +declare type InputChildProps = ChildProps; + +declare type TextAreaProps = Props; +declare type TextAreaChildProps = ChildProps; + +declare type SpanProps = Props; +declare type SpanChildProps = ChildProps; From b5158a1bf768596d194c023c48658326ebf3f58a Mon Sep 17 00:00:00 2001 From: yuanyxh <15766118362@139.com> Date: Wed, 6 Sep 2023 01:38:35 +0800 Subject: [PATCH 2/3] feat: gif encoder complete --- craco.config.js | 20 ++ package-lock.json | 19 +- package.json | 3 +- src/components/InputNumber/InputNumber.tsx | 22 +- src/components/MessageBox/utils.tsx | 1 + src/components/Select/Select.tsx | 1 + src/components/Upload/Upload.tsx | 2 - src/index.css | 2 +- src/pages/GIF-Explorer/GIFVideo.module.css | 16 +- src/pages/GIF-Explorer/GIFVideo.tsx | 201 +++++++++++---- src/pages/GIF-Explorer/gif/GIF.ts | 191 ++++++++++++++ src/pages/GIF-Explorer/gif/GIFByte.ts | 149 +++++++++++ src/pages/GIF-Explorer/gif/Octree.ts | 213 ++++++++++++++++ src/pages/GIF-Explorer/gif/Queue.ts | 63 +++++ src/pages/GIF-Explorer/gif/Trie.ts | 56 +++++ src/pages/GIF-Explorer/gif/config.ts | 19 ++ src/pages/GIF-Explorer/gif/encoder.worker.ts | 247 +++++++++++++++++++ src/pages/GIF-Explorer/types.d.ts | 55 +++++ src/pages/GIF-Explorer/types.ts | 1 - src/pages/GIF-Explorer/utils.ts | 1 - src/pages/PdfParser/lib/PDFParser.ts | 5 +- src/router/index.tsx | 14 +- src/types/index.d.ts | 14 ++ src/types/utils.d.ts | 23 ++ src/utils/elements.ts | 1 + src/utils/index.ts | 34 +++ src/utils/polling.ts | 45 ++++ 27 files changed, 1347 insertions(+), 71 deletions(-) create mode 100644 src/pages/GIF-Explorer/gif/GIF.ts create mode 100644 src/pages/GIF-Explorer/gif/GIFByte.ts create mode 100644 src/pages/GIF-Explorer/gif/Octree.ts create mode 100644 src/pages/GIF-Explorer/gif/Queue.ts create mode 100644 src/pages/GIF-Explorer/gif/Trie.ts create mode 100644 src/pages/GIF-Explorer/gif/config.ts create mode 100644 src/pages/GIF-Explorer/gif/encoder.worker.ts create mode 100644 src/pages/GIF-Explorer/types.d.ts delete mode 100644 src/pages/GIF-Explorer/types.ts delete mode 100644 src/pages/GIF-Explorer/utils.ts create mode 100644 src/types/utils.d.ts create mode 100644 src/utils/polling.ts diff --git a/craco.config.js b/craco.config.js index 5930377..e1236c7 100644 --- a/craco.config.js +++ b/craco.config.js @@ -2,10 +2,30 @@ const path = require('path'); +const { loaderByName, getLoader, addBeforeLoader } = require('@craco/craco'); + module.exports = { webpack: { alias: { '@': path.resolve(__dirname, 'src') + }, + configure(webpackConfig) { + const { isFound } = getLoader( + webpackConfig, + loaderByName('babel-loader') + ); + + if (isFound) { + addBeforeLoader(webpackConfig, loaderByName('source-map-loader'), { + test: /\.worker\.(js|jsx|ts|tsx)$/, + loader: 'worker-loader', + options: { + filename: '[name].[contenthash].worker.js' + } + }); + } + + return webpackConfig; } } }; diff --git a/package-lock.json b/package-lock.json index 033de98..efcf025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,8 @@ "lint-staged": "^13.2.0", "prettier": "^2.8.7", "stylelint": "^15.3.0", - "stylelint-config-standard": "^31.0.0" + "stylelint-config-standard": "^31.0.0", + "worker-loader": "^3.0.8" } }, "node_modules/@adobe/css-tools": { @@ -19900,6 +19901,22 @@ "workbox-core": "6.5.4" } }, + "node_modules/worker-loader": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/worker-loader/-/worker-loader-3.0.8.tgz", + "integrity": "sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 1503816..689f1cc 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "lint-staged": "^13.2.0", "prettier": "^2.8.7", "stylelint": "^15.3.0", - "stylelint-config-standard": "^31.0.0" + "stylelint-config-standard": "^31.0.0", + "worker-loader": "^3.0.8" }, "lint-staged": { "*.js": "eslint --cache --fix", diff --git a/src/components/InputNumber/InputNumber.tsx b/src/components/InputNumber/InputNumber.tsx index 6fed341..5bc33d9 100644 --- a/src/components/InputNumber/InputNumber.tsx +++ b/src/components/InputNumber/InputNumber.tsx @@ -4,8 +4,8 @@ import Input from '@/components/Input/Input'; import style from './InputNumber.module.css'; interface InputNumberProps extends InputProps { - value: number | string; - change: React.Dispatch>; + value: number; + change: React.Dispatch>; size?: 'large' | 'default' | 'small'; disabled?: boolean; step?: number; @@ -31,7 +31,7 @@ export default function InputNumber(props: InputNumberProps) { } = props; const _change = (e: string) => { - if (e.trim() === '') return change(e); + if (e.trim() === '') return change(e as unknown as number); if (/\d+/.test(e)) { let num = window.parseInt(e); @@ -44,28 +44,34 @@ export default function InputNumber(props: InputNumberProps) { if (num < min || num > max) return; - change(isNumber(precision) ? num : num.toFixed(precision)); + change( + isNumber(precision) ? Number(num) : Number(num.toFixed(precision)) + ); } }; const decrease = () => { const num = isNumber(value) ? value - : window.parseInt(value.trim() === '' ? '0' : value); + : window.parseInt((value as string).trim() === '' ? '0' : value); if (num <= min) return; - change(isNumber(precision) ? (num - step).toFixed(precision) : num - step); + change( + isNumber(precision) ? Number((num - step).toFixed(precision)) : num - step + ); }; const increase = () => { const num = isNumber(value) ? value - : window.parseInt(value.trim() === '' ? '0' : value); + : window.parseInt((value as string).trim() === '' ? '0' : value); if (num >= max) return; - change(isNumber(precision) ? (num + step).toFixed(precision) : num + step); + change( + isNumber(precision) ? Number((num + step).toFixed(precision)) : num + step + ); }; const inputNumberClass = generateClass([`input-number-${size}`]); diff --git a/src/components/MessageBox/utils.tsx b/src/components/MessageBox/utils.tsx index 37fbad9..0f11bab 100644 --- a/src/components/MessageBox/utils.tsx +++ b/src/components/MessageBox/utils.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { MessageBoxEncapsulate } from './config'; diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index dfbd453..b375a21 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useRef, useMemo, useState, useEffect } from 'react'; import { useTransition } from '@/hooks'; import { classnames } from '@/utils'; diff --git a/src/components/Upload/Upload.tsx b/src/components/Upload/Upload.tsx index d825766..c099b3e 100644 --- a/src/components/Upload/Upload.tsx +++ b/src/components/Upload/Upload.tsx @@ -172,8 +172,6 @@ export default function Upload(props: Readonly) { if (index < 0) return prev; - console.log(prev[index].percent); - prev[index] = { ...prev[index], status: 'error', diff --git a/src/index.css b/src/index.css index d62361c..06daaad 100644 --- a/src/index.css +++ b/src/index.css @@ -2,7 +2,7 @@ @import url('./styles/variable.css'); @import url('./styles/theme.css'); @import url('./styles/preset.css'); -@import url('//at.alicdn.com/t/c/font_3329662_718am7u5dne.css'); +@import url('//at.alicdn.com/t/c/font_3329662_ezmhfqkr8te.css'); body { margin: 0; diff --git a/src/pages/GIF-Explorer/GIFVideo.module.css b/src/pages/GIF-Explorer/GIFVideo.module.css index 36828a5..7525f8c 100644 --- a/src/pages/GIF-Explorer/GIFVideo.module.css +++ b/src/pages/GIF-Explorer/GIFVideo.module.css @@ -1,15 +1,29 @@ .gif-transfer { display: flex; + align-items: flex-start; } .gif-video-container { position: relative; margin-bottom: 20px; + min-height: 200px; padding: 5px; width: 40%; border: var(--border); border-color: var(--color-primary); border-radius: var(--border-radius-base); + cursor: pointer; +} + +.gif-video-upload { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.icon-upload { + font-size: 50px; } .gif-video { @@ -31,6 +45,6 @@ .gif-transfer-form-item > label { flex-shrink: 0; font-weight: 600; - width: 100px; + width: 105px; white-space: nowrap; } diff --git a/src/pages/GIF-Explorer/GIFVideo.tsx b/src/pages/GIF-Explorer/GIFVideo.tsx index 667db21..6c1a2d7 100644 --- a/src/pages/GIF-Explorer/GIFVideo.tsx +++ b/src/pages/GIF-Explorer/GIFVideo.tsx @@ -1,5 +1,7 @@ import React, { useRef, useState, useEffect } from 'react'; +import { classnames, polling } from '@/utils'; import { useModel } from '@/hooks'; +import GIF from './gif/GIF'; import Card from '@/components/Card/Card'; import Button from '@/components/Button/Button'; import MessageBox from '@/components/MessageBox/MessageBox'; @@ -8,6 +10,7 @@ import Select from '@/components/Select/Select'; import Switch from '@/components/Switch/Switch'; import Text from '@/components/Text/Text'; import TimePicker from './component/TimePicker'; +import type { Transparency, UserInput, DisposalMethod } from './types'; import style from './GIFVideo.module.css'; const instructOptions = [ @@ -17,34 +20,71 @@ const instructOptions = [ { label: '恢复到渲染当前帧之前', value: 12 } ]; +const generateClass = classnames(style); + export default function GIFVideo() { const inputRef = useRef(null); const videoRef = useRef(null); const [blobObject, setBlobObject] = useState(''); - const [timer, setTimer] = useState(null); - const timeModel = useModel('00 : 00 : 05'); - const delay = useModel(50); + const delay = useModel(40); + + const disposalMethod = useModel(4); + + const userInputModel = useModel(0); - const disposalMethod = useModel(0); + const transparencyModel = useModel(0); - const userInputModel = useModel(0); + const transparencyIndexModel = useModel(0); - const transparencyModel = useModel(0); + const cyclesModel = useModel(0); - const transparencyIndexModel = useModel(0); + const scalingModel = useModel(3); useEffect(() => { + window.document.addEventListener('dragover', stopPropagation); + window.document.addEventListener('drop', stopPropagation); + return () => { - if (timer) window.clearInterval(timer); + if (blobObject) { + window.URL.revokeObjectURL(blobObject); + } + + window.document.removeEventListener('dragover', stopPropagation); + window.document.removeEventListener('drop', stopPropagation); }; }, []); + const videoContinerClass = generateClass( + { 'gif-video-upload': !blobObject }, + style['gif-video-container'] + ); + const iconClass = generateClass(['icon-upload'], 'iconfont', 'icon-upload'); + + const stopPropagation = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + }; + const handleSelect = () => inputRef.current?.click(); + const handleDrop: React.DragEventHandler = (e) => { + const { files } = e.dataTransfer; + + if (files === null || files.length === 0) return; + + const file = files[0]; + + if (blobObject) { + window.URL.revokeObjectURL(blobObject); + } + + setBlobObject(window.URL.createObjectURL(file)); + }; + const handleChange: React.ChangeEventHandler = (e) => { const { files } = e.target; @@ -54,7 +94,9 @@ export default function GIFVideo() { window.URL.revokeObjectURL(blobObject); } - setBlobObject(window.URL.createObjectURL(files[0])); + const url = window.URL.createObjectURL(files[0]); + + setBlobObject(url); }; const handleError = () => { @@ -63,6 +105,8 @@ export default function GIFVideo() { title: '播放异常', message: '视频播放失败, 请检查视频格式' }); + + window.URL.revokeObjectURL(blobObject); }; const handlePlay = () => { @@ -78,65 +122,117 @@ export default function GIFVideo() { videoRef.current.load(); - loop(1000 / 24); - }; + polling(() => { + if (videoRef.current === null || videoRef.current.videoWidth === 0) { + return false; + } - const loop = (millisecond: number) => { - const val = window.setInterval(captureScreen, Math.ceil(millisecond)); + const time = +timeModel.value.split(':')[2].trim(); - if (timer) window.clearInterval(timer); + const width = videoRef.current.videoWidth; + const height = videoRef.current.videoHeight; - setTimer(val); - }; + const _canvas = window.document.createElement('canvas'); + const context = _canvas.getContext('2d', { willReadFrequently: true }); - const captureScreen = () => { - if (videoRef.current === null) return; + _canvas.width = Math.round(width / scalingModel.value); + _canvas.height = Math.round(height / scalingModel.value); - // const width = videoRef.current.videoWidth; - // const height = videoRef.current.videoHeight; + const gif = new GIF({ + width: _canvas.width, + height: _canvas.height, + workers: 2 + }); + + gif.setCycles(cyclesModel.value); + gif.setDelay(delay.value); + gif.setDisposalMethod(disposalMethod.value); + gif.setUserInput(userInputModel.value); + + const timer = window.setInterval(() => { + if (videoRef.current === null) return; + + if (videoRef.current.currentTime > time) { + gif.render(); + + return window.clearInterval(timer); + } + + context?.clearRect(0, 0, _canvas.width, _canvas.height); + context?.drawImage( + videoRef.current, + 0, + 0, + width, + height, + 0, + 0, + _canvas.width, + _canvas.height + ); + + gif.addFrame(_canvas); + }, 1000 / 24); + + return true; + }); }; return ( <>
-
- +
{ + e.stopPropagation(); + e.preventDefault(); + }} + onDrop={handleDrop} + onClick={handleSelect} + > + {blobObject ? ( + + ) : ( + <> + +

+ 点击或拖拽上传视频文件 +

+ + )}
- +
+
+ + +
+
-
- - -
-
+
+ + +
+ +
+ + +
+
- -

diff --git a/src/pages/GIF-Explorer/gif/GIF.ts b/src/pages/GIF-Explorer/gif/GIF.ts new file mode 100644 index 0000000..8306faa --- /dev/null +++ b/src/pages/GIF-Explorer/gif/GIF.ts @@ -0,0 +1,191 @@ +import { assign } from '@/utils'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import _Worker from './encoder.worker'; +import Queue from './Queue'; +import GIFByte from './GIFByte'; +import type { + Transparency, + UserInput, + DisposalMethod, + ImageOptions, + GIFConfig +} from '../types'; + +class GIF { + private _width = 0; + private _height = 0; + + private frames = new Queue(); + + private options = { + delay: 40, + cycles: 0, + disposalMethod: 4, + userInput: 0, + transparency: 0, + transparencyIndex: 0 + }; + + private workers: Worker[]; + + private config: GIFConfig; + + private _canvas = window.document.createElement('canvas'); + private _context = this._canvas.getContext('2d', { + willReadFrequently: true + }); + + private firstFrame = true; + + constructor(config?: GIFConfig) { + this.config = config || {}; + + const { workers = 2 } = this.config; + + this.workers = []; + + for (let i = 0; i < workers; i++) + this.workers[this.workers.length] = new _Worker(); + } + + get width() { + return this._width; + } + + get height() { + return this._height; + } + + setDelay(num: number) { + this.options.delay = num; + } + + setCycles(num: number) { + this.options.cycles = num; + } + + setDisposalMethod(num: DisposalMethod) { + this.options.disposalMethod = num; + } + + setUserInput(num: UserInput) { + this.options.userInput = num; + } + + setTransparency(num: Transparency) { + this.options.transparency = num; + } + + setTransparencyIndex(num: number) { + if (num < 0 || num > 255) return this; + + this.options.transparencyIndex = window.parseInt(num.toString()); + } + + getOptions() { + return { ...this.options }; + } + + getConfig() { + return { ...this.config }; + } + + addFrame( + element: + | HTMLImageElement + | HTMLCanvasElement + | ImageData + | CanvasRenderingContext2D, + options?: ImageOptions['options'] + ) { + const frame = {} as ImageOptions; + + if (this.firstFrame) { + this.firstFrame = false; + + if (element instanceof CanvasRenderingContext2D) { + this._width = element.canvas.width; + this._height = element.canvas.height; + } else { + this._width = this.config.width || element.width; + this._height = this.config.height || element.height; + } + } + + if (element instanceof ImageData) { + frame.data = element; + } else { + frame.data = this.getImageData(element); + } + + frame.options = assign( + {}, + this.options, + { width: this.width, height: this.height }, + options || {} + ); + + this.frames.enqueue(frame); + } + + getImageData( + element: HTMLImageElement | HTMLCanvasElement | CanvasRenderingContext2D + ) { + let context!: CanvasRenderingContext2D | null; + + if (element instanceof HTMLImageElement) { + context = this._context; + + context?.clearRect(0, 0, this.width, this.height); + context?.drawImage(element, 0, 0, this.width, this.height); + } else if (element instanceof HTMLCanvasElement) { + context = element.getContext('2d', { willReadFrequently: true }); + } else { + context = element; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return context!.getImageData(0, 0, this.width, this.height); + } + + render() { + const frame = this.frames.dequeue(); + + if (frame?.value === null || frame?.value === undefined) + throw Error('no frame, please add first'); + + const out = new GIFByte(); + out.writeGifMagic(); + out.writeLogicalScreenDescriptor(this.width, this.height, 0x70); + out.writeApplicationExtension(this.options.cycles); + + const worker = this.workers[0]; + + worker.postMessage(frame.value); + + worker.addEventListener('message', (e: MessageEvent) => { + const { data } = e; + + out.writeBytes(data); + + if (this.frames.isEmpty()) { + out.end(); + + const file = new File([out.export()], 'test.gif', { + type: 'image/gif' + }); + + window.open(window.URL.createObjectURL(file)); + } else { + const next = this.frames.dequeue(); + + if (next?.value) { + worker.postMessage(next.value); + } + } + }); + } +} + +export default GIF; diff --git a/src/pages/GIF-Explorer/gif/GIFByte.ts b/src/pages/GIF-Explorer/gif/GIFByte.ts new file mode 100644 index 0000000..044f63f --- /dev/null +++ b/src/pages/GIF-Explorer/gif/GIFByte.ts @@ -0,0 +1,149 @@ +import { + GIF_MAGIC_NUMBER, + APPLICATION_EXTENSION, + GRAPHIC_CONTROL_EXTENSION, + PLAIN_TEXT_EXTENSION, + COMMENT_EXTENSION, + IMAGE_DESCRIPTOR, + APPLICATION_IDENTIFY, + END_GIF, + MAX_LENGTH +} from './config'; + +class GIFByte { + pages: Uint8Array[]; + cursor = 0; + + constructor() { + this.pages = []; + this.newPage(); + } + + newPage() { + this.pages[this.pages.length] = new Uint8Array(MAX_LENGTH); + this.cursor = 0; + } + + writeByte(byte: number) { + this.pages[this.pages.length - 1][this.cursor++] = byte; + + if (this.cursor === MAX_LENGTH) this.newPage(); + } + + writeBytes(bytes: ArrayLike) { + for (let i = 0; i < bytes.length; i++) { + this.writeByte(bytes[i]); + } + } + + writeShort(short: number) { + const bytes = [short & 0xff, (short >> 8) & 0xff]; + + this.writeBytes(bytes); + } + + writeGifMagic() { + this.writeBytes(GIF_MAGIC_NUMBER); + } + + writeLogicalScreenDescriptor( + width: number, + height: number, + info: number, + backgroundIndex = 0, + pixelAspectRatio = 0 + ) { + this.writeShort(width); + this.writeShort(height); + this.writeByte(info); + this.writeByte(backgroundIndex); + this.writeByte(pixelAspectRatio); + } + + writeApplicationExtension(Cycles = 0) { + this.writeBytes(APPLICATION_EXTENSION); + this.writeByte(0x0b); + this.writeBytes(Array.from(new TextEncoder().encode(APPLICATION_IDENTIFY))); + this.writeByte(0x03); + this.writeByte(0x01); + + this.writeShort(Cycles); + + this.writeByte(0); + } + + writeGraphicControlExtension(info = 0, delay = 40, transparent = 0) { + this.writeBytes(GRAPHIC_CONTROL_EXTENSION); + this.writeByte(0x04); + + this.writeByte(info); + this.writeShort(delay); + this.writeByte(transparent); + + this.writeByte(0); + } + + writePlainTextExtension() { + this.writeBytes(PLAIN_TEXT_EXTENSION); + } + + writeCommentExtension() { + this.writeBytes(COMMENT_EXTENSION); + } + + writeImageDescriptor( + width: number, + height: number, + info = 0, + offsetLeft = 0, + offsetTop = 0 + ) { + this.writeBytes(IMAGE_DESCRIPTOR); + + this.writeShort(offsetLeft); + this.writeShort(offsetTop); + + this.writeShort(width); + this.writeShort(height); + + this.writeByte(info); + } + + end() { + this.writeBytes(END_GIF); + } + + export() { + let size = 0; + + const len = this.pages.length; + const cursor = this.cursor; + + if (cursor === 0) { + size = len * MAX_LENGTH; + } else { + size = len > 0 ? (len - 1) * MAX_LENGTH + cursor : 0; + } + + const uint8 = new Uint8Array(size); + + for (let i = 0; i < len; i++) { + const curr = this.pages[i]; + + const last = len - 1; + if (cursor && i === last) { + const slice = curr.slice(0, cursor); + + uint8.set(slice, i * MAX_LENGTH); + + break; + } + + uint8.set(curr, i * MAX_LENGTH); + } + + return uint8; + } +} + +export default GIFByte; diff --git a/src/pages/GIF-Explorer/gif/Octree.ts b/src/pages/GIF-Explorer/gif/Octree.ts new file mode 100644 index 0000000..c3ff9f8 --- /dev/null +++ b/src/pages/GIF-Explorer/gif/Octree.ts @@ -0,0 +1,213 @@ +import type { ColorObject } from '../types'; + +class OctreeNode { + children: OctreeNode[] = []; + color: ColorObject | null; + pixelCount = 0; + + constructor() { + this.color = null; + } +} + +class Octree { + private maxDepth = 8; + private count = 1; + + root: OctreeNode; + + constructor(maxDepth = 8) { + if (maxDepth > 8) { + throw new Error('the maximum depth cannot exceed 8'); + } + + this.maxDepth = maxDepth; + this.root = new OctreeNode(); + } + + insertColor(color: ColorObject) { + let node = this.root; + + node.pixelCount++; + + for (let depth = this.maxDepth - 1; depth >= 0; depth--) { + const index = this.getColorIndex(color, depth); + + if (!node.children[index]) { + node.children[index] = new OctreeNode(); + } + + node = node.children[index]; + + node.pixelCount++; + } + + if (node.color === null) { + this.count++; + + return (node.color = color); + } + + const rgb = ['r', 'g', 'b'] as const; + + for (let i = 0; i < rgb.length; i++) { + node.color[rgb[i]] += color[rgb[i]]; + } + } + + getColorIndex(color: ColorObject, depth: number) { + let index = 0; + const mask = 1 << depth; + + if (color.r & mask) { + index |= 1; + } + + if (color.g & mask) { + index |= 2; + } + + if (color.b & mask) { + index |= 4; + } + + return index; + } + + shrink(maxCount: number) { + if (this.count <= maxCount) return; + + for (let depth = this.maxDepth - 1; depth >= 0; depth--) { + this.reduceColor(this.root, depth, 0, maxCount); + + if (this.count <= maxCount) break; + } + + return this.collectColor(); + } + + reduceColor( + node: OctreeNode, + depth: number, + currDepth: number, + maxCount: number + ) { + if (this.count <= maxCount) return; + + const children = [...node.children]; + + if (depth === currDepth) { + this._reduceColor(node, maxCount); + } else if (depth > currDepth) { + for (let i = 0; i < children.length; i++) { + if (children[i] === null || children[i] === undefined) continue; + + this.reduceColor(children[i], depth, currDepth + 1, maxCount); + } + } + } + + _reduceColor(node: OctreeNode, maxCount: number) { + let isClear = true; + + const children = [...node.children]; + + children.sort((a, b) => a.pixelCount - b.pixelCount); + + for (let i = 0; i < children.length; i++) { + if (children[i] === null || children[i] === undefined) continue; + + const color = children[i].color; + + if (color === null) continue; + + if (node.color === null) { + node.color = color; + + continue; + } + + const rgb = ['r', 'g', 'b'] as const; + + for (let j = 0; j < rgb.length; j++) { + node.color[rgb[j]] += color[rgb[j]]; + } + + this.count--; + + if (this.count <= maxCount) { + isClear = false; + + for (let j = 0; j < i; j++) { + const index = node.children.indexOf(children[j]); + + node.children.splice(index, 1, null as unknown as OctreeNode); + } + + break; + } + } + if (isClear) node.children = []; + } + + quantizeColor(color: ColorObject) { + let node = this.root; + + for (let depth = this.maxDepth - 1; depth >= 0; depth--) { + const index = this.getColorIndex(color, depth); + if (!node.children[index]) { + break; + } + node = node.children[index]; + } + + if (node.color?.normalize) { + return node.color; + } + + if (node.color) { + const { r, g, b } = node.color; + + node.color.r = Math.round(r / node.pixelCount) || 0; + node.color.g = Math.round(g / node.pixelCount) || 0; + node.color.b = Math.round(b / node.pixelCount) || 0; + node.color.normalize = true; + } + + return node.color; + } + + collectColor(node = this.root) { + if (node === null || node === undefined) return []; + + const colors: ColorObject[] = []; + const children = node.children; + + for (let i = 0; i < children.length; i++) { + if (children[i] === null || children[i] === undefined) continue; + + const { color } = children[i]; + if (color) { + colors.push(color); + } + + colors.push(...this.collectColor(children[i])); + } + + return colors; + } + + calcColorDistance(color_1: ColorObject, color_2: ColorObject) { + const d = Math.sqrt( + (color_1.r - color_2.r) ** 2 + + (color_1.g - color_2.g) ** 2 + + (color_1.b - color_2.b) ** 2 + ); + + return Math.floor( + (1 - d / Math.sqrt(255 ** 2 + 255 ** 2 + 255 ** 2)) * 100 + ); + } +} + +export default Octree; diff --git a/src/pages/GIF-Explorer/gif/Queue.ts b/src/pages/GIF-Explorer/gif/Queue.ts new file mode 100644 index 0000000..be54898 --- /dev/null +++ b/src/pages/GIF-Explorer/gif/Queue.ts @@ -0,0 +1,63 @@ +class Element { + value?: T; + next: Element | null; + + constructor(value?: T) { + this.value = value; + this.next = null; + } +} + +class Queue { + queue: Element | null; + private _size = 0; + + constructor() { + this.queue = new Element(); + } + + enqueue(value: T) { + let next = this.queue; + + if (this.isEmpty()) { + this.queue = new Element(value); + + return ++this._size; + } + + while (next?.next) { + next = next.next; + } + + if (next) { + next.next = new Element(value); + return ++this._size; + } + } + + dequeue() { + if (this.queue === null) return this.queue; + + const temp = this.queue; + + this.queue = this.queue.next; + + --this._size; + + return temp; + } + + clear() { + this.queue = null; + } + + isEmpty() { + return this.size === 0; + } + + get size() { + return this._size; + } +} + +export default Queue; diff --git a/src/pages/GIF-Explorer/gif/Trie.ts b/src/pages/GIF-Explorer/gif/Trie.ts new file mode 100644 index 0000000..5a9caef --- /dev/null +++ b/src/pages/GIF-Explorer/gif/Trie.ts @@ -0,0 +1,56 @@ +class TrieNode { + children: TrieNode[]; + isEndOfWord: boolean; + code!: number; + + constructor() { + this.children = new Array(4096); + this.isEndOfWord = false; + } +} + +class Trie { + root = new TrieNode(); + count = 0; + + insert(nums: number[]) { + let node = this.root; + + for (let i = 0; i < nums.length; i++) { + const index = nums[i]; + + if (!node.children[index]) { + node.children[index] = new TrieNode(); + } + + node = node.children[index]; + } + + node.isEndOfWord = true; + node.code = this.count; + + this.count++; + } + + search(nums: number[]) { + let node = this.root; + for (let i = 0; i < nums.length; i++) { + const index = nums[i]; + + if (!node.children[index]) { + return false; + } + + node = node.children[index]; + } + + return node; + } + + clear() { + this.root = new TrieNode(); + this.count = 0; + } +} + +export default Trie; diff --git a/src/pages/GIF-Explorer/gif/config.ts b/src/pages/GIF-Explorer/gif/config.ts new file mode 100644 index 0000000..a7de17a --- /dev/null +++ b/src/pages/GIF-Explorer/gif/config.ts @@ -0,0 +1,19 @@ +export const GIF_MAGIC_NUMBER = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; + +export const APPLICATION_EXTENSION = [0x21, 0xff]; + +export const PLAIN_TEXT_EXTENSION = [0x21, 0x01]; + +export const COMMENT_EXTENSION = [0x21, 0xf1]; + +export const GRAPHIC_CONTROL_EXTENSION = [0x21, 0xf9]; + +export const IMAGE_DESCRIPTOR = [0x2c]; + +export const APPLICATION_IDENTIFY = 'NETSCAPE2.0'; + +export const END_GIF = [0x3b]; + +export const MAX_LENGTH = 4096; + +export const MAX_CODE_SIZE = 12; diff --git a/src/pages/GIF-Explorer/gif/encoder.worker.ts b/src/pages/GIF-Explorer/gif/encoder.worker.ts new file mode 100644 index 0000000..c17fb5a --- /dev/null +++ b/src/pages/GIF-Explorer/gif/encoder.worker.ts @@ -0,0 +1,247 @@ +import { MAX_CODE_SIZE } from './config'; +import Octree from './Octree'; +import Trie from './Trie'; +import GIFByte from './GIFByte'; +import type { ImageOptions, ColorObject } from '../types'; + +const _self = self as unknown as Worker; +function encoder(e: MessageEvent) { + const { data } = e; + + const { + data: { data: pixels }, + options + } = data; + + const { + offsetLeft = 0, + offsetTop = 0, + width = 0, + height = 0, + disposalMethod = 4, + userInput = 0, + transparency = 0, + delay = 40, + transparencyIndex = 0 + } = options; + + const map = new Map(); + const input: number[] = []; + + const octree = new Octree(); + + // console.log('start', self.performance.now()); + + // 八叉树颜色量化 + for (let i = 0; i < pixels.length; i += 4) { + octree.insertColor({ r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] }); + } + + // 减色至 256 色 + octree.shrink(256); + + // 获取颜色列表 + const colorList = octree.collectColor(); + + // 颜色转换为调色盘中颜色的索引 + for (let i = 0; i < pixels.length; i += 4) { + const color = octree.quantizeColor({ + r: pixels[i], + g: pixels[i + 1], + b: pixels[i + 2] + }); + + if (color) { + if (map.has(color)) { + input.push(map.get(color) || 0); + } else { + const index = colorList.indexOf(color); + map.set(color, index); + input.push(index); + + if (index < 0 || index > 255) { + throw Error( + 'the color disk compilation fails, exceeding the expected value' + ); + } + } + } else { + throw Error( + 'the color disk compilation fails, and there is a problem with the quantification of the color' + ); + } + } + + if (input.length !== width * height) { + throw Error('index length error'); + } + + // 本地颜色表 + const localColorTable = colorList.map(({ r, g, b }) => [r, g, b]); + + // 初始化最小代码大小 + let lzwMiniCodeSize = 2; + while (localColorTable.length > 1 << lzwMiniCodeSize) lzwMiniCodeSize++; + + // 填充色盘, 防止色盘大小错误 + for (let i = 0; i < (1 << lzwMiniCodeSize) - localColorTable.length; i++) { + localColorTable.push([0, 0, 0]); + } + + // 初始化清除码、结束码、码表 + const clearCode = 1 << lzwMiniCodeSize; + const eoiCode = clearCode + 1; + + // 初始化查找表、字节输出 + const trie = new Trie(); + const out = new GIFByte(); + + // 输出图形控制器 + out.writeGraphicControlExtension( + disposalMethod | userInput | transparency, + delay / 10, + transparencyIndex + ); + // 输出图像描述符 + out.writeImageDescriptor( + width, + height, + 0x80 | (lzwMiniCodeSize - 1), + offsetLeft, + offsetTop + ); + // 输出本地颜色表 + out.writeBytes(localColorTable.flat()); + // 输出最小代码大小 + out.writeByte(lzwMiniCodeSize); + + // 初始化可变代码大小、前缀、当前输入 k + let codeSize = lzwMiniCodeSize + 1; + let perfix: number[] = []; + let k: number[] = []; + + // 初始化输入长度、当前索引 point + const len = input.length; + let point = 0; + + // 剩余位 + let bit = 0; + // 偏移位 + let offset = 0; + + // 字节 + const bytes: number[] = []; + + const set = (code: number) => { + code <<= offset; + code |= bit; + + offset += codeSize; + + while (offset >= 8) { + bytes[bytes.length] = code & 0xff; + + if (bytes.length === 0xff) { + out.writeByte(0xff); + out.writeBytes(bytes); + + bytes.length = 0; + } + + code >>>= 8; + offset -= 8; + } + + bit = code; + }; + + // 首先输出清除码 + set(clearCode); + + // 取第一个输入作为初始化的当前前缀 + perfix = [input[point++]]; + + // 初始化查找表 + for (let i = 0; i <= eoiCode; i++) trie.insert([i]); + + // 主循环体 + while (point < len) { + // 获取下一个输入作为 k + k = [input[point++]]; + + // 查找表中查找 前缀 + k + const current = perfix.concat(k); + const result = trie.search(current); + + // 找到则 前缀 = 前缀 + k + if (result) { + perfix = current; + k = []; + + continue; + } + + // 未找到在查找表中插入 + trie.insert(current); + + // 获取前缀对应的码 + const perfixCode = trie.search(perfix); + + // 添加至码表 + perfixCode && set(perfixCode.code); + + // 前缀 = k + perfix = k; + // k 重置 + k = []; + + // 获取刚刚添加的码 + const preCode = trie.count - 1; + + // 如果码大于 codeSize 所能表示的数字且 codeSize 小于 12, codeSize + 1 + if (preCode > (1 << codeSize) - 1 && codeSize < MAX_CODE_SIZE) { + codeSize++; + } else if (preCode === 1 << MAX_CODE_SIZE) { + // 如果码等于最大代码所能表示的值, 重新初始化查找表 + trie.clear(); + for (let i = 0; i <= eoiCode; i++) trie.insert([i]); + + // 输出清除码 + set(clearCode); + + // 重置 codeSize + codeSize = lzwMiniCodeSize + 1; + } + } + + // 已完成输出最后的码 + const val = trie.search(perfix); + val && set(val.code); + + // 输出信息结束码 + set(eoiCode); + + // 如果还有剩余的位, 添加至字节列表 + if (offset !== 0) { + bytes[bytes.length] = bit; + } + + // 如果字节还有数据, 输出至字节数组 + if (bytes.length) { + out.writeByte(bytes.length); + out.writeBytes(bytes); + } + + // 块结束 + out.writeByte(0x00); + + const image = out.export(); + + _self.postMessage(image); + + // console.log('end', self.performance.now()); +} + +_self.addEventListener('message', encoder); + +export {}; diff --git a/src/pages/GIF-Explorer/types.d.ts b/src/pages/GIF-Explorer/types.d.ts new file mode 100644 index 0000000..9175ace --- /dev/null +++ b/src/pages/GIF-Explorer/types.d.ts @@ -0,0 +1,55 @@ +export type DisposalMethod = 0 | 4 | 8 | 12; + +export type UserInput = 0 | 2; + +export type Transparency = 0 | 1; + +export interface ImageOptions { + data: ImageData; + options: { + width?: number; + height?: number; + offsetLeft?: number; + offsetTop?: number; + delay?: number; + userInput?: UserInput; + disposalMethod?: DisposalMethod; + transparency?: Transparency; + transparencyIndex?: number; + }; +} + +export interface GIFConfig { + width?: number; + height?: number; + workers?: number; +} + +export interface InputData extends Pick { + width: data.data.width; + height: data.data.height; + colorTable: number[][]; + inputStream: number[]; +} + +export interface OutputData { + index: number; + data: number[]; +} + +export type Color = 'color'; +export type Encode = 'encode'; +export type EventOn = 'message' | 'error'; +export type ColorTypeOn = `${Color}/${EventOn}`; +export type EncodeTypeOn = `${Encode}/${EventOn}`; +export type MergeTypeOn = ColorTypeOn | EncodeTypeOn; +export type ColorTypeSend = `${Color}/postMessage`; +export type EncodeTypeSend = `${Encode}/postMessage`; +export type MergeTypeSend = ColorTypeSend | EncodeTypeSend; + +export type ColorObject = { + r: number; + g: number; + b: number; + normalize?: boolean; +}; diff --git a/src/pages/GIF-Explorer/types.ts b/src/pages/GIF-Explorer/types.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/src/pages/GIF-Explorer/types.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/pages/GIF-Explorer/utils.ts b/src/pages/GIF-Explorer/utils.ts deleted file mode 100644 index cc798ff..0000000 --- a/src/pages/GIF-Explorer/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const a = 1; diff --git a/src/pages/PdfParser/lib/PDFParser.ts b/src/pages/PdfParser/lib/PDFParser.ts index 81dc21c..a1bfb2b 100644 --- a/src/pages/PdfParser/lib/PDFParser.ts +++ b/src/pages/PdfParser/lib/PDFParser.ts @@ -634,8 +634,6 @@ export class Draw { // pdf.set(xref[DescendantFonts[0].serial]); - // console.log(pdf.parseDictionary(stream)); - pdf.set(xref[ToUnicode.serial]); const { Filter } = pdf.parseDictionary(stream); @@ -710,6 +708,7 @@ export class Draw { const xref = pdf.xref; /** 操作栈 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any const operations: any[] = []; /** 解析完成 */ @@ -802,8 +801,6 @@ export class Draw { pdf.set(xref[gs.serial]); - // console.log(pdf.parseDictionary(pdf.bytes)); - break; /** 保存状态 */ diff --git a/src/router/index.tsx b/src/router/index.tsx index ae865e9..86fc192 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -10,8 +10,8 @@ const FileSystemAccess = lazy( () => import('@/pages/FileSystemAccess/FileSystemAccess') ); const GIFExplorer = lazy(() => import('@/pages/GIF-Explorer/GIF-Explorer')); -const Test = lazy(() => import('@/pages/Test/Test')); const PdfParser = lazy(() => import('@/pages/PdfParser/PdfParser')); +const Test = lazy(() => import('@/pages/Test/Test')); const UploadFile = lazy(() => import('@/pages/UploadFile/UploadFile')); const VisualEdit = lazy(() => import('@/pages/VisualEdit/VisualEdit')); @@ -49,18 +49,18 @@ export const routes: Route.CustomRouteObject[] = [ image: '', element: }, - { - path: 'test', - title: '测试页面', - image: '', - element: - }, { path: 'pdf-parser', title: 'PDF 解析', image: '', element: }, + { + path: 'test', + title: '测试页面', + image: '', + element: + }, { path: 'upload-file', title: '文件上传', diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 35bb3d8..d051a60 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -15,3 +15,17 @@ declare interface ChildProps extends CommonProps { /** function slot */ readonly children?: React.ReactNode | React.ReactNode[]; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare type Fn = (...args: unknown[]) => any; + +declare module '*.worker.ts' { + // You need to change `Worker`, if you specified a different value for the `workerType` option + class WebpackWorker extends Worker { + constructor(); + } + + // Uncomment this if you set the `esModule` option to `false` + // export = WebpackWorker; + export default WebpackWorker; +} diff --git a/src/types/utils.d.ts b/src/types/utils.d.ts new file mode 100644 index 0000000..b09c60f --- /dev/null +++ b/src/types/utils.d.ts @@ -0,0 +1,23 @@ +declare enum PollingSubscribeType { + failed = 'failed' +} + +declare interface PollingOptions { + delay?: number; + maxRetryCount?: number; +} + +declare interface PollingResult { + cacelPolling(): void; + subscribe(type: PollingSubscribeType, fn: T): void; +} + +declare type Polling = ( + fn: T, + options?: PollingOptions, + ...args: Parameters +) => void; + +declare type OrdinaryObject = { + [key: string | symbol]: unknown; +}; diff --git a/src/utils/elements.ts b/src/utils/elements.ts index 4a28d11..82aebb6 100644 --- a/src/utils/elements.ts +++ b/src/utils/elements.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/ban-ts-comment */ /** diff --git a/src/utils/index.ts b/src/utils/index.ts index ffa0061..89d1bf8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -149,6 +149,38 @@ export const checkCharacter = (reg: RegExp) => (s: string) => reg.test(s); export const isRenderElement = (condition: unknown) => condition ? 'render' : undefined; +export const assign = (obj: OrdinaryObject, ...args: OrdinaryObject[]) => { + for (let i = 0; i < args.length; i++) { + const curr = args[i]; + const _names = Object.getOwnPropertyNames(args[i]); + const _symbols = Object.getOwnPropertySymbols(args[i]); + + for (let j = 0; j < _names.length; j++) { + if ( + typeof curr[_names[j]] === 'undefined' && + typeof obj[_names[j]] !== 'undefined' + ) { + continue; + } + + obj[_names[j]] = curr[_names[j]]; + } + + for (let j = 0; j < _symbols.length; j++) { + if ( + typeof curr[_symbols[j]] === 'undefined' && + typeof obj[_symbols[j]] !== 'undefined' + ) { + continue; + } + + obj[_symbols[j]] = curr[_symbols[j]]; + } + } + + return obj; +}; + export * from './http'; export * from './classnames'; @@ -157,4 +189,6 @@ export * from './elements'; export * from './events'; +export * from './polling'; + export { default as base64 } from './crypto/base64'; diff --git a/src/utils/polling.ts b/src/utils/polling.ts new file mode 100644 index 0000000..01e6510 --- /dev/null +++ b/src/utils/polling.ts @@ -0,0 +1,45 @@ +/** + * + * @param fn 需要执行的回调, 返回 false 继续轮询, 返回 true 结束轮询 + * @param options 设置最大轮询数与轮询间隔 + * @param args 需要传递给回调的额外参数 + * @returns 返回一个包含取消轮询、订阅消息方法的对象 + */ +export const polling: Polling = (fn, options, ...args) => { + const { delay = 10, maxRetryCount = 20 } = options || {}; + + let surplus = maxRetryCount; + + const bus = new Map(); + + const _fn = () => { + const result = fn(...args); + + if (result) return window.clearTimeout(timer); + else surplus--; + + if (surplus === 0) { + return bus.forEach((events) => events.forEach((fn) => fn())); + } + + timer = window.setTimeout(_fn, delay); + }; + + const subscribe: PollingResult['subscribe'] = (type, fn) => { + const events = bus.get(type); + + if (events) { + events.push(fn); + bus.set(type, events); + } else { + bus.set(type, [fn]); + } + }; + + const cacelPolling: PollingResult['cacelPolling'] = () => + window.clearTimeout(timer); + + let timer = window.setTimeout(_fn, delay); + + return { subscribe, cacelPolling }; +}; From 981b314d4667643fbafbd24db72fc0f57c6e854f Mon Sep 17 00:00:00 2001 From: yuanyxh <15766118362@139.com> Date: Mon, 11 Sep 2023 02:20:58 +0800 Subject: [PATCH 3/3] feat: complete gif page --- src/components/Card/Card.module.css | 1 - src/components/InputNumber/InputNumber.tsx | 4 +- src/components/MessageBox/MessageBox.tsx | 2 + src/components/Select/Select.module.css | 36 -- src/components/Select/Select.tsx | 4 +- src/components/Upload/Upload.tsx | 4 +- src/components/Upload/utils.ts | 7 +- src/index.css | 2 +- src/layout/Layout.module.css | 2 +- src/layout/Main.module.css | 1 - src/layout/Main.tsx | 2 +- .../GIF-Explorer/GIF-Explorer.module.css | 1 + src/pages/GIF-Explorer/GIF-Explorer.tsx | 6 + src/pages/GIF-Explorer/GIFPicture.module.css | 18 + src/pages/GIF-Explorer/GIFPicture.tsx | 434 ++++++++++++++++ src/pages/GIF-Explorer/GIFPlayer.module.css | 24 + src/pages/GIF-Explorer/GIFPlayer.tsx | 311 +++++++++++ src/pages/GIF-Explorer/GIFVideo.module.css | 23 +- src/pages/GIF-Explorer/GIFVideo.tsx | 320 ++++++------ .../component/Configuration.module.css | 85 +++ .../GIF-Explorer/component/Configuration.tsx | 333 ++++++++++++ src/pages/GIF-Explorer/gif/GIF.ts | 211 ++++++-- src/pages/GIF-Explorer/gif/GIFByte.ts | 14 +- src/pages/GIF-Explorer/gif/GIFDecoder.ts | 489 ++++++++++++++++++ src/pages/GIF-Explorer/gif/Octree.ts | 24 +- src/pages/GIF-Explorer/gif/bulidColor.ts | 20 + src/pages/GIF-Explorer/gif/config.ts | 7 +- src/pages/GIF-Explorer/gif/decoder.worker.ts | 192 +++++++ src/pages/GIF-Explorer/gif/encoder.worker.ts | 27 +- src/pages/GIF-Explorer/gif/utils.ts | 28 + src/pages/GIF-Explorer/types.d.ts | 99 +++- src/pages/PdfParser/lib/PDFParser.ts | 8 +- src/pages/PdfParser/lib/utils.ts | 13 - src/pages/UploadFile/UploadFile.tsx | 2 - src/pages/VisualEdit/Button.module.css | 18 - src/pages/VisualEdit/Button.tsx | 14 - src/pages/VisualEdit/Input.module.css | 10 - src/pages/VisualEdit/Input.tsx | 12 - src/pages/VisualEdit/VisualEdit.module.css | 2 +- src/pages/VisualEdit/VisualEdit.tsx | 14 +- src/styles/animation.css | 35 ++ src/types/utils.d.ts | 15 + src/utils/index.ts | 36 +- 43 files changed, 2546 insertions(+), 364 deletions(-) create mode 100644 src/pages/GIF-Explorer/GIFPicture.module.css create mode 100644 src/pages/GIF-Explorer/GIFPicture.tsx create mode 100644 src/pages/GIF-Explorer/GIFPlayer.module.css create mode 100644 src/pages/GIF-Explorer/GIFPlayer.tsx create mode 100644 src/pages/GIF-Explorer/component/Configuration.module.css create mode 100644 src/pages/GIF-Explorer/component/Configuration.tsx create mode 100644 src/pages/GIF-Explorer/gif/GIFDecoder.ts create mode 100644 src/pages/GIF-Explorer/gif/bulidColor.ts create mode 100644 src/pages/GIF-Explorer/gif/decoder.worker.ts create mode 100644 src/pages/GIF-Explorer/gif/utils.ts delete mode 100644 src/pages/VisualEdit/Button.module.css delete mode 100644 src/pages/VisualEdit/Button.tsx delete mode 100644 src/pages/VisualEdit/Input.module.css delete mode 100644 src/pages/VisualEdit/Input.tsx create mode 100644 src/styles/animation.css diff --git a/src/components/Card/Card.module.css b/src/components/Card/Card.module.css index a5f085a..9b7eb85 100644 --- a/src/components/Card/Card.module.css +++ b/src/components/Card/Card.module.css @@ -7,7 +7,6 @@ border-radius: var(--card-border-radius); border: 1px solid var(--card-border-color); background-color: var(--card-bg-color); - overflow: hidden; color: var(--text-color-primary); transition: var(--transition-duration); } diff --git a/src/components/InputNumber/InputNumber.tsx b/src/components/InputNumber/InputNumber.tsx index 5bc33d9..a809b82 100644 --- a/src/components/InputNumber/InputNumber.tsx +++ b/src/components/InputNumber/InputNumber.tsx @@ -27,7 +27,8 @@ export default function InputNumber(props: InputNumberProps) { max = window.Infinity, precision, size = 'default', - disabled + disabled, + ...nativeProps } = props; const _change = (e: string) => { @@ -113,6 +114,7 @@ export default function InputNumber(props: InputNumberProps) { size={size} disabled={disabled} className={style['input-number-wrapper']} + {...(nativeProps as object)} >

); diff --git a/src/components/MessageBox/MessageBox.tsx b/src/components/MessageBox/MessageBox.tsx index d325a8c..f94555e 100644 --- a/src/components/MessageBox/MessageBox.tsx +++ b/src/components/MessageBox/MessageBox.tsx @@ -59,6 +59,8 @@ export function _MessageBox(props: _MessageBoxProps) { ...nativeProps } = props; + appendTo; + const [init, setInit] = useState(true); const [visible, setVisible] = useState(false); const [action, setAction] = useState('close'); diff --git a/src/components/Select/Select.module.css b/src/components/Select/Select.module.css index a4f3a9b..9e6f07a 100644 --- a/src/components/Select/Select.module.css +++ b/src/components/Select/Select.module.css @@ -81,39 +81,3 @@ font-weight: 700; background-color: var(--fill-color-light); } - -.select-options-enter-active { - animation: zoom-in var(--transition-duration) - var(--transition-function-fast-bezier); - transform-origin: center top; -} - -.select-options-leave-active { - animation: zoom-out var(--transition-duration) - var(--transition-function-fast-bezier); - transform-origin: center top; -} - -@keyframes zoom-in { - 0% { - opacity: 0; - transform: scaleY(0); - } - - 100% { - opacity: 1; - transform: scaleY(1); - } -} - -@keyframes zoom-out { - 0% { - opacity: 1; - transform: scaleY(1); - } - - 100% { - opacity: 0; - transform: scaleY(0); - } -} diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index b375a21..a793e19 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -22,11 +22,11 @@ interface SelectProps extends Props { } const enter: TransitionClass = { - active: style['select-options-enter-active'] + active: 'zoom-in-active' }; const leave: TransitionClass = { - active: style['select-options-leave-active'] + active: 'zoom-out-active' }; export default function Select(props: SelectProps) { diff --git a/src/components/Upload/Upload.tsx b/src/components/Upload/Upload.tsx index c099b3e..cd42e72 100644 --- a/src/components/Upload/Upload.tsx +++ b/src/components/Upload/Upload.tsx @@ -193,7 +193,9 @@ export default function Upload(props: Readonly) { let defaultSlot: Children, tipsSlot: Children; - if (isArray(children) || isValidElement(children)) defaultSlot = children; + if (isArray(children) || isValidElement(children)) { + defaultSlot = children; + } if (isNameSlot(children)) { const { default: defaultElement, tips } = children; diff --git a/src/components/Upload/utils.ts b/src/components/Upload/utils.ts index 6e49568..e4b2997 100644 --- a/src/components/Upload/utils.ts +++ b/src/components/Upload/utils.ts @@ -1,3 +1,4 @@ +import { isValidElement } from 'react'; import { hasData } from '@/utils'; import type { NameSlot } from './types'; @@ -15,7 +16,11 @@ export function generateId() { /** 是否是命名插槽 */ export function isNameSlot(data: unknown): data is NameSlot { - return hasData(data) && hasData((data as NameSlot).default); + return ( + hasData(data) && + (typeof (data as NameSlot).default === 'function' || + isValidElement((data as NameSlot).default)) + ); } /** 获取最大字节长度 */ diff --git a/src/index.css b/src/index.css index 06daaad..3fc5968 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,7 @@ @import url('./styles/variable.css'); @import url('./styles/theme.css'); @import url('./styles/preset.css'); +@import url('./styles//animation.css'); @import url('//at.alicdn.com/t/c/font_3329662_ezmhfqkr8te.css'); body { @@ -12,7 +13,6 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - height: 100vh; color: #333; font-size: var(--font-size-base); background-color: var(--background-color); diff --git a/src/layout/Layout.module.css b/src/layout/Layout.module.css index 0951c50..e153b3b 100644 --- a/src/layout/Layout.module.css +++ b/src/layout/Layout.module.css @@ -1,5 +1,5 @@ .layout { - height: 100vh; + min-height: 100vh; } @media screen and (min-width: 768px) { diff --git a/src/layout/Main.module.css b/src/layout/Main.module.css index 749a2df..62b817b 100644 --- a/src/layout/Main.module.css +++ b/src/layout/Main.module.css @@ -1,6 +1,5 @@ .main { padding-left: var(--sidebar-width); - height: inherit; } @media screen and (max-width: 840px) { diff --git a/src/layout/Main.tsx b/src/layout/Main.tsx index 3bf0156..5cfacba 100644 --- a/src/layout/Main.tsx +++ b/src/layout/Main.tsx @@ -9,7 +9,7 @@ import style from './Main.module.css'; export default memo(function Main() { return (
- }> + }>
diff --git a/src/pages/GIF-Explorer/GIF-Explorer.module.css b/src/pages/GIF-Explorer/GIF-Explorer.module.css index 7388ef8..8cfc9f7 100644 --- a/src/pages/GIF-Explorer/GIF-Explorer.module.css +++ b/src/pages/GIF-Explorer/GIF-Explorer.module.css @@ -1,3 +1,4 @@ .gif-explorer { padding: 10px; + padding-bottom: 150px; } diff --git a/src/pages/GIF-Explorer/GIF-Explorer.tsx b/src/pages/GIF-Explorer/GIF-Explorer.tsx index a7a9cbf..8955549 100644 --- a/src/pages/GIF-Explorer/GIF-Explorer.tsx +++ b/src/pages/GIF-Explorer/GIF-Explorer.tsx @@ -1,5 +1,7 @@ import React from 'react'; import GIFVideo from './GIFVideo'; +import GIFPicture from './GIFPicture'; +import GIFPlayer from './GIFPlayer'; import style from './GIF-Explorer.module.css'; // --title: GIF 编解码-- @@ -8,6 +10,10 @@ export default function GIFExplorer() { return (
+ + + +
); } diff --git a/src/pages/GIF-Explorer/GIFPicture.module.css b/src/pages/GIF-Explorer/GIFPicture.module.css new file mode 100644 index 0000000..fd1b60c --- /dev/null +++ b/src/pages/GIF-Explorer/GIFPicture.module.css @@ -0,0 +1,18 @@ +.gif-picture { + display: flex; +} + +.drawing-board-container { + margin-right: 20px; +} + +.drawing-board { + touch-action: none; +} + +.opterator { + display: flex; + column-gap: 10px; + margin-top: 40px; + margin-left: 30px; +} diff --git a/src/pages/GIF-Explorer/GIFPicture.tsx b/src/pages/GIF-Explorer/GIFPicture.tsx new file mode 100644 index 0000000..6d7e98c --- /dev/null +++ b/src/pages/GIF-Explorer/GIFPicture.tsx @@ -0,0 +1,434 @@ +import React, { useRef, useMemo, useState, useEffect } from 'react'; +import { isEmpty, createCanvasContext } from '@/utils'; +import bulidColor from './gif/bulidColor'; +import GIF from './gif/GIF'; +import Card from '@/components/Card/Card'; +import Button from '@/components/Button/Button'; +import MessageBox from '@/components/MessageBox/MessageBox'; +import Configuration from './component/Configuration'; +import type { BulidColor, ImageOptions } from './types'; +import type { Options } from './component/Configuration'; +import style from './GIFPicture.module.css'; + +export default function GIFPicture() { + const drawRef = useRef(null); + const inputRef = useRef(null); + const configurationRef = useRef(null); + + const [context, setContext] = useState(null); + const gif = useRef(new GIF()); + + const [isDisabled, setIsDisabled] = useState(false); + const [isDrawBackground, setIsDrawBackground] = useState(false); + const [isMove, setIsMove] = useState(false); + const [rect, setRect] = useState({ left: 0, top: 0, width: 0, height: 0 }); + const [frames, setFrames] = useState([]); + const [currentImage, setCurrentImage] = useState( + null + ); + + useMemo(() => { + gif.current.on('finished', (blob) => { + window.open(window.URL.createObjectURL(blob)); + + setIsDisabled(false); + setFrames([]); + reset(); + context?.beginPath(); + }); + }, []); + + useEffect(() => { + const context = drawRef.current?.getContext('2d', { + willReadFrequently: true + }); + + setContext(context as CanvasRenderingContext2D); + setIsDrawBackground(true); + }, []); + + useEffect(() => { + drawRef.current?.addEventListener('wheel', preventDefault); + return () => { + drawRef.current?.removeEventListener('wheel', preventDefault); + }; + }, []); + + useEffect(() => { + if (isDrawBackground) { + drawBakcground(); + setIsDrawBackground(false); + } + }, [isDrawBackground]); + + useEffect(() => { + if (isEmpty(currentImage)) return; + + const { canvas: _canvas, context } = createCanvasContext({ + width: currentImage.width, + height: currentImage.height + }); + + context?.drawImage(currentImage, 0, 0); + const imageData = context?.getImageData( + 0, + 0, + _canvas.width, + _canvas.height + ); + + if (imageData) { + const colorTable = bulidColor(imageData.data); + + configurationRef.current?.setColorTable(colorTable); + } + }, [currentImage]); + + const preventDefault = (e: Event) => e.preventDefault(); + + const reset = () => { + if (isEmpty(drawRef.current) || isEmpty(context)) return; + + context.clearRect(0, 0, drawRef.current.width, drawRef.current.height); + + setCurrentImage(null); + setIsMove(false); + setRect({ left: 0, top: 0, width: 0, height: 0 }); + setIsDrawBackground(true); + configurationRef.current?.setColorTable({ + colorList: [], + octree: null + } as BulidColor); + }; + + const drawBakcground = () => { + if (isEmpty(context)) return; + + const width = drawRef.current?.width || 0; + const height = drawRef.current?.height || 0; + + const imageData = context.createImageData(width, height); + + const { data } = imageData; + + // 通过 canvas宽高 来遍历一下 canvas 上的所有像素点 + for (let i = 0; i < height; i++) { + for (let j = 0; j < width; j++) { + const point = (i * width + j) << 2; + const rgb = ((i >> 2) + (j >> 2)) & 1 ? 204 : 255; + data[point] = rgb; + data[point + 1] = rgb; + data[point + 2] = rgb; + data[point + 3] = 0xff; + } + } + + context.putImageData(imageData, 0, 0); + }; + + const drawPath = (x: number, y: number, width: number, height: number) => { + if (isEmpty(context)) return; + + context.beginPath(); + context.lineWidth = 0; + context.moveTo(x, y); + context.lineTo(x + width, y); + context.lineTo(x + width, y + height); + context.lineTo(x, y + height); + context.strokeStyle = 'transparent'; + context.stroke(); + context.closePath(); + }; + + const handleSelect = () => inputRef.current?.click(); + + const handleUpload: React.ChangeEventHandler = (e) => { + const { files } = e.target; + + if (isEmpty(files) || files.length === 0) return; + + reset(); + + const url = window.URL.createObjectURL(files[0]); + const image = new Image(); + image.src = url; + + image.onload = () => { + if (Math.max(image.width, image.height) > 400) { + MessageBox({ + type: 'warning', + title: '图片限制', + message: '请不要上传大于 400 x 400 的图像' + }); + } else { + const x = ((drawRef.current?.width || 0) - image.width) / 2; + const y = ((drawRef.current?.height || 0) - image.height) / 2; + + context?.drawImage(image, x, y, image.width, image.height); + + drawPath(x, y, image.width, image.height); + + setCurrentImage(image); + setRect({ left: x, top: y, width: image.width, height: image.height }); + } + + window.URL.revokeObjectURL(url); + }; + + image.onerror = () => { + MessageBox.alert({ + type: 'error', + title: '加载失败', + message: '图像加载失败,请确认图片格式' + }); + }; + + e.target.value = ''; + }; + + const handleMouseDown: React.MouseEventHandler = (e) => { + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + + if (context?.isPointInPath(x, y)) { + setIsMove(true); + } + }; + const handleMouseUp: React.MouseEventHandler = () => { + setIsMove(false); + }; + + const handleMove: React.MouseEventHandler = (e) => { + if (isEmpty(drawRef.current) || isEmpty(context) || isEmpty(currentImage)) { + return; + } + + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + + if (context.isPointInPath(x, y)) { + drawRef.current.style.cursor = 'move'; + } else { + drawRef.current.style.cursor = 'revert'; + } + + if (isMove) { + const { left, top, width, height } = rect; + + const _left = Math.min( + drawRef.current.width - width, + Math.max(0, left + e.movementX) + ); + const _top = Math.min( + drawRef.current.height - height, + Math.max(0, top + e.movementY) + ); + context.clearRect(0, 0, drawRef.current.width, drawRef.current.height); + + drawBakcground(); + + context.drawImage( + currentImage, + 0, + 0, + currentImage.width, + currentImage.height, + _left, + _top, + width, + height + ); + + drawPath(_left, _top, width, height); + + setRect({ left: _left, top: _top, width, height }); + } + }; + + const handleWheel: React.WheelEventHandler = (e) => { + if (isEmpty(drawRef.current) || isEmpty(context) || isEmpty(currentImage)) { + return; + } + + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + + if (context.isPointInPath(x, y)) { + const { left, top, width, height } = rect; + + const _width = Math.round(width * (e.deltaY < 0 ? 1.2 : 0.8)); + const _height = Math.round(height * (e.deltaY < 0 ? 1.2 : 0.8)); + + if ( + left + _width > drawRef.current.width || + top + _height > drawRef.current.height + ) { + return; + } + + context.clearRect(0, 0, drawRef.current.width, drawRef.current.height); + + drawBakcground(); + + context.drawImage( + currentImage, + 0, + 0, + currentImage.width, + currentImage.height, + left, + top, + _width, + _height + ); + + drawPath(left, top, _width, _height); + + setRect({ left, top, width: _width, height: _height }); + } + }; + + const handleNext = () => { + if ( + isEmpty(drawRef.current) || + isEmpty(context) || + isEmpty(currentImage) || + isEmpty(configurationRef.current) + ) { + return; + } + + const { context: _context } = createCanvasContext({ + width: rect.width, + height: rect.height + }); + + _context.drawImage(currentImage, 0, 0, rect.width, rect.height); + + const imageData = _context.getImageData(0, 0, rect.width, rect.height); + + const { width, height, time, scaling, background, ...options } = + configurationRef.current.getOptions(); + + setFrames((prev) => [ + ...prev, + { data: imageData, options: { index: prev.length, ...options } } + ]); + + reset(); + + width & height & time & scaling; + background; + + return { data: imageData, options: { index: frames.length, ...options } }; + }; + + const handleEncode = () => { + const last = handleNext() as ImageOptions; + + if (isEmpty(last)) return; + + setIsDisabled(true); + + const _frames = [...frames, last]; + + let maxWidth = Number.MIN_SAFE_INTEGER; + let maxHeight = Number.MIN_SAFE_INTEGER; + for (let i = 0; i < _frames.length; i++) { + const { data } = _frames[i]; + + maxWidth = Math.max(maxWidth, data.width); + maxHeight = Math.max(maxHeight, data.height); + } + + for (let i = 0; i < _frames.length; i++) { + const { + data: { width, height }, + options + } = _frames[i]; + + options.offsetLeft = Math.floor((maxWidth - width) / 2); + options.offsetTop = Math.floor((maxHeight - height) / 2); + + gif.current.addFrame(_frames[i]['data'], { width, height, ...options }); + } + + gif.current.setCycles(configurationRef.current?.getOptions().cycles || 0); + gif.current.setConfig({ + width: maxWidth, + height: maxHeight, + background: configurationRef.current?.getOptions().background + }); + + gif.current.render(); + }; + + return ( + +
+ setIsMove(false)} + onWheel={handleWheel} + > +
+ +
+ + +
+ + + + + +
+ + +
+
+ ); +} diff --git a/src/pages/GIF-Explorer/GIFPlayer.module.css b/src/pages/GIF-Explorer/GIFPlayer.module.css new file mode 100644 index 0000000..6e34c56 --- /dev/null +++ b/src/pages/GIF-Explorer/GIFPlayer.module.css @@ -0,0 +1,24 @@ +.title { + font-size: var(--font-size-extra-large); + font-weight: var(--font-weight-primary); +} + +.gif-player-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-top: 6px; + padding: 20px 10px; + min-height: 300px; + line-height: 0; + background-color: #272b30; + cursor: pointer; +} + +.operator { + display: flex; + align-items: center; + column-gap: 5px; + margin-top: 20px; +} diff --git a/src/pages/GIF-Explorer/GIFPlayer.tsx b/src/pages/GIF-Explorer/GIFPlayer.tsx new file mode 100644 index 0000000..394af9d --- /dev/null +++ b/src/pages/GIF-Explorer/GIFPlayer.tsx @@ -0,0 +1,311 @@ +import React, { useRef, useMemo, useState, useEffect } from 'react'; +import { isEmpty, isRenderElement, createCanvasContext } from '@/utils'; +import GIF from './gif/GIF'; +import Card from '@/components/Card/Card'; +import Button from '@/components/Button/Button'; +import Text from '@/components/Text/Text'; +import MessageBox from '@/components/MessageBox/MessageBox'; +import type { GIFPattern } from './types'; +import style from './GIFPlayer.module.css'; + +export default function GIFPlayer() { + const drawRef = useRef(null); + const gif = useRef(new GIF()); + const inputRef = useRef(null); + + const [pattern, setPattern] = useState(null); + const [context, setContext] = useState(null); + const [file, setFile] = useState(null); + + const [frames, setFrames] = useState(null); + const [restore, setRestore] = useState(null); + const [loop, setLoop] = useState(0); + const [views, setViews] = useState(0); + const [cursor, setCursor] = useState(0); + const [timer, setTimer] = useState(-1); + const [play, setPlay] = useState(true); + + useMemo(() => { + gif.current.decoder.on('finished', (e) => { + setPattern(e); + }); + }, []); + + useEffect(() => { + if (isEmpty(drawRef.current)) return; + + const context = drawRef.current.getContext('2d', { + willReadFrequently: true + }); + + setContext(context); + }, []); + + useEffect(() => { + if (isEmpty(file)) return; + + gif.current.decoder.decode(file); + }, [file]); + + useEffect(() => { + if (isEmpty(pattern) || isEmpty(drawRef.current) || isEmpty(context)) { + return; + } + + drawRef.current.width = pattern.logicalScreenDescriptor.width; + drawRef.current.height = pattern.logicalScreenDescriptor.height; + + fillBackground(0, 0, drawRef.current.width, drawRef.current.height); + + const { applicationExtension, frames } = pattern; + const { loop = 0 } = applicationExtension || {}; + + setFrames(frames); + setLoop(loop); + }, [pattern]); + + useEffect(() => { + if (isEmpty(frames) || isEmpty(context) || isEmpty(drawRef.current)) return; + + const current = frames[cursor]; + + if (isEmpty(current?.data)) return; + + const prev = frames[cursor === 0 ? frames.length - 1 : cursor - 1]; + + const { control } = prev; + + const disposal = control?.disposal || 0; + + switch (disposal) { + case 2: + context.clearRect( + prev.offsetLeft, + prev.offsetTop, + prev.width, + prev.height + ); + fillBackground( + prev.offsetLeft, + prev.offsetTop, + prev.width, + prev.height + ); + break; + case 3: + if (restore) { + context.putImageData(restore, prev.offsetLeft, prev.offsetTop); + } else { + fillBackground( + prev.offsetLeft, + prev.offsetTop, + prev.width, + prev.height + ); + } + break; + + default: + break; + } + + const currentDisposal = current.control?.disposal || 0; + + if (currentDisposal === 2) { + const imageData = context.getImageData( + current.offsetLeft, + current.offsetTop, + current.width, + current.height + ); + + setRestore(imageData); + } + + context.putImageData(current.data, current.offsetLeft, current.offsetTop); + + if (play === false) return; + + const timer = window.setTimeout(() => { + if (cursor === frames.length - 1) { + if (loop === 0 || views < loop - 1) { + setCursor(0); + setViews((prev) => ++prev); + } else { + window.clearTimeout(timer); + } + } else { + setCursor((prev) => ++prev); + } + }, current.control?.delay || 10); + + setTimer(timer); + + return () => window.clearTimeout(timer); + }, [cursor, frames]); + + useEffect(() => { + if (isEmpty(frames)) return; + + if (play) { + setCursor((prev) => { + if (prev === frames.length - 1) { + return 0; + } else { + return ++prev; + } + }); + + setViews(0); + } else { + window.clearTimeout(timer); + } + }, [play]); + + function fillBackground(x: number, y: number, w: number, h: number) { + if (isEmpty(pattern) || isEmpty(context)) { + return; + } + + const backgroundIndex = pattern.logicalScreenDescriptor.backgroundIndex; + + if (pattern.logicalScreenDescriptor.statistics.globalColor) { + const background = toColor(pattern?.globalColorTable?.[backgroundIndex]); + context.fillStyle = background; + context.fillRect(x, y, w, h); + } + } + + function toColor(rgb = [0xff, 0xff, 0xff]) { + const [r, g, b] = rgb; + + return `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`; + } + + const handleUpload = () => inputRef.current?.click(); + + const handleSelect: React.ChangeEventHandler = (e) => { + const { files } = e.target; + + if (isEmpty(files) || files.length === 0) return; + + if (files[0].type.includes('image/gif')) { + window.clearTimeout(timer); + + setFile(files[0]); + setPattern(null); + setFrames(null); + setRestore(null); + setLoop(0); + setViews(0); + setTimer(-1); + setPlay(true); + } else { + MessageBox.alert({ + type: 'error', + title: '图片格式错误', + message: '请上传 GIF 图片文件' + }); + } + + e.target.value = ''; + }; + + const handleFrame = (direction: 'prev' | 'next') => { + if (isEmpty(frames)) return; + + setPlay(false); + if (direction === 'prev') { + setCursor((prev) => { + return prev === 0 ? frames.length - 1 : --prev; + }); + } else if (direction === 'next') { + setCursor((prev) => { + return prev === frames.length - 1 ? 0 : ++prev; + }); + } + }; + + const download = async () => { + if (isEmpty(frames)) return; + + const { canvas, context } = createCanvasContext({ + willReadFrequently: true + }); + + if (typeof window.showDirectoryPicker === 'function') { + const directory = await window.showDirectoryPicker({ mode: 'readwrite' }); + + for (let i = 0; i < frames.length; i++) { + canvas.width = frames[i].width; + canvas.height = frames[i].height; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + context.putImageData(frames[i].data!, 0, 0); + canvas.toBlob(async (blob) => { + const fileHandle = await directory.getFileHandle( + i.toString().padStart(frames.length.toString().length, '0') + + '.jpg', + { create: true } + ); + + const writable = await fileHandle.createWritable(); + + const write = writable.getWriter(); + write.write(blob); + write.close(); + }, 'image/jpeg'); + } + } else { + MessageBox.alert({ + type: 'warning', + title: '下载失败', + message: + '当前浏览器不支持 window.showDirectoryPicker 方法,建议使用最新浏览器,或前往 MDN 查看浏览器兼容性' + }); + } + }; + + return ( + +

GIF Player

+ +
+ + + {isRenderElement(frames) && ( +
e.stopPropagation()} + > + + + + + + {`${cursor + .toString() + .padStart((frames?.length || 1).toString().length, '0')}/${ + (frames?.length || 1) - 1 + }`} +
+ )} +
+ + +
+ ); +} diff --git a/src/pages/GIF-Explorer/GIFVideo.module.css b/src/pages/GIF-Explorer/GIFVideo.module.css index 7525f8c..5fa9411 100644 --- a/src/pages/GIF-Explorer/GIFVideo.module.css +++ b/src/pages/GIF-Explorer/GIFVideo.module.css @@ -8,7 +8,6 @@ margin-bottom: 20px; min-height: 200px; padding: 5px; - width: 40%; border: var(--border); border-color: var(--color-primary); border-radius: var(--border-radius-base); @@ -22,6 +21,10 @@ align-items: center; } +.gif-video-disabled { + cursor: not-allowed; +} + .icon-upload { font-size: 50px; } @@ -31,20 +34,10 @@ cursor: pointer; } -.gif-transfer-form { - margin-left: 30px; -} - -.gif-transfer-form-item { - display: flex; - align-items: center; - column-gap: 20px; - margin-bottom: 20px; +.gif-video-disabled > .gif-video { + cursor: not-allowed; } -.gif-transfer-form-item > label { - flex-shrink: 0; - font-weight: 600; - width: 105px; - white-space: nowrap; +.gif-video-status { + margin-top: 20px; } diff --git a/src/pages/GIF-Explorer/GIFVideo.tsx b/src/pages/GIF-Explorer/GIFVideo.tsx index 6c1a2d7..4f4d8d2 100644 --- a/src/pages/GIF-Explorer/GIFVideo.tsx +++ b/src/pages/GIF-Explorer/GIFVideo.tsx @@ -1,48 +1,44 @@ import React, { useRef, useState, useEffect } from 'react'; -import { classnames, polling } from '@/utils'; -import { useModel } from '@/hooks'; +import { + classnames, + isRenderElement, + polling, + createCanvasContext +} from '@/utils'; import GIF from './gif/GIF'; import Card from '@/components/Card/Card'; import Button from '@/components/Button/Button'; import MessageBox from '@/components/MessageBox/MessageBox'; -import InputNumber from '@/components/InputNumber/InputNumber'; -import Select from '@/components/Select/Select'; -import Switch from '@/components/Switch/Switch'; +import Progress from '@/components/Progress/Progress'; import Text from '@/components/Text/Text'; -import TimePicker from './component/TimePicker'; -import type { Transparency, UserInput, DisposalMethod } from './types'; +import Configuration from './component/Configuration'; +import type { Options } from './component/Configuration'; +import type { ProgressParams } from './types'; import style from './GIFVideo.module.css'; -const instructOptions = [ - { label: '不处理', value: 0 }, - { label: '下一帧覆盖当前帧', value: 4 }, - { label: '恢复到背景颜色', value: 8 }, - { label: '恢复到渲染当前帧之前', value: 12 } -]; - const generateClass = classnames(style); export default function GIFVideo() { const inputRef = useRef(null); const videoRef = useRef(null); - const [blobObject, setBlobObject] = useState(''); - - const timeModel = useModel('00 : 00 : 05'); - - const delay = useModel(40); - - const disposalMethod = useModel(4); - - const userInputModel = useModel(0); + const configurationRef = useRef(null); - const transparencyModel = useModel(0); + const [disabled, setDisabled] = useState(false); - const transparencyIndexModel = useModel(0); + const [collect, setCollect] = useState(false); - const cyclesModel = useModel(0); + const [progress, setProgress] = useState({ + loaded: 0, + total: 0, + activeWorkers: 0, + duration: 0, + surplus: 0, + percentage: 0 + }); - const scalingModel = useModel(3); + const [blobObject, setBlobObject] = useState(''); + const [download, setDownload] = useState(null); useEffect(() => { window.document.addEventListener('dragover', stopPropagation); @@ -59,7 +55,7 @@ export default function GIFVideo() { }, []); const videoContinerClass = generateClass( - { 'gif-video-upload': !blobObject }, + { 'gif-video-upload': !blobObject, 'gif-video-disabled': disabled }, style['gif-video-container'] ); const iconClass = generateClass(['icon-upload'], 'iconfont', 'icon-upload'); @@ -69,9 +65,11 @@ export default function GIFVideo() { e.preventDefault(); }; - const handleSelect = () => inputRef.current?.click(); + const handleSelect = () => disabled || inputRef.current?.click(); const handleDrop: React.DragEventHandler = (e) => { + if (disabled) return; + const { files } = e.dataTransfer; if (files === null || files.length === 0) return; @@ -122,21 +120,32 @@ export default function GIFVideo() { videoRef.current.load(); + setDisabled(true); + setCollect(true); + setDownload(null); + polling(() => { if (videoRef.current === null || videoRef.current.videoWidth === 0) { return false; } - const time = +timeModel.value.split(':')[2].trim(); + const { + time = 5, + scaling = 3, + cycles = 0, + delay = 40, + disposalMethod = 4, + userInput = 0 + } = configurationRef.current?.getOptions() || {}; const width = videoRef.current.videoWidth; const height = videoRef.current.videoHeight; - const _canvas = window.document.createElement('canvas'); - const context = _canvas.getContext('2d', { willReadFrequently: true }); - - _canvas.width = Math.round(width / scalingModel.value); - _canvas.height = Math.round(height / scalingModel.value); + const { canvas: _canvas, context } = createCanvasContext({ + width: Math.round(width / scaling), + height: Math.round(height / scaling), + willReadFrequently: true + }); const gif = new GIF({ width: _canvas.width, @@ -144,17 +153,27 @@ export default function GIFVideo() { workers: 2 }); - gif.setCycles(cyclesModel.value); - gif.setDelay(delay.value); - gif.setDisposalMethod(disposalMethod.value); - gif.setUserInput(userInputModel.value); + gif.setCycles(cycles); + gif.setDelay(delay); + gif.setDisposalMethod(disposalMethod); + gif.setUserInput(userInput); + + gif.on('progress', (e) => { + setProgress(e); + }); + + gif.on('finished', (e) => { + setDisabled(false); + + setDownload(e); + }); const timer = window.setInterval(() => { if (videoRef.current === null) return; if (videoRef.current.currentTime > time) { + setCollect(false); gif.render(); - return window.clearInterval(timer); } @@ -178,124 +197,133 @@ export default function GIFVideo() { }); }; + const handleDownload = async () => { + if (download === null) return; + + if (typeof window.showSaveFilePicker === 'function') { + const fileHandle = await window.showSaveFilePicker({ + excludeAcceptAllOption: true, + suggestedName: 'illustrate.gif', + types: [ + { description: '图像文件 gif', accept: { 'image/gif': ['.gif'] } } + ] + }); + + const writeable = await fileHandle.createWritable(); + + const write = writeable.getWriter(); + await write.write(download); + await write.close(); + } else { + const a = window.document.createElement('a'); + const url = window.URL.createObjectURL(download); + a.href = url; + a.download = 'illustrate.gif'; + window.document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } + }; + return ( <>
-
{ - e.stopPropagation(); - e.preventDefault(); - }} - onDrop={handleDrop} - onClick={handleSelect} - > - {blobObject ? ( - - ) : ( - <> - -

- 点击或拖拽上传视频文件 -

- - )} -
- -
-
- - +
+
{ + e.stopPropagation(); + e.preventDefault(); + }} + onDrop={handleDrop} + onClick={handleSelect} + > + {blobObject ? ( + + ) : ( + <> + +

+ 点击或拖拽上传视频文件 +

+ + )}
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
+ {isRenderElement(collect) && ( + + 正在收集视频帧,稍后开始编译。。。 + + )} -
- - +
+ + 总帧数:{progress.total} + + + 已完成:{progress.loaded} + + + 活动线程: {progress.activeWorkers} + + + 已编译:{progress.duration}秒 + + + 预计剩余: {progress.surplus}秒 + + +
+ +
- +
+ -

- - 前端转换较慢,限制 gif 时长为 60 - 秒;色盘在编码时确认,无法提前选择透明索引。 - -

+ + +

+ + 前端转换较慢,限制 gif 时长为 60 + 秒;色盘在编码时确认,无法提前选择透明色(PS:显示背景色)。 + +

+
label { + flex-shrink: 0; + font-weight: 600; + width: 105px; + white-space: nowrap; +} + +.gif-transfer-form-item-background { + position: relative; + padding-left: 10px; + width: 150px; + height: 30px; + cursor: pointer; + border-radius: 4px; + border-color: var(--border-color); +} + +.gif-transfer-form-item-background:hover, +.gif-transfer-form-item-background:focus { + border-color: var(--color-primary); +} + +.gif-transfer-form-item-background:active { + border-color: var(--color-primary-light-5); +} + +.gif-transfer-form-item-background::before { + content: attr(data-color); + color: var(--color); +} + +.gif-transfer-form-item-background::-webkit-color-swatch-wrapper { + position: absolute; + right: 5px; + top: 0; + width: 30px; + height: 30px; +} + +.gif-transfer-form-item-background::-webkit-color-swatch { + border-radius: var(--border-radius-circle); + border: none; +} + +.gif-transfer-form-item-index { + position: relative; +} + +.gif-transfer-form-itme-color-table { + display: none; + position: absolute; + left: 0; + top: 110%; + padding: 10px; + width: 230px; + border: var(--border); + border-radius: var(--border-radius-base); + background-color: #fff; +} + +.gif-transfer-form-itme-inner-color-table { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + gap: 1px; + cursor: pointer; +} + +.color { + width: 10px; + height: 10px; + border: var(--border); + border-color: var(--border-color-dark); +} diff --git a/src/pages/GIF-Explorer/component/Configuration.tsx b/src/pages/GIF-Explorer/component/Configuration.tsx new file mode 100644 index 0000000..001d814 --- /dev/null +++ b/src/pages/GIF-Explorer/component/Configuration.tsx @@ -0,0 +1,333 @@ +import React, { + forwardRef, + useImperativeHandle, + useEffect, + useRef, + useMemo +} from 'react'; +import { isRenderElement } from '@/utils'; +import { useModel, useTransition } from '@/hooks'; +import InputNumber from '@/components/InputNumber/InputNumber'; +import Select from '@/components/Select/Select'; +import Switch from '@/components/Switch/Switch'; +import TimePicker from './TimePicker'; +import type { + Transparency, + UserInput, + DisposalMethod, + ColorObject, + BulidColor +} from '../types'; +import style from './Configuration.module.css'; + +interface List { + width: number; + height: number; + time: number; + delay: number; + disposalMethod: DisposalMethod; + userInput: UserInput; + transparency: Transparency; + transparencyIndex: number; + cycles: number; + background: string; + scaling: number; +} + +export interface Options { + getOptions(): List & { colorTable: BulidColor }; + setColorTable(colorTable: BulidColor): void; +} + +type ListKeys = keyof List; + +interface ConfigurationProps extends Props { + perfix?: string; + showList: ListKeys[]; +} + +const instructOptions = [ + { label: '不处理', value: 0 }, + { label: '下一帧覆盖当前帧', value: 4 }, + { label: '恢复到背景颜色', value: 8 }, + { label: '恢复到渲染当前帧之前', value: 12 } +]; + +export default forwardRef(function Configuration( + props: ConfigurationProps, + ref: React.ForwardedRef +) { + const { showList, perfix = '' } = props; + + const colorRef = useRef(null); + const tableRef = useRef(null); + + const showTable = useModel(false); + + const timeModel = useModel('00 : 00 : 05'); + + const widthModel = useModel(0); + + const heightModel = useModel(0); + + const delay = useModel(40); + + const disposalMethod = useModel(4); + + const userInputModel = useModel(0); + + const transparencyModel = useModel(0); + + const transparencyIndexModel = useModel(0); + + const cyclesModel = useModel(0); + + const backgroundModel = useModel('#000000'); + + const scalingModel = useModel(3); + + const colorTableModel = useModel({ + colorList: [], + octree: null + } as BulidColor); + + useEffect(() => { + window.addEventListener('click', cacelabled); + + return () => { + window.removeEventListener('click', cacelabled); + }; + }, []); + + useImperativeHandle( + ref, + () => { + return { getOptions, setColorTable }; + }, + [ + timeModel.value, + widthModel.value, + heightModel.value, + delay.value, + disposalMethod.value, + cyclesModel.value, + backgroundModel.value, + transparencyModel.value, + transparencyIndexModel.value, + userInputModel.value, + scalingModel.value, + colorTableModel.value + ] + ); + + const colorVar = useMemo(() => { + colorRef.current?.setAttribute('data-color', backgroundModel.value); + + return { '--color': backgroundModel.value } as React.CSSProperties; + }, [backgroundModel.value]); + + useTransition( + showTable.value, + tableRef, + { active: 'zoom-in-active' }, + { active: 'zoom-out-active' } + ); + + const cacelabled = () => showTable.change(false); + + function getOptions() { + const time = +timeModel.value.split(':')[2].trim(); + + return { + time, + width: widthModel.value, + height: heightModel.value, + scaling: scalingModel.value, + delay: delay.value, + background: backgroundModel.value, + disposalMethod: disposalMethod.value, + cycles: cyclesModel.value, + userInput: userInputModel.value, + transparency: transparencyModel.value, + transparencyIndex: transparencyIndexModel.value, + colorTable: colorTableModel.value + }; + } + + function setColorTable(colors: BulidColor) { + colorTableModel.change({ ...colors }); + } + + const parseColor = ({ r, g, b }: ColorObject) => `rgb(${r}, ${g}, ${b})`; + + const handleFocus = () => { + if (colorTableModel.value.colorList.length) { + showTable.change(true); + } + }; + + const handleSelect = (i: number) => { + transparencyIndexModel.change(i); + + showTable.change(false); + }; + + return ( +
+ {isRenderElement(showList.includes('time')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('width')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('height')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('scaling')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('delay')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('userInput')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('cycles')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('disposalMethod')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('background')) && ( +
+ + backgroundModel.change(e.target.value)} + /> +
+ )} + + {isRenderElement(showList.includes('transparency')) && ( +
+ + +
+ )} + + {isRenderElement(showList.includes('transparencyIndex')) && ( +
+ +
+ + +
+
+ {colorTableModel.value.colorList.map((color, i) => ( + { + e.stopPropagation(); + handleSelect(i); + }} + > + ))} +
+
+
+
+ )} +
+ ); +}); diff --git a/src/pages/GIF-Explorer/gif/GIF.ts b/src/pages/GIF-Explorer/gif/GIF.ts index 8306faa..85527a6 100644 --- a/src/pages/GIF-Explorer/gif/GIF.ts +++ b/src/pages/GIF-Explorer/gif/GIF.ts @@ -1,23 +1,27 @@ -import { assign } from '@/utils'; +import { assign, isEmpty } from '@/utils'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import _Worker from './encoder.worker'; import Queue from './Queue'; import GIFByte from './GIFByte'; +import GIFDecoder from './GIFDecoder'; import type { Transparency, UserInput, DisposalMethod, ImageOptions, - GIFConfig + GIFConfig, + OutputData, + BusEvent, + GIFEventOn, + ProgressCallback, + FinishedCallback } from '../types'; class GIF { private _width = 0; private _height = 0; - - private frames = new Queue(); - + private config: GIFConfig; private options = { delay: 40, cycles: 0, @@ -27,9 +31,12 @@ class GIF { transparencyIndex: 0 }; - private workers: Worker[]; + private bus = new Map(); - private config: GIFConfig; + private frames = new Queue(); + private workers: Worker[]; + private activeWorkers: Worker[]; + private response: Uint8Array[]; private _canvas = window.document.createElement('canvas'); private _context = this._canvas.getContext('2d', { @@ -38,15 +45,15 @@ class GIF { private firstFrame = true; + public decoder: GIFDecoder; + constructor(config?: GIFConfig) { this.config = config || {}; - - const { workers = 2 } = this.config; - this.workers = []; + this.activeWorkers = []; + this.response = []; - for (let i = 0; i < workers; i++) - this.workers[this.workers.length] = new _Worker(); + this.decoder = new GIFDecoder(config?.workers); } get width() { @@ -80,17 +87,42 @@ class GIF { setTransparencyIndex(num: number) { if (num < 0 || num > 255) return this; - this.options.transparencyIndex = window.parseInt(num.toString()); + this.options.transparencyIndex = Math.floor(num); } getOptions() { return { ...this.options }; } + setOptions(options: Omit) { + this.options = assign({}, this.options, options); + } + getConfig() { return { ...this.config }; } + setConfig(config: GIFConfig) { + this.config = assign( + {}, + this.config as OrdinaryObject, + config as OrdinaryObject + ); + } + + on: GIFEventOn = function (this: GIF, type, fn) { + if (this.bus.has(type)) { + const cbs = this.bus.get(type); + + if (cbs) { + cbs.push(fn); + return this.bus.set(type, cbs); + } + } + + this.bus.set(type, [fn]); + }; + addFrame( element: | HTMLImageElement @@ -120,7 +152,7 @@ class GIF { } frame.options = assign( - {}, + { index: this.frames.size }, this.options, { width: this.width, height: this.height }, options || {} @@ -129,7 +161,70 @@ class GIF { this.frames.enqueue(frame); } - getImageData( + render() { + if (this.frames.isEmpty()) throw Error('no frame, please add first'); + + const workers = this.initWorkers(); + + const total = this.frames.size; + const startTime = window.performance.now(); + + for (let i = 0; i < workers.length; i++) { + const worker = workers[i]; + const next = this.frames.dequeue(); + + if (isEmpty(next) || isEmpty(next.value)) return; + + worker.postMessage(next.value); + + this.activeWorkers.push(worker); + + worker.addEventListener('message', (e: MessageEvent) => { + const { + data: { index, data } + } = e; + + this.response[index] = data; + + const i = this.activeWorkers.indexOf(worker); + this.activeWorkers.splice(i, 1); + + const next = this.frames.dequeue(); + + if (next?.value) { + worker.postMessage(next.value); + this.activeWorkers.push(worker); + } + + const cbs = this.bus.get('progress'); + if (cbs) { + const duration = window.performance.now() - startTime; + const loaded = this.response.length; + const average = duration / loaded; + const surplus = average * (total - loaded); + + const params = { + total: total, + loaded: this.response.length, + activeWorkers: this.activeWorkers.length, + duration: +(duration / 1000).toFixed(2), + surplus: +(surplus / 1000).toFixed(2), + percentage: Math.round((loaded / total) * 100) + }; + + for (let i = 0; i < cbs.length; i++) { + (cbs[i] as ProgressCallback)(params); + } + } + + if (this.frames.isEmpty() && this.activeWorkers.length === 0) { + return this.complete(); + } + }); + } + } + + private getImageData( element: HTMLImageElement | HTMLCanvasElement | CanvasRenderingContext2D ) { let context!: CanvasRenderingContext2D | null; @@ -149,42 +244,78 @@ class GIF { return context!.getImageData(0, 0, this.width, this.height); } - render() { - const frame = this.frames.dequeue(); - - if (frame?.value === null || frame?.value === undefined) - throw Error('no frame, please add first'); - + private initGif() { const out = new GIFByte(); out.writeGifMagic(); - out.writeLogicalScreenDescriptor(this.width, this.height, 0x70); + + const globalColorTable: number[][] = []; + + if (this.config.background) { + const background = window.parseInt(this.config.background.slice(1), 16); + const bytes = [ + (background >> 16) & 0xff, + (background >> 8) & 0xff, + background & 0xff + ]; + + globalColorTable.push(bytes, [0, 0, 0], [0, 0, 0], [0, 0, 0]); + } + + let colorSize = 2; + + while (globalColorTable.length > 1 << colorSize) colorSize++; + + const result = globalColorTable.length !== 0 ? 0x80 | (colorSize - 1) : 0; + + out.writeLogicalScreenDescriptor( + this.config.width || this.width, + this.config.height || this.height, + 0x70 | result + ); + + out.writeBytes(globalColorTable.flat()); + out.writeApplicationExtension(this.options.cycles); - const worker = this.workers[0]; + return out; + } - worker.postMessage(frame.value); + private initWorkers() { + const { workers = 2 } = this.config; - worker.addEventListener('message', (e: MessageEvent) => { - const { data } = e; + const count = Math.min(this.frames.size, workers); - out.writeBytes(data); + for (let i = 0; i < count; i++) { + this.workers[this.workers.length] = new _Worker(); + } - if (this.frames.isEmpty()) { - out.end(); + return this.workers; + } - const file = new File([out.export()], 'test.gif', { - type: 'image/gif' - }); + private complete() { + const frames = this.response; - window.open(window.URL.createObjectURL(file)); - } else { - const next = this.frames.dequeue(); + const out = this.initGif(); - if (next?.value) { - worker.postMessage(next.value); - } - } - }); + for (let i = 0; i < frames.length; i++) { + out.writeBytes(frames[i]); + } + + const uint8 = out.end(); + + const cbs = this.bus.get('finished'); + + if (isEmpty(cbs)) return; + + for (let i = 0; i < cbs.length; i++) { + (cbs[i] as FinishedCallback)(new Blob([uint8], { type: 'image/gif' })); + } + + for (let i = 0; i < this.workers.length; i++) this.workers[i].terminate(); + + this.firstFrame = true; + this.workers = []; + this.response = []; } } diff --git a/src/pages/GIF-Explorer/gif/GIFByte.ts b/src/pages/GIF-Explorer/gif/GIFByte.ts index 044f63f..d6c529e 100644 --- a/src/pages/GIF-Explorer/gif/GIFByte.ts +++ b/src/pages/GIF-Explorer/gif/GIFByte.ts @@ -55,9 +55,7 @@ class GIFByte { ) { this.writeShort(width); this.writeShort(height); - this.writeByte(info); - this.writeByte(backgroundIndex); - this.writeByte(pixelAspectRatio); + this.writeBytes([info, backgroundIndex, pixelAspectRatio]); } writeApplicationExtension(Cycles = 0) { @@ -109,10 +107,6 @@ class GIFByte { this.writeByte(info); } - end() { - this.writeBytes(END_GIF); - } - export() { let size = 0; @@ -144,6 +138,12 @@ class GIFByte { return uint8; } + + end() { + this.writeBytes(END_GIF); + + return this.export(); + } } export default GIFByte; diff --git a/src/pages/GIF-Explorer/gif/GIFDecoder.ts b/src/pages/GIF-Explorer/gif/GIFDecoder.ts new file mode 100644 index 0000000..f870c42 --- /dev/null +++ b/src/pages/GIF-Explorer/gif/GIFDecoder.ts @@ -0,0 +1,489 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import _Worker from './decoder.worker'; +import { isEmpty } from '@/utils'; +import { + GIF_MAGIC_NUMBER, + APPLICATION_EXTENSION, + GRAPHIC_CONTROL_EXTENSION, + PLAIN_TEXT_EXTENSION, + COMMENT_EXTENSION, + IMAGE_DESCRIPTOR, + END_GIF +} from './config'; +import { + isGIF, + isApplicationExtension, + isGraphicControlExtension, + isPlainTextExtension, + isCommentExtension, + isImageDescriptor, + isEnd +} from './utils'; +import type { GIFPattern } from '../types'; + +class GIFDecoder { + cursor = 0; + + private pattern!: GIFPattern; + private mark: 'never' | 'graphic' | 'end' = 'never'; + private index = 0; + + private bus = new Map<'finished', ((e: GIFPattern) => void)[]>(); + private maxWorkers: number; + private workers: Worker[] = []; + private activeWorkers: Worker[] = []; + + private isRunning = false; + + constructor(maxWorkers = 2) { + this.maxWorkers = maxWorkers; + } + + private async getBytes(binary: Blob) { + return new Uint8Array(await binary.arrayBuffer()); + } + + private toText(binary: Uint8Array) { + return new TextDecoder().decode(binary); + } + + private initWorkers() { + for (let i = 0; i < this.maxWorkers; i++) { + this.workers[this.workers.length] = new _Worker(); + } + + return this.workers; + } + + private parseShort(bytes: Uint8Array) { + const lowBit = bytes[this.cursor++]; + const highBit = bytes[this.cursor++]; + + return highBit * 256 + lowBit; + } + + private parseSubBlocks(bytes: Uint8Array) { + const blocks: Uint8Array[] = []; + + let len = bytes[this.cursor++]; + + while (len !== 0) { + blocks.push(bytes.slice(this.cursor, (this.cursor += len))); + + len = bytes[this.cursor++]; + } + + return blocks; + } + + private parse(bytes: Uint8Array) { + this.parseHeader(bytes); + this.parseLogicalScreenDescriptor(bytes); + + while (this.cursor < bytes.length) { + switch (true) { + case bytes[this.cursor] === 0x21: + this.parseExtension(bytes); + break; + + case isImageDescriptor(bytes, this.cursor): + this.cursor += IMAGE_DESCRIPTOR.length; + + this.parseImage(bytes); + break; + + case isEnd(bytes, this.cursor): + this.cursor += END_GIF.length; + + this.mark = 'end'; + + break; + + default: + if (this.mark !== 'end') { + throw new Error( + `unknown byte ${bytes[this.cursor]} in ${this.cursor}` + ); + } else { + this.cursor++; + } + } + + if (this.mark === 'end') break; + } + } + + private parseHeader(bytes: Uint8Array) { + if (isGIF(bytes)) { + this.pattern.header = this.toText( + bytes.slice(0, (this.cursor += GIF_MAGIC_NUMBER.length)) + ); + + return; + } + + throw Error('it must be a gif file and the version is 89a'); + } + + private parseLogicalScreenDescriptor(bytes: Uint8Array) { + const width = this.parseShort(bytes); + const height = this.parseShort(bytes); + + const byte = bytes[this.cursor++]; + + const globalColor = !!(byte & 0x80); + const colorResolution = (byte & 0x70) >> 4; + const sort = !!(byte & 0x8); + const globalColorSize = byte & 0x7; + + const backgroundIndex = bytes[this.cursor++]; + const pixelAspectRatio = bytes[this.cursor++]; + + this.pattern.logicalScreenDescriptor = { + width, + height, + statistics: { globalColor, colorResolution, sort, globalColorSize }, + backgroundIndex, + pixelAspectRatio + }; + + if (globalColor) { + this.pattern.globalColorTable = this.parseColorTable( + bytes, + globalColorSize + ); + } + } + + private parseExtension(bytes: Uint8Array) { + switch (true) { + case isApplicationExtension(bytes, this.cursor): + if (this.mark !== 'never') { + console.warn( + `analysis of the analytical step -by -step, it may be an irregular GIF file. block in ${this.cursor}` + ); + } + + this.parseApplicationExtension(bytes); + break; + + case isGraphicControlExtension(bytes, this.cursor): + if (this.mark !== 'never') { + console.warn( + `is there a consecutive image control block? is this intentional? block in ${this.cursor}` + ); + } + + this.mark = 'graphic'; + + this.parseGraphicControlExtension(bytes); + break; + + case isPlainTextExtension(bytes, this.cursor): + this.parsePlainTextExtension(bytes); + break; + + case isCommentExtension(bytes, this.cursor): + if (this.mark !== 'never') { + console.warn( + `analysis of the analytical step -by -step, it may be an irregular GIF file. block in ${this.cursor}` + ); + } + + this.parseCommentExtension(bytes); + break; + + default: + throw Error( + `invalid extension ${bytes + .slice(this.cursor, this.cursor + 2) + .join(', ')} in ${this.cursor}` + ); + } + } + + private parseColorTable(bytes: Uint8Array, colorSize: number) { + const len = 1 << (colorSize + 1); + + const table: number[][] = []; + + for (let i = 0; i < len; i++) { + table.push([ + bytes[this.cursor++], + bytes[this.cursor++], + bytes[this.cursor++] + ]); + } + + return table; + } + + private parseApplicationExtension(bytes: Uint8Array) { + this.cursor += APPLICATION_EXTENSION.length; + + const len = bytes[this.cursor++]; + const application = this.toText( + bytes.slice(this.cursor, (this.cursor += len)) + ); + + this.cursor++; + const id = bytes[this.cursor++]; + const loop = this.parseShort(bytes); + + this.pattern.applicationExtension = { + id, + application, + loop + }; + + if (bytes[this.cursor++] !== 0) { + throw Error( + `unknown byte, it should be the ending character 0x00, but encountered ${ + bytes[this.cursor] + }` + ); + } + + console.log('exec parseApplicationExtension'); + } + + private parseGraphicControlExtension(bytes: Uint8Array) { + this.cursor += GRAPHIC_CONTROL_EXTENSION.length; + + this.cursor++; + + const byte = bytes[this.cursor++]; + + const disposal = (byte & 0x1c) >> 2; + const userInput = !!(byte & 0x02); + const transparency = !!(byte & 0x01); + const delay = this.parseShort(bytes) * 10; + const transparencyIndex = bytes[this.cursor++]; + + this.pattern.graphicControlExtension = { + disposal, + userInput, + transparency, + delay, + transparencyIndex + }; + + if (bytes[this.cursor++] !== 0) { + throw Error( + `unknown byte, it should be the ending character 0x00, but encountered ${ + bytes[this.cursor] + }` + ); + } + } + + private parsePlainTextExtension(bytes: Uint8Array) { + this.cursor += PLAIN_TEXT_EXTENSION.length; + this.cursor++; + + const offsetLeft = this.parseShort(bytes); + const offsetTop = this.parseShort(bytes); + const gridWidth = this.parseShort(bytes); + const gridHeight = this.parseShort(bytes); + const charWidth = bytes[this.cursor++]; + const charHeight = bytes[this.cursor++]; + const textColorIndex = bytes[this.cursor++]; + const textBackgroundIndex = bytes[this.cursor++]; + + const blocks = this.parseSubBlocks(bytes); + + let text = ''; + + for (let i = 0; i < blocks.length; i++) { + for (let j = 0; j < blocks[i].length; j++) { + text += String.fromCharCode(blocks[i][j]); + } + } + + if (isEmpty(this.pattern.plainTextExtension)) { + this.pattern.plainTextExtension = []; + } + + this.pattern.plainTextExtension.push({ + index: this.index++, + offsetLeft, + offsetTop, + gridWidth, + gridHeight, + charWidth, + charHeight, + textColorIndex, + textBackgroundIndex, + text, + control: + this.mark === 'graphic' + ? this.pattern.graphicControlExtension + : undefined + }); + + this.mark = 'never'; + } + + private parseCommentExtension(bytes: Uint8Array) { + this.cursor += COMMENT_EXTENSION.length; + + const blocks = this.parseSubBlocks(bytes); + + let comments = ''; + + for (let i = 0; i < blocks.length; i++) { + for (let j = 0; j < blocks[i].length; j++) { + comments += String.fromCharCode(blocks[i][j]); + } + } + + if (isEmpty(this.pattern.commentExtension)) { + this.pattern.commentExtension = []; + } + + this.pattern.commentExtension.push({ index: this.index++, comments }); + } + + private parseImage(bytes: Uint8Array) { + const offsetLeft = this.parseShort(bytes); + const offsetTop = this.parseShort(bytes); + const width = this.parseShort(bytes); + const height = this.parseShort(bytes); + + const byte = bytes[this.cursor++]; + + const localColor = !!(byte & 0x80); + const interlace = !!(byte & 0x40); + const sort = !!(byte & 0x20); + const localColorSize = byte & 0x07; + + let localColorTable: number[][] | undefined; + + if (localColor) { + localColorTable = this.parseColorTable(bytes, localColorSize); + } + + const lzwMiniCodeSize = bytes[this.cursor++]; + + const blocks = this.parseSubBlocks(bytes); + + if (isEmpty(this.pattern.frames)) { + this.pattern.frames = []; + } + + this.pattern.frames.push({ + index: this.index++, + offsetLeft, + offsetTop, + width, + height, + statistics: { + localColor, + interlace, + sort, + localColorSize + }, + localColorTable, + lzwMiniCodeSize, + blocks, + control: + this.mark === 'graphic' + ? this.pattern.graphicControlExtension + : undefined + }); + + this.mark = 'never'; + } + + private unzip() { + const workers = this.initWorkers(); + + const globalColorTable = this.pattern.globalColorTable; + const globalColor = + this.pattern.logicalScreenDescriptor.statistics.globalColor; + const frames = this.pattern.frames.slice(0); + + this.pattern.frames = []; + + for (let i = 0; i < workers.length; i++) { + const message = frames.shift(); + + if (isEmpty(message)) break; + + workers[i].postMessage({ ...message, globalColorTable, globalColor }); + + this.activeWorkers.push(workers[i]); + + workers[i].addEventListener( + 'message', + ({ data }: MessageEvent) => { + this.pattern.frames.push(data); + + const index = this.activeWorkers.indexOf(workers[i]); + this.activeWorkers.splice(index, 1); + + const message = frames.shift(); + + if (message) { + workers[i].postMessage({ + ...message, + globalColorTable, + globalColor + }); + + this.activeWorkers[this.activeWorkers.length] = workers[i]; + } else if (this.activeWorkers.length === 0) { + this.isRunning = false; + this.cursor = 0; + this.mark = 'never'; + this.index = 0; + + for (let i = 0; i < this.workers.length; i++) { + this.workers[i].terminate(); + } + this.workers = []; + + this.complete(); + } + } + ); + } + } + + private complete() { + this.pattern.frames.sort((a, b) => a.index - b.index); + + const events = this.bus.get('finished') || []; + + for (let i = 0; i < events.length; i++) { + events[i](this.pattern); + } + + this.pattern = {} as GIFPattern; + } + + on(type: 'finished', fn: (e: GIFPattern) => void) { + if (this.bus.has(type)) { + const events = this.bus.get(type); + events?.push(fn); + this.bus.set(type, events || []); + } else { + this.bus.set(type, [fn]); + } + } + + async decode(binary: Blob) { + if (this.isRunning) return false; + + this.isRunning = true; + + const bytes = await this.getBytes(binary); + + this.pattern = {} as GIFPattern; + this.parse(bytes); + this.unzip(); + } +} + +export default GIFDecoder; diff --git a/src/pages/GIF-Explorer/gif/Octree.ts b/src/pages/GIF-Explorer/gif/Octree.ts index c3ff9f8..c9e7621 100644 --- a/src/pages/GIF-Explorer/gif/Octree.ts +++ b/src/pages/GIF-Explorer/gif/Octree.ts @@ -75,7 +75,7 @@ class Octree { } shrink(maxCount: number) { - if (this.count <= maxCount) return; + if (this.count <= maxCount) return []; for (let depth = this.maxDepth - 1; depth >= 0; depth--) { this.reduceColor(this.root, depth, 0, maxCount); @@ -161,19 +161,6 @@ class Octree { node = node.children[index]; } - if (node.color?.normalize) { - return node.color; - } - - if (node.color) { - const { r, g, b } = node.color; - - node.color.r = Math.round(r / node.pixelCount) || 0; - node.color.g = Math.round(g / node.pixelCount) || 0; - node.color.b = Math.round(b / node.pixelCount) || 0; - node.color.normalize = true; - } - return node.color; } @@ -188,6 +175,15 @@ class Octree { const { color } = children[i]; if (color) { + if (!color.normalize) { + const { r, g, b } = color; + + color.r = Math.round(r / children[i].pixelCount) || 0; + color.g = Math.round(g / children[i].pixelCount) || 0; + color.b = Math.round(b / children[i].pixelCount) || 0; + color.normalize = true; + } + colors.push(color); } diff --git a/src/pages/GIF-Explorer/gif/bulidColor.ts b/src/pages/GIF-Explorer/gif/bulidColor.ts new file mode 100644 index 0000000..9988d96 --- /dev/null +++ b/src/pages/GIF-Explorer/gif/bulidColor.ts @@ -0,0 +1,20 @@ +import Octree from './Octree'; + +function bulidColor(pixels: Uint8ClampedArray) { + const octree = new Octree(); + + // 八叉树颜色量化 + for (let i = 0; i < pixels.length; i += 4) { + octree.insertColor({ r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] }); + } + + // 减色至 256 色 + octree.shrink(256); + + // 获取颜色列表 + const colorList = octree.collectColor(); + + return { colorList, octree }; +} + +export default bulidColor; diff --git a/src/pages/GIF-Explorer/gif/config.ts b/src/pages/GIF-Explorer/gif/config.ts index a7de17a..6ad5c1f 100644 --- a/src/pages/GIF-Explorer/gif/config.ts +++ b/src/pages/GIF-Explorer/gif/config.ts @@ -4,7 +4,7 @@ export const APPLICATION_EXTENSION = [0x21, 0xff]; export const PLAIN_TEXT_EXTENSION = [0x21, 0x01]; -export const COMMENT_EXTENSION = [0x21, 0xf1]; +export const COMMENT_EXTENSION = [0x21, 0xfe]; export const GRAPHIC_CONTROL_EXTENSION = [0x21, 0xf9]; @@ -17,3 +17,8 @@ export const END_GIF = [0x3b]; export const MAX_LENGTH = 4096; export const MAX_CODE_SIZE = 12; + +export const INTERCEPT_BIT = [ + 0x00, 0x01, 0x03, 0x07, 0x0f, 0x1f, 0x3f, 0x7f, 0xff, 0x01ff, 0x03ff, 0x07ff, + 0x0fff +]; diff --git a/src/pages/GIF-Explorer/gif/decoder.worker.ts b/src/pages/GIF-Explorer/gif/decoder.worker.ts new file mode 100644 index 0000000..df1e61e --- /dev/null +++ b/src/pages/GIF-Explorer/gif/decoder.worker.ts @@ -0,0 +1,192 @@ +import { isEmpty } from '@/utils'; +import { INTERCEPT_BIT, MAX_CODE_SIZE } from './config'; +import type { GIFPattern } from '../types'; + +type Frames = GIFPattern['frames'][number]; + +type Message = Frames & { globalColor: boolean; globalColorTable?: number[][] }; + +const _self = self as unknown as Worker; + +_self.addEventListener('message', ({ data }: MessageEvent) => { + // 获取必要数据 + const { + width, + height, + globalColor, + globalColorTable, + statistics: { localColor }, + localColorTable, + lzwMiniCodeSize, + blocks, + control + } = data; + + const { transparency = false, transparencyIndex = 0 } = control || {}; + + if ((globalColor || localColor) === false) { + throw Error( + "can't find the corresponding color disk, please confirm whether there are valid data" + ); + } + + function getByte() { + if (cursor === block?.length) { + block = blocks.shift(); + cursor = 0; + } + + return block?.[cursor++]; + } + + function getCode() { + while (offset < codeSize) { + const byte = getByte(); + + if (byte === undefined) { + const result = bit & INTERCEPT_BIT[codeSize]; + + bit >>>= codeSize; + offset = 0; + + return result; + } + + bit = (byte << offset) | bit; + offset += 8; + } + + const result = bit & INTERCEPT_BIT[codeSize]; + + bit >>>= codeSize; + offset -= codeSize; + + return result; + } + + const clearCode = 1 << lzwMiniCodeSize; + const eoiCode = clearCode + 1; + const trie = new Map(); + const indexStream: number[] = []; + + let codeSize = lzwMiniCodeSize + 1; + + let code = -1; + let $code: number[] = []; + let prevCode = -1; + let perfix: number[] = []; + let k = -1; + let block = blocks.shift(); + let offset = 0; + let bit = 0; + let cursor = 0; + + let isFinish = false; + + code = getCode(); + if (code !== clearCode) { + throw new Error( + 'invalid data, the first code in the code stream should be Clear Code' + ); + } + + for (let i = 0; i <= eoiCode; i++) trie.set(i, [i]); + + code = getCode(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + $code = trie.get(code)!; + indexStream.push(...$code); + prevCode = code; + + while (block) { + if (trie.size - 1 === (1 << codeSize) - 1 && codeSize < MAX_CODE_SIZE) { + codeSize++; + } + + code = getCode(); + + switch (true) { + case code === clearCode: + trie.clear(); + for (let i = 0; i <= eoiCode; i++) trie.set(i, [i]); + codeSize = lzwMiniCodeSize + 1; + + code = getCode(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + $code = trie.get(code)!; + indexStream.push(...$code); + prevCode = code; + break; + + case code === eoiCode: + isFinish = true; + break; + + default: + if (trie.has(code)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + $code = trie.get(code)!; + indexStream.push(...$code); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + perfix = trie.get(prevCode)!; + k = $code[0]; + + perfix = perfix.concat(k); + + trie.set(trie.size, perfix); + prevCode = code; + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + perfix = trie.get(prevCode)!; + k = perfix[0]; + perfix = perfix.concat(k); + indexStream.push(...perfix); + trie.set(trie.size, perfix); + prevCode = code; + } + + break; + } + + if (isFinish) break; + } + + let table: number[][] = []; + if (localColor && localColorTable) { + table = localColorTable; + } else if (globalColor && globalColorTable) { + table = globalColorTable; + } + + const transparent = transparency ? 0x00 : 0xff; + const imageData = new ImageData(width, height); + + for (let i = 0; i < indexStream.length; i++) { + const index = indexStream[i]; + const color = table[index]; + + if (isEmpty(color)) { + throw new Error( + `can't find the corresponding color, please confirm that the color disc index ${index} exists` + ); + } + + const j = i * 4; + + imageData.data[j] = color[0]; + imageData.data[j + 1] = color[1]; + imageData.data[j + 2] = color[2]; + imageData.data[j + 3] = + index === transparencyIndex ? 0xff & transparent : 0xff; + } + + const { globalColor: _, globalColorTable: __, ...result } = data; + + _; + __; + + result.data = imageData; + + _self.postMessage(result); +}); diff --git a/src/pages/GIF-Explorer/gif/encoder.worker.ts b/src/pages/GIF-Explorer/gif/encoder.worker.ts index c17fb5a..f83dbe9 100644 --- a/src/pages/GIF-Explorer/gif/encoder.worker.ts +++ b/src/pages/GIF-Explorer/gif/encoder.worker.ts @@ -1,7 +1,7 @@ import { MAX_CODE_SIZE } from './config'; -import Octree from './Octree'; import Trie from './Trie'; import GIFByte from './GIFByte'; +import bulidColor from './bulidColor'; import type { ImageOptions, ColorObject } from '../types'; const _self = self as unknown as Worker; @@ -28,20 +28,17 @@ function encoder(e: MessageEvent) { const map = new Map(); const input: number[] = []; - const octree = new Octree(); - + // const octree = new Octree(); // console.log('start', self.performance.now()); - // 八叉树颜色量化 - for (let i = 0; i < pixels.length; i += 4) { - octree.insertColor({ r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] }); - } - + // for (let i = 0; i < pixels.length; i += 4) { + // octree.insertColor({ r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] }); + // } // 减色至 256 色 - octree.shrink(256); + // octree.shrink(256); // 获取颜色列表 - const colorList = octree.collectColor(); + const { colorList, octree } = bulidColor(pixels); // 颜色转换为调色盘中颜色的索引 for (let i = 0; i < pixels.length; i += 4) { @@ -84,7 +81,11 @@ function encoder(e: MessageEvent) { while (localColorTable.length > 1 << lzwMiniCodeSize) lzwMiniCodeSize++; // 填充色盘, 防止色盘大小错误 - for (let i = 0; i < (1 << lzwMiniCodeSize) - localColorTable.length; i++) { + for ( + let i = 0, len = localColorTable.length; + i < (1 << lzwMiniCodeSize) - len; + i++ + ) { localColorTable.push([0, 0, 0]); } @@ -237,11 +238,9 @@ function encoder(e: MessageEvent) { const image = out.export(); - _self.postMessage(image); + _self.postMessage({ index: options.index, data: image }); // console.log('end', self.performance.now()); } _self.addEventListener('message', encoder); - -export {}; diff --git a/src/pages/GIF-Explorer/gif/utils.ts b/src/pages/GIF-Explorer/gif/utils.ts new file mode 100644 index 0000000..acadc4e --- /dev/null +++ b/src/pages/GIF-Explorer/gif/utils.ts @@ -0,0 +1,28 @@ +import { + GIF_MAGIC_NUMBER, + APPLICATION_EXTENSION, + GRAPHIC_CONTROL_EXTENSION, + PLAIN_TEXT_EXTENSION, + COMMENT_EXTENSION, + IMAGE_DESCRIPTOR, + END_GIF +} from './config'; + +export const isTypeOf = + (nums: number[]) => + (bytes: Uint8Array, cursor = 0) => + nums.every((byte, i) => byte === bytes[i + cursor]); + +export const isGIF = isTypeOf(GIF_MAGIC_NUMBER); + +export const isApplicationExtension = isTypeOf(APPLICATION_EXTENSION); + +export const isGraphicControlExtension = isTypeOf(GRAPHIC_CONTROL_EXTENSION); + +export const isPlainTextExtension = isTypeOf(PLAIN_TEXT_EXTENSION); + +export const isCommentExtension = isTypeOf(COMMENT_EXTENSION); + +export const isImageDescriptor = isTypeOf(IMAGE_DESCRIPTOR); + +export const isEnd = isTypeOf(END_GIF); diff --git a/src/pages/GIF-Explorer/types.d.ts b/src/pages/GIF-Explorer/types.d.ts index 9175ace..07e7ff9 100644 --- a/src/pages/GIF-Explorer/types.d.ts +++ b/src/pages/GIF-Explorer/types.d.ts @@ -7,6 +7,7 @@ export type Transparency = 0 | 1; export interface ImageOptions { data: ImageData; options: { + index: number; width?: number; height?: number; offsetLeft?: number; @@ -16,6 +17,7 @@ export interface ImageOptions { disposalMethod?: DisposalMethod; transparency?: Transparency; transparencyIndex?: number; + colorTable?: BulidColor; }; } @@ -23,6 +25,7 @@ export interface GIFConfig { width?: number; height?: number; workers?: number; + background?: string; } export interface InputData extends Pick { @@ -34,7 +37,7 @@ export interface InputData extends Pick { export interface OutputData { index: number; - data: number[]; + data: Uint8Array; } export type Color = 'color'; @@ -53,3 +56,97 @@ export type ColorObject = { b: number; normalize?: boolean; }; + +export interface ProgressParams { + loaded: number; + total: number; + activeWorkers: number; + duration: number; + surplus: number; + percentage: number; +} + +export interface BulidColor { + colorList: ColorObject[]; + octree: Octree; +} + +export type Progress = 'progress'; + +export type Finished = 'finished'; + +export type BusEvent = Progress | Finished; + +export type FinishedCallback = (blob: Blob) => void; + +export type ProgressCallback = (event: ProgressParams) => void; + +export type GIFEventOn = ( + type: T, + fn: T extends Progress ? ProgressCallback : FinishedCallback +) => void; + +export interface GIFPattern { + header: string; + logicalScreenDescriptor: { + width: number; + height: number; + statistics: { + globalColor: boolean; + colorResolution: number; + sort: boolean; + globalColorSize: number; + }; + backgroundIndex: number; + pixelAspectRatio: number; + }; + frames: { + index: number; + data?: ImageData; + offsetLeft: number; + offsetTop: number; + width: number; + height: number; + statistics: { + localColor: boolean; + interlace: boolean; + sort: boolean; + localColorSize: number; + }; + lzwMiniCodeSize: number; + blocks: Uint8Array[]; + localColorTable?: number[][]; + control?: GIFPattern['graphicControlExtension']; + }[]; + + globalColorTable?: number[][]; + applicationExtension?: { + id: number; + application: string; + loop: number; + }; + graphicControlExtension?: { + disposal: number; + userInput: boolean; + transparency: boolean; + delay: number; + transparencyIndex: number; + }; + plainTextExtension?: { + index: number; + offsetLeft: number; + offsetTop: number; + gridWidth: number; + gridHeight: number; + charWidth: number; + charHeight: number; + textColorIndex: number; + textBackgroundIndex: number; + text: string; + control?: GIFPattern['graphicControlExtension']; + }[]; + commentExtension?: { + index: number; + comments: string; + }[]; +} diff --git a/src/pages/PdfParser/lib/PDFParser.ts b/src/pages/PdfParser/lib/PDFParser.ts index a1bfb2b..4dab888 100644 --- a/src/pages/PdfParser/lib/PDFParser.ts +++ b/src/pages/PdfParser/lib/PDFParser.ts @@ -1,7 +1,7 @@ /* eslint-disable no-case-declarations */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import pako from 'pako'; -import { isNumber, isArray, isUndef } from '@/utils'; +import { isNumber, isArray, isUndef, createCanvasContext } from '@/utils'; import { Flag, Feature, @@ -16,7 +16,6 @@ import { getBufferView, toText, isTypeOf, - createContext, /* 标识函数 */ isPdf, isQuote, @@ -1110,7 +1109,10 @@ export class Draw { const mediaBox = page.MediaBox || rootPage.MediaBox || [0, 0, 0, 0]; - const { canvas, context } = createContext(mediaBox[2], mediaBox[3]); + const { canvas, context } = createCanvasContext({ + width: mediaBox[2], + height: mediaBox[3] + }); try { this.drawPage(page, context, canvas); diff --git a/src/pages/PdfParser/lib/utils.ts b/src/pages/PdfParser/lib/utils.ts index 67cd2f6..4ef6c01 100644 --- a/src/pages/PdfParser/lib/utils.ts +++ b/src/pages/PdfParser/lib/utils.ts @@ -1,18 +1,5 @@ import { Flag, Feature, InvisibleChar, Graphic } from './enum'; -/** 创建 canvas */ -export const createContext = (w: number, h: number) => { - const canvas = document.createElement('canvas'); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const context = canvas.getContext('2d')!; - - canvas.width = w; - canvas.height = h; - - return { canvas, context }; -}; - /** point to px */ export const getCurrentPixel = (pt: number, scale = 1) => ((pt * (window.devicePixelRatio * 96)) / 72) * scale; diff --git a/src/pages/UploadFile/UploadFile.tsx b/src/pages/UploadFile/UploadFile.tsx index 5ec490f..3e284d5 100644 --- a/src/pages/UploadFile/UploadFile.tsx +++ b/src/pages/UploadFile/UploadFile.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useModel } from '@/hooks'; -// import { classnames } from '@/utils'; import Upload from '@/components/Upload/Upload'; import Button from '@/components/Button/Button'; import Text from '@/components/Text/Text'; @@ -71,7 +70,6 @@ export default function UploadFile() { >

- {' '} Drop file here or{' '} click to upload diff --git a/src/pages/VisualEdit/Button.module.css b/src/pages/VisualEdit/Button.module.css deleted file mode 100644 index a3a6948..0000000 --- a/src/pages/VisualEdit/Button.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.button { - padding: 5px 10px; - background-color: hsla(240deg 80% 70% / 70%); - font-size: 0.8em; - outline: 0; - border: 0; - border-radius: 3px; - color: #fff; - cursor: pointer; -} - -.button:hover { - background-color: hsla(240deg 80% 70% / 50%); -} - -.button:active { - background-color: hsla(240deg 80% 50% / 70%); -} diff --git a/src/pages/VisualEdit/Button.tsx b/src/pages/VisualEdit/Button.tsx deleted file mode 100644 index 7828773..0000000 --- a/src/pages/VisualEdit/Button.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import style from './Button.module.css'; - -interface ButtonProps extends ButtonChildProps { - dragstart: React.DragEventHandler; -} - -export default function Button({ children, dragstart }: Readonly) { - return ( - - ); -} diff --git a/src/pages/VisualEdit/Input.module.css b/src/pages/VisualEdit/Input.module.css deleted file mode 100644 index f2c8cc1..0000000 --- a/src/pages/VisualEdit/Input.module.css +++ /dev/null @@ -1,10 +0,0 @@ -.input { - width: var(--stretch); - height: 30px; - outline: 0; - border: 1px solid var(--border-color); -} - -.input:focus { - border-color: hsla(240deg 80% 70% / 70%); -} diff --git a/src/pages/VisualEdit/Input.tsx b/src/pages/VisualEdit/Input.tsx deleted file mode 100644 index 4eeb410..0000000 --- a/src/pages/VisualEdit/Input.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import style from './Input.module.css'; - -interface _InputProps extends InputProps { - dragstart: React.DragEventHandler; -} - -export default function Input({ dragstart }: Readonly<_InputProps>) { - return ( - - ); -} diff --git a/src/pages/VisualEdit/VisualEdit.module.css b/src/pages/VisualEdit/VisualEdit.module.css index 747e2bf..6eb957b 100644 --- a/src/pages/VisualEdit/VisualEdit.module.css +++ b/src/pages/VisualEdit/VisualEdit.module.css @@ -1,7 +1,7 @@ .visual-edit { display: flex; padding: 10px; - height: inherit; + height: calc(100vh - 10px); } .visual, diff --git a/src/pages/VisualEdit/VisualEdit.tsx b/src/pages/VisualEdit/VisualEdit.tsx index 5e000ff..99648dd 100644 --- a/src/pages/VisualEdit/VisualEdit.tsx +++ b/src/pages/VisualEdit/VisualEdit.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef } from 'react'; -import Button from './Button'; +import Button from '@/components/Button/Button'; import Wrapper from './Wrapper'; -import Input from './Input'; +import Input from '@/components/Input/Input'; import style from './VisualEdit.module.css'; // --title: 可视化编辑-- @@ -163,11 +163,17 @@ export default function VisualEdit() {

输入框 - + { + /* */ + }} + onDragStart={dragstart} + >
按钮 - +
)} diff --git a/src/styles/animation.css b/src/styles/animation.css new file mode 100644 index 0000000..f7a16f5 --- /dev/null +++ b/src/styles/animation.css @@ -0,0 +1,35 @@ +.zoom-in-active { + animation: zoom-in var(--transition-duration) + var(--transition-function-fast-bezier); + transform-origin: center top; +} + +.zoom-out-active { + animation: zoom-out var(--transition-duration) + var(--transition-function-fast-bezier); + transform-origin: center top; +} + +@keyframes zoom-in { + 0% { + opacity: 0; + transform: scaleY(0); + } + + 100% { + opacity: 1; + transform: scaleY(1); + } +} + +@keyframes zoom-out { + 0% { + opacity: 1; + transform: scaleY(1); + } + + 100% { + opacity: 0; + transform: scaleY(0); + } +} diff --git a/src/types/utils.d.ts b/src/types/utils.d.ts index b09c60f..2458500 100644 --- a/src/types/utils.d.ts +++ b/src/types/utils.d.ts @@ -21,3 +21,18 @@ declare type Polling = ( declare type OrdinaryObject = { [key: string | symbol]: unknown; }; + +declare interface CreateCanvasContextOptions { + width?: number; + height?: number; + willReadFrequently?: boolean; +} + +declare interface CreateCanvasContextResult { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; +} + +declare type CreateCanvasContext = ( + options?: CreateCanvasContextOptions +) => CreateCanvasContextResult; diff --git a/src/utils/index.ts b/src/utils/index.ts index 89d1bf8..9a1a42e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -72,7 +72,11 @@ export const isFun = function unknown>( /** * @description data is not undefined and null */ -export const hasData = (data: unknown) => data != null; +export const hasData = (data: unknown) => data !== null || data !== undefined; + +export function isEmpty(data: unknown): data is undefined | null { + return data === null || data === undefined; +} /** * @description data is basic type @@ -149,7 +153,10 @@ export const checkCharacter = (reg: RegExp) => (s: string) => reg.test(s); export const isRenderElement = (condition: unknown) => condition ? 'render' : undefined; -export const assign = (obj: OrdinaryObject, ...args: OrdinaryObject[]) => { +export const assign = ( + obj: OrdinaryObject, + ...args: OrdinaryObject[] +): T => { for (let i = 0; i < args.length; i++) { const curr = args[i]; const _names = Object.getOwnPropertyNames(args[i]); @@ -178,7 +185,30 @@ export const assign = (obj: OrdinaryObject, ...args: OrdinaryObject[]) => { } } - return obj; + return obj as T; +}; + +/** + * + * @param width canvas 宽 + * @param height canvas 高 + * @returns canvas 及对应上下文 + */ +export const createCanvasContext: CreateCanvasContext = (options) => { + const _canvas = window.document.createElement('canvas'); + const context = _canvas.getContext('2d', { + willReadFrequently: options?.willReadFrequently + }); + + if (options?.width) { + _canvas.width = options.width; + } + if (options?.height) { + _canvas.height = options.height; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return { canvas: _canvas, context: context! }; }; export * from './http';