From 50895a7b8cf71b5ae09ad1503d005561ca2395b2 Mon Sep 17 00:00:00 2001 From: takagi Date: Mon, 18 May 2026 19:29:01 +0800 Subject: [PATCH 1/3] feat: add drag-to-move support for widget, toggle button and chat window - Add DraggableMixin for reusable drag functionality - Position info saved to localStorage (client-side) - Support dragging: widget, toggle button, chat window - Skip interactive elements (input, button, canvas, tools) - Add visual feedback during drag (cursor, overlay) - Clamp position within viewport bounds Closes #47 --- .../src/components/Live2dChatWindow.tsx | 13 +- .../live2d/src/components/Live2dToggle.tsx | 9 +- .../live2d/src/components/Live2dWidget.tsx | 9 +- packages/live2d/src/mixins/draggable.ts | 310 ++++++++++++++++++ packages/live2d/src/utils/drag-position.ts | 60 ++++ 5 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 packages/live2d/src/mixins/draggable.ts create mode 100644 packages/live2d/src/utils/drag-position.ts diff --git a/packages/live2d/src/components/Live2dChatWindow.tsx b/packages/live2d/src/components/Live2dChatWindow.tsx index 26c547e..ec3fc37 100644 --- a/packages/live2d/src/components/Live2dChatWindow.tsx +++ b/packages/live2d/src/components/Live2dChatWindow.tsx @@ -5,6 +5,7 @@ import { configContext, } from "@/live2d/context/config-context"; import { sendMessage } from "@/live2d/helpers/sendMessage"; +import { DraggableMixin } from "@/live2d/mixins/draggable"; import { consume } from "@lit/context"; import { type PropertyValues, type TemplateResult, html } from "lit"; import { property, query, state } from "lit/decorators.js"; @@ -14,6 +15,10 @@ const CHAT_PANEL_WIDTH = "min(26rem, calc(100vw - 1rem))"; const CHAT_PANEL_BOTTOM = "2rem"; const CHAT_PANEL_TRANSITION_MS = 220; +const DraggableUnoLitElement = DraggableMixin(UnoLitElement, { + storageKey: "chat-window", +}); + type PopoverCapableElement = HTMLDivElement & { hidePopover: () => void; showPopover: () => void; @@ -27,7 +32,7 @@ const isPopoverCapable = ( "function" && typeof (element as Partial).hidePopover === "function"; -export class Live2dChatWindow extends UnoLitElement { +export class Live2dChatWindow extends DraggableUnoLitElement { @consume({ context: configContext }) @property({ attribute: false }) public config?: Live2dConfig; @@ -53,6 +58,8 @@ export class Live2dChatWindow extends UnoLitElement { connectedCallback(): void { super.connectedCallback(); + // 应用保存的位置 + this.applySavedPosition(); window.addEventListener("live2d:toggle-chat-window", this.handleToggle); } @@ -63,7 +70,9 @@ export class Live2dChatWindow extends UnoLitElement { } render(): TemplateResult { - const positionStyle = `inset: auto auto ${CHAT_PANEL_BOTTOM} 50%; margin: 0; width: ${CHAT_PANEL_WIDTH}; transform: translateX(-50%); transition: opacity ${CHAT_PANEL_TRANSITION_MS}ms ease;`; + const positionStyle = this.getSavedPosition() + ? undefined + : `inset: auto auto ${CHAT_PANEL_BOTTOM} 50%; margin: 0; width: ${CHAT_PANEL_WIDTH}; transform: translateX(-50%); transition: opacity ${CHAT_PANEL_TRANSITION_MS}ms ease;`; const panelClasses = [ "fixed z-[10000] overflow-hidden rounded-full border border-[#eadfce] bg-[#fffaf4]/96 shadow-[0_10px_24px_rgba(15,23,42,0.08)] backdrop-blur-sm will-change-[opacity]", this._isShow diff --git a/packages/live2d/src/components/Live2dToggle.tsx b/packages/live2d/src/components/Live2dToggle.tsx index edb4ca5..3f2a057 100644 --- a/packages/live2d/src/components/Live2dToggle.tsx +++ b/packages/live2d/src/components/Live2dToggle.tsx @@ -10,12 +10,17 @@ import { readWidgetSuppression, rememberWidgetDismissal, } from "@/live2d/helpers/widgetVisibility"; +import { DraggableMixin } from "@/live2d/mixins/draggable"; import { consume } from "@lit/context"; import { type TemplateResult, html } from "lit"; import { property } from "lit/decorators.js"; import { state } from "lit/decorators.js"; -export class Live2dToggle extends UnoLitElement { +const DraggableUnoLitElement = DraggableMixin(UnoLitElement, { + storageKey: "toggle", +}); + +export class Live2dToggle extends DraggableUnoLitElement { @consume({ context: configContext }) @property({ attribute: false }) public config?: Live2dConfig; @@ -28,6 +33,8 @@ export class Live2dToggle extends UnoLitElement { connectedCallback(): void { super.connectedCallback(); + // 应用保存的位置 + this.applySavedPosition(); this.addEventListener("click", this.handleClick); window.addEventListener("live2d:toggle-canvas", this.handleGlobalToggle); diff --git a/packages/live2d/src/components/Live2dWidget.tsx b/packages/live2d/src/components/Live2dWidget.tsx index f5e038b..649d0c2 100644 --- a/packages/live2d/src/components/Live2dWidget.tsx +++ b/packages/live2d/src/components/Live2dWidget.tsx @@ -17,8 +17,13 @@ import { WIDGET_DRAWER_HIDDEN_BOTTOM, WIDGET_DRAWER_VISIBLE_BOTTOM, } from "@/live2d/helpers/widgetDrawer"; +import { DraggableMixin } from "@/live2d/mixins/draggable"; -export class Live2dWidget extends UnoLitElement { +const DraggableUnoLitElement = DraggableMixin(UnoLitElement, { + storageKey: "widget", +}); + +export class Live2dWidget extends DraggableUnoLitElement { @consume({ context: configContext }) @property({ attribute: false }) public config?: Live2dConfig; @@ -108,6 +113,8 @@ export class Live2dWidget extends UnoLitElement { connectedCallback(): void { super.connectedCallback(); + // 应用保存的位置 + this.applySavedPosition(); // 页面加载时清除历史消息 // 对应原始代码中的 window.onload window.addEventListener("load", this.clearChatHistory); diff --git a/packages/live2d/src/mixins/draggable.ts b/packages/live2d/src/mixins/draggable.ts new file mode 100644 index 0000000..ba87e80 --- /dev/null +++ b/packages/live2d/src/mixins/draggable.ts @@ -0,0 +1,310 @@ +import { + clearPosition, + getSavedPosition, + savePosition, +} from "@/live2d/utils/drag-position"; + +export interface DraggableOptions { + storageKey: string; +} + +export interface Position { + left?: string; + right?: string; + bottom?: string; + top?: string; +} + +export interface DraggableInterface { + getSavedPosition(): Position | null; + applySavedPosition(): void; + resetPosition(): void; +} + +const DRAGGING_CLASS = "live2d-dragging"; +const DRAG_OVERLAY_ID = "live2d-drag-overlay"; + +// biome-ignore lint/suspicious/noExplicitAny: mixin pattern requires any for constructor signature +export type Constructor = new (...args: any[]) => T; + +// biome-ignore lint/suspicious/noExplicitAny: mixin pattern requires any for constructor signature +type HTMLElementConstructor = new (...args: any[]) => HTMLElement; + +// Return type that includes the mixin interface +type DraggableMixinReturn = T & Constructor; + +/** + * 为 HTMLElement 添加拖拽调整位置的功能 + * 位置信息保存在 localStorage 中 + * + * 使用方式: + * class MyElement extends DraggableMixin(LitElement, { storageKey: "my-element" }) { + * // ... + * } + */ +export const DraggableMixin = ( + SuperClass: T, + options: DraggableOptions, +): DraggableMixinReturn => { + class DraggableClass extends SuperClass implements DraggableInterface { + private _dragStartX = 0; + private _dragStartY = 0; + private _initialLeft = 0; + private _initialTop = 0; + private _isDragging = false; + private _dragThreshold = 3; + private _overlay?: HTMLDivElement; + + // biome-ignore lint/suspicious/noExplicitAny: mixin pattern requires any for constructor rest args + constructor(...args: any[]) { + super(...args); + } + + /** + * 获取保存的位置 + */ + getSavedPosition(): Position | null { + return getSavedPosition(options.storageKey); + } + + /** + * 应用保存的位置到元素 + */ + applySavedPosition(): void { + const saved = this.getSavedPosition(); + if (!saved) { + return; + } + const style = this.style; + if (saved.left !== undefined) { + style.left = saved.left; + style.right = "auto"; + } + if (saved.right !== undefined) { + style.right = saved.right; + style.left = "auto"; + } + if (saved.bottom !== undefined) { + style.bottom = saved.bottom; + } + if (saved.top !== undefined) { + style.top = saved.top; + } + } + + /** + * 重置位置(清除保存的位置) + */ + resetPosition(): void { + clearPosition(options.storageKey); + const style = this.style; + style.left = ""; + style.right = ""; + style.bottom = ""; + style.top = ""; + } + + connectedCallback(): void { + const proto = Object.getPrototypeOf(DraggableClass.prototype); + if (typeof proto.connectedCallback === "function") { + proto.connectedCallback.call(this); + } + this._setupDragListeners(); + } + + disconnectedCallback(): void { + this._removeDragListeners(); + this._removeOverlay(); + const proto = Object.getPrototypeOf(DraggableClass.prototype); + if (typeof proto.disconnectedCallback === "function") { + proto.disconnectedCallback.call(this); + } + } + + private _setupDragListeners(): void { + this.addEventListener("mousedown", this._onMouseDown); + this.addEventListener("touchstart", this._onTouchStart, { passive: false }); + } + + private _removeDragListeners(): void { + this.removeEventListener("mousedown", this._onMouseDown); + this.removeEventListener("touchstart", this._onTouchStart); + this._endDrag(); + } + + private _onMouseDown = (e: MouseEvent): void => { + // 只响应左键,不响应输入框、按钮等交互元素上的拖拽 + if ( + e.button !== 0 || + this._isInteractiveElement(e.target as HTMLElement) + ) { + return; + } + this._startDrag(e.clientX, e.clientY); + e.preventDefault(); + }; + + private _onTouchStart = (e: TouchEvent): void => { + if ( + e.touches.length !== 1 || + this._isInteractiveElement(e.target as HTMLElement) + ) { + return; + } + const touch = e.touches[0]; + this._startDrag(touch.clientX, touch.clientY); + }; + + private _isInteractiveElement(el: HTMLElement | null): boolean { + if (!el) return false; + const tagName = el.tagName.toLowerCase(); + const interactiveTags = [ + "input", + "textarea", + "button", + "select", + "a", + "canvas", + ]; + if (interactiveTags.includes(tagName)) { + return true; + } + // 检查是否在工具栏内部 + if ( + el.closest("#live2d-tools") || + el.closest("#live2d-chat-input") || + el.closest("#live2d-chat-send") + ) { + return true; + } + return false; + } + + private _startDrag(clientX: number, clientY: number): void { + const rect = this.getBoundingClientRect(); + + this._dragStartX = clientX; + this._dragStartY = clientY; + this._initialLeft = rect.left; + this._initialTop = rect.top; + this._isDragging = false; + + document.addEventListener("mousemove", this._onMouseMove); + document.addEventListener("mouseup", this._onMouseUp); + document.addEventListener("touchmove", this._onTouchMove, { + passive: false, + }); + document.addEventListener("touchend", this._onTouchEnd); + } + + private _onMouseMove = (e: MouseEvent): void => { + this._handleMove(e.clientX, e.clientY); + }; + + private _onTouchMove = (e: TouchEvent): void => { + if (e.touches.length !== 1) return; + e.preventDefault(); + this._handleMove(e.touches[0].clientX, e.touches[0].clientY); + }; + + private _handleMove(clientX: number, clientY: number): void { + const dx = clientX - this._dragStartX; + const dy = clientY - this._dragStartY; + + if (!this._isDragging) { + if ( + Math.abs(dx) > this._dragThreshold || + Math.abs(dy) > this._dragThreshold + ) { + this._isDragging = true; + this._addOverlay(); + this.classList.add(DRAGGING_CLASS); + this.style.cursor = "grabbing"; + } else { + return; + } + } + + const newLeft = this._initialLeft + dx; + const newTop = this._initialTop + dy; + + // 确保不拖出视口 + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const rect = this.getBoundingClientRect(); + const clampedLeft = Math.max( + 0, + Math.min(newLeft, viewportWidth - rect.width), + ); + const clampedTop = Math.max( + 0, + Math.min(newTop, viewportHeight - rect.height), + ); + + this.style.left = `${clampedLeft}px`; + this.style.top = `${clampedTop}px`; + this.style.right = "auto"; + this.style.bottom = "auto"; + } + + private _onMouseUp = (): void => { + this._endDrag(); + }; + + private _onTouchEnd = (): void => { + this._endDrag(); + }; + + private _endDrag(): void { + if (this._isDragging) { + const rect = this.getBoundingClientRect(); + + // 保存位置 + savePosition(options.storageKey, { + left: `${rect.left}px`, + top: `${rect.top}px`, + }); + + this.classList.remove(DRAGGING_CLASS); + this.style.cursor = ""; + } + + document.removeEventListener("mousemove", this._onMouseMove); + document.removeEventListener("mouseup", this._onMouseUp); + document.removeEventListener("touchmove", this._onTouchMove); + document.removeEventListener("touchend", this._onTouchEnd); + + this._isDragging = false; + this._removeOverlay(); + } + + /** + * 添加一个全屏 overlay 来捕获鼠标事件,避免拖拽过程中触发其他元素 + */ + private _addOverlay(): void { + if (this._overlay) return; + this._overlay = document.createElement("div"); + this._overlay.id = DRAG_OVERLAY_ID; + this._overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 99999; + cursor: grabbing; + background: transparent; + `; + document.body.appendChild(this._overlay); + } + + private _removeOverlay(): void { + if (this._overlay && this._overlay.parentNode) { + this._overlay.parentNode.removeChild(this._overlay); + this._overlay = undefined; + } + } + } + + return DraggableClass as DraggableMixinReturn; +}; diff --git a/packages/live2d/src/utils/drag-position.ts b/packages/live2d/src/utils/drag-position.ts new file mode 100644 index 0000000..fc4f889 --- /dev/null +++ b/packages/live2d/src/utils/drag-position.ts @@ -0,0 +1,60 @@ +import type { Position } from "@/live2d/mixins/draggable"; + +const LIVE2D_POSITION_PREFIX = "live2d-position-"; + +/** + * 获取元素的保存位置 + */ +export const getSavedPosition = (key: string): Position | null => { + try { + const saved = localStorage.getItem(`${LIVE2D_POSITION_PREFIX}${key}`); + if (saved) { + return JSON.parse(saved) as Position; + } + } catch { + // ignore parse errors + } + return null; +}; + +/** + * 保存元素位置 + */ +export const savePosition = (key: string, position: Position): void => { + try { + localStorage.setItem(`${LIVE2D_POSITION_PREFIX}${key}`, JSON.stringify(position)); + } catch { + // ignore storage errors + } +}; + +/** + * 清除保存的位置 + */ +export const clearPosition = (key: string): void => { + try { + localStorage.removeItem(`${LIVE2D_POSITION_PREFIX}${key}`); + } catch { + // ignore storage errors + } +}; + +/** + * 清除所有 live2d 位置信息 + */ +export const clearAllPositions = (): void => { + try { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(LIVE2D_POSITION_PREFIX)) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + } catch { + // ignore storage errors + } +}; From f60d4661740bad1f6debd72861e080558648aa05 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Tue, 19 May 2026 15:35:32 +0800 Subject: [PATCH 2/3] implement the feature to drag and adjust position --- .../src/components/Live2dChatWindow.tsx | 7 +- .../live2d/src/components/Live2dToggle.tsx | 36 ++- .../live2d/src/components/Live2dWidget.tsx | 68 ++++- packages/live2d/src/mixins/draggable.ts | 281 ++++++++++++++---- packages/live2d/tsconfig.tsbuildinfo | 2 +- 5 files changed, 300 insertions(+), 94 deletions(-) diff --git a/packages/live2d/src/components/Live2dChatWindow.tsx b/packages/live2d/src/components/Live2dChatWindow.tsx index ec3fc37..2573925 100644 --- a/packages/live2d/src/components/Live2dChatWindow.tsx +++ b/packages/live2d/src/components/Live2dChatWindow.tsx @@ -17,6 +17,8 @@ const CHAT_PANEL_TRANSITION_MS = 220; const DraggableUnoLitElement = DraggableMixin(UnoLitElement, { storageKey: "chat-window", + targetSelector: "#live2d-chat-model", + clearTransformOnPosition: true, }); type PopoverCapableElement = HTMLDivElement & { @@ -70,9 +72,6 @@ export class Live2dChatWindow extends DraggableUnoLitElement { } render(): TemplateResult { - const positionStyle = this.getSavedPosition() - ? undefined - : `inset: auto auto ${CHAT_PANEL_BOTTOM} 50%; margin: 0; width: ${CHAT_PANEL_WIDTH}; transform: translateX(-50%); transition: opacity ${CHAT_PANEL_TRANSITION_MS}ms ease;`; const panelClasses = [ "fixed z-[10000] overflow-hidden rounded-full border border-[#eadfce] bg-[#fffaf4]/96 shadow-[0_10px_24px_rgba(15,23,42,0.08)] backdrop-blur-sm will-change-[opacity]", this._isShow @@ -89,7 +88,7 @@ export class Live2dChatWindow extends DraggableUnoLitElement { id="live2d-chat-model" popover="manual" class=${panelClasses} - style=${positionStyle} + style="inset: auto auto ${CHAT_PANEL_BOTTOM} 50%; margin: 0; width: ${CHAT_PANEL_WIDTH}; transform: translateX(-50%); transition: opacity ${CHAT_PANEL_TRANSITION_MS}ms ease;" >
- 看板娘 + + + + 看板娘
`; } @@ -78,6 +86,14 @@ export class Live2dToggle extends DraggableUnoLitElement { this.dispatchEvent(new ToggleCanvasEvent({ isShow: this._isShow })); } + handleKeydown = (e: KeyboardEvent) => { + if (e.key !== "Enter" && e.key !== " ") { + return; + } + e.preventDefault(); + this.handleClick(); + }; + handleGlobalToggle = (e: Event) => { const event = e as ToggleCanvasEvent; if (event.detail.isShow) { diff --git a/packages/live2d/src/components/Live2dWidget.tsx b/packages/live2d/src/components/Live2dWidget.tsx index 649d0c2..b1c26b4 100644 --- a/packages/live2d/src/components/Live2dWidget.tsx +++ b/packages/live2d/src/components/Live2dWidget.tsx @@ -14,13 +14,13 @@ import "@/live2d/components/Live2dChatWindow"; import type { ToggleCanvasEvent } from "@/live2d/events/toggle-canvas"; import { WIDGET_DRAWER_DURATION_MS, - WIDGET_DRAWER_HIDDEN_BOTTOM, WIDGET_DRAWER_VISIBLE_BOTTOM, } from "@/live2d/helpers/widgetDrawer"; import { DraggableMixin } from "@/live2d/mixins/draggable"; const DraggableUnoLitElement = DraggableMixin(UnoLitElement, { storageKey: "widget", + targetSelector: "#live2d-plugin", }); export class Live2dWidget extends DraggableUnoLitElement { @@ -34,7 +34,11 @@ export class Live2dWidget extends DraggableUnoLitElement { @state() private _hasMountedWidget = false; + @state() + private _isDrawerAnimating = false; + private showAnimationFrameId?: number; + private drawerAnimationTimer?: number; render(): TemplateResult { return html` @@ -54,6 +58,16 @@ export class Live2dWidget extends DraggableUnoLitElement { } } + renderLive2dTips() { + if (!this._isShow && !this._isDrawerAnimating) { + return; + } + + return html``; + } + renderLive2dWidget() { if (!this._hasMountedWidget) { return; @@ -66,24 +80,29 @@ export class Live2dWidget extends DraggableUnoLitElement { const visibilityClass = this._isShow ? "pointer-events-auto" : "pointer-events-none"; - const bottom = this._isShow - ? WIDGET_DRAWER_VISIBLE_BOTTOM - : WIDGET_DRAWER_HIDDEN_BOTTOM; + const shouldClipDrawer = !this._isShow || this._isDrawerAnimating; + const drawerBoundaryStyle = shouldClipDrawer + ? `bottom: ${WIDGET_DRAWER_VISIBLE_BOTTOM}; clip-path: inset(-100vh -100vw 0 -100vw);` + : `bottom: ${WIDGET_DRAWER_VISIBLE_BOTTOM};`; + const drawerClass = this._isShow ? "translate-y-0" : "translate-y-full"; return html`
- - - ${this.renderLive2dTools()} +
+ ${this.renderLive2dTips()} + + ${this.renderLive2dTools()} +
`; } @@ -97,6 +116,7 @@ export class Live2dWidget extends DraggableUnoLitElement { } this.cancelScheduledShow(); + this.startDrawerAnimation(); this._isShow = e.detail.isShow; this.requestUpdate(); }; @@ -128,6 +148,7 @@ export class Live2dWidget extends DraggableUnoLitElement { disconnectedCallback(): void { super.disconnectedCallback(); this.cancelScheduledShow(); + this.cancelDrawerAnimation(); window.removeEventListener("load", this.clearChatHistory); window.removeEventListener( "live2d:toggle-canvas", @@ -146,6 +167,7 @@ export class Live2dWidget extends DraggableUnoLitElement { this.cancelScheduledShow(); this.showAnimationFrameId = window.requestAnimationFrame(() => { this.showAnimationFrameId = undefined; + this.startDrawerAnimation(); this._isShow = true; this.requestUpdate(); }); @@ -159,6 +181,24 @@ export class Live2dWidget extends DraggableUnoLitElement { window.cancelAnimationFrame(this.showAnimationFrameId); this.showAnimationFrameId = undefined; } + + private startDrawerAnimation(): void { + this.cancelDrawerAnimation(); + this._isDrawerAnimating = true; + this.drawerAnimationTimer = window.setTimeout(() => { + this.drawerAnimationTimer = undefined; + this._isDrawerAnimating = false; + }, WIDGET_DRAWER_DURATION_MS); + } + + private cancelDrawerAnimation(): void { + if (this.drawerAnimationTimer === undefined) { + return; + } + + window.clearTimeout(this.drawerAnimationTimer); + this.drawerAnimationTimer = undefined; + } } customElements.define("live2d-widget", Live2dWidget); diff --git a/packages/live2d/src/mixins/draggable.ts b/packages/live2d/src/mixins/draggable.ts index ba87e80..d7c4c25 100644 --- a/packages/live2d/src/mixins/draggable.ts +++ b/packages/live2d/src/mixins/draggable.ts @@ -3,9 +3,12 @@ import { getSavedPosition, savePosition, } from "@/live2d/utils/drag-position"; +import type { PropertyValues } from "lit"; export interface DraggableOptions { storageKey: string; + targetSelector?: string; + clearTransformOnPosition?: boolean; } export interface Position { @@ -23,15 +26,24 @@ export interface DraggableInterface { const DRAGGING_CLASS = "live2d-dragging"; const DRAG_OVERLAY_ID = "live2d-drag-overlay"; +const POSITION_UNIT_PATTERN = /^-?\d+(\.\d+)?px$/; -// biome-ignore lint/suspicious/noExplicitAny: mixin pattern requires any for constructor signature -export type Constructor = new (...args: any[]) => T; +// TypeScript requires `any[]` for generic class mixin constructors. +// biome-ignore lint/suspicious/noExplicitAny: required by TS mixin constructor rules +type Constructor = new (...args: any[]) => T; -// biome-ignore lint/suspicious/noExplicitAny: mixin pattern requires any for constructor signature -type HTMLElementConstructor = new (...args: any[]) => HTMLElement; +type HTMLElementConstructor = Constructor; + +type HTMLElementLifecycle = HTMLElement & { + connectedCallback?(): void; + disconnectedCallback?(): void; + firstUpdated?(changedProperties: PropertyValues): void; + updated?(changedProperties: PropertyValues): void; +}; // Return type that includes the mixin interface -type DraggableMixinReturn = T & Constructor; +type DraggableMixinReturn = T & + Constructor; /** * 为 HTMLElement 添加拖拽调整位置的功能 @@ -52,13 +64,11 @@ export const DraggableMixin = ( private _initialLeft = 0; private _initialTop = 0; private _isDragging = false; - private _dragThreshold = 3; + private readonly _dragThreshold = 3; + private _dragElement?: HTMLElement; + private _activeDragElement?: HTMLElement; private _overlay?: HTMLDivElement; - - // biome-ignore lint/suspicious/noExplicitAny: mixin pattern requires any for constructor rest args - constructor(...args: any[]) { - super(...args); - } + private _suppressNextClick = false; /** * 获取保存的位置 @@ -72,23 +82,33 @@ export const DraggableMixin = ( */ applySavedPosition(): void { const saved = this.getSavedPosition(); - if (!saved) { + const dragElement = this._getDragElement(); + if (!saved || !dragElement) { return; } - const style = this.style; - if (saved.left !== undefined) { - style.left = saved.left; + const nextPosition = this._getClampedSavedPosition(saved, dragElement); + if (!nextPosition) { + clearPosition(options.storageKey); + return; + } + const style = dragElement.style; + if (nextPosition.left !== undefined) { + style.left = nextPosition.left; style.right = "auto"; } - if (saved.right !== undefined) { - style.right = saved.right; + if (nextPosition.right !== undefined) { + style.right = nextPosition.right; style.left = "auto"; } - if (saved.bottom !== undefined) { - style.bottom = saved.bottom; + if (nextPosition.bottom !== undefined) { + style.bottom = nextPosition.bottom; + } + if (nextPosition.top !== undefined) { + style.top = nextPosition.top; + style.bottom = "auto"; } - if (saved.top !== undefined) { - style.top = saved.top; + if (options.clearTransformOnPosition) { + style.transform = "none"; } } @@ -97,15 +117,22 @@ export const DraggableMixin = ( */ resetPosition(): void { clearPosition(options.storageKey); - const style = this.style; + const dragElement = this._getDragElement(); + if (!dragElement) { + return; + } + const style = dragElement.style; style.left = ""; style.right = ""; style.bottom = ""; style.top = ""; + if (options.clearTransformOnPosition) { + style.transform = ""; + } } connectedCallback(): void { - const proto = Object.getPrototypeOf(DraggableClass.prototype); + const proto = this._getSuperPrototype(); if (typeof proto.connectedCallback === "function") { proto.connectedCallback.call(this); } @@ -115,29 +142,121 @@ export const DraggableMixin = ( disconnectedCallback(): void { this._removeDragListeners(); this._removeOverlay(); - const proto = Object.getPrototypeOf(DraggableClass.prototype); + const proto = this._getSuperPrototype(); if (typeof proto.disconnectedCallback === "function") { proto.disconnectedCallback.call(this); } } + protected firstUpdated(changedProperties: PropertyValues): void { + const proto = this._getSuperPrototype(); + if (typeof proto.firstUpdated === "function") { + proto.firstUpdated.call(this, changedProperties); + } + this._setupDragListeners(); + this.applySavedPosition(); + } + + protected updated(changedProperties: PropertyValues): void { + const proto = this._getSuperPrototype(); + if (typeof proto.updated === "function") { + proto.updated.call(this, changedProperties); + } + this._setupDragListeners(); + if (!this._isDragging) { + this.applySavedPosition(); + } + } + + private _getSuperPrototype(): HTMLElementLifecycle { + return Object.getPrototypeOf( + DraggableClass.prototype, + ) as HTMLElementLifecycle; + } + + private _getDragElement(): HTMLElement | null { + if (!options.targetSelector) { + return this; + } + return ( + this.shadowRoot?.querySelector(options.targetSelector) ?? + null + ); + } + + private _getClampedSavedPosition( + saved: Position, + dragElement: HTMLElement, + ): Position | null { + if ( + !this._isPixelPosition(saved.left) || + !this._isPixelPosition(saved.top) + ) { + return null; + } + + const rect = dragElement.getBoundingClientRect(); + const left = this._clampToViewport( + Number.parseFloat(saved.left), + rect.width, + window.innerWidth, + ); + const top = this._clampToViewport( + Number.parseFloat(saved.top), + rect.height, + window.innerHeight, + ); + + return { + left: `${left}px`, + top: `${top}px`, + }; + } + + private _isPixelPosition(value: string | undefined): value is string { + return value !== undefined && POSITION_UNIT_PATTERN.test(value); + } + + private _clampToViewport( + position: number, + elementSize: number, + viewportSize: number, + ): number { + return Math.max( + 0, + Math.min(position, Math.max(0, viewportSize - elementSize)), + ); + } + private _setupDragListeners(): void { - this.addEventListener("mousedown", this._onMouseDown); - this.addEventListener("touchstart", this._onTouchStart, { passive: false }); + const dragElement = this._getDragElement(); + if (!dragElement || dragElement === this._dragElement) { + return; + } + this._removeDragListeners(); + this._dragElement = dragElement; + dragElement.addEventListener("mousedown", this._onMouseDown); + dragElement.addEventListener("touchstart", this._onTouchStart, { + passive: false, + }); + dragElement.addEventListener("click", this._onClickCapture, true); } private _removeDragListeners(): void { - this.removeEventListener("mousedown", this._onMouseDown); - this.removeEventListener("touchstart", this._onTouchStart); + this._dragElement?.removeEventListener("mousedown", this._onMouseDown); + this._dragElement?.removeEventListener("touchstart", this._onTouchStart); + this._dragElement?.removeEventListener( + "click", + this._onClickCapture, + true, + ); + this._dragElement = undefined; this._endDrag(); } private _onMouseDown = (e: MouseEvent): void => { // 只响应左键,不响应输入框、按钮等交互元素上的拖拽 - if ( - e.button !== 0 || - this._isInteractiveElement(e.target as HTMLElement) - ) { + if (e.button !== 0 || this._isInteractiveEvent(e)) { return; } this._startDrag(e.clientX, e.clientY); @@ -145,27 +264,31 @@ export const DraggableMixin = ( }; private _onTouchStart = (e: TouchEvent): void => { - if ( - e.touches.length !== 1 || - this._isInteractiveElement(e.target as HTMLElement) - ) { + if (e.touches.length !== 1 || this._isInteractiveEvent(e)) { return; } const touch = e.touches[0]; this._startDrag(touch.clientX, touch.clientY); }; - private _isInteractiveElement(el: HTMLElement | null): boolean { - if (!el) return false; + private _isInteractiveEvent(e: Event): boolean { + for (const target of e.composedPath()) { + if (target === this._dragElement || target === this) { + break; + } + if ( + target instanceof HTMLElement && + this._isInteractiveElement(target) + ) { + return true; + } + } + return false; + } + + private _isInteractiveElement(el: HTMLElement): boolean { const tagName = el.tagName.toLowerCase(); - const interactiveTags = [ - "input", - "textarea", - "button", - "select", - "a", - "canvas", - ]; + const interactiveTags = ["input", "textarea", "button", "select", "a"]; if (interactiveTags.includes(tagName)) { return true; } @@ -181,13 +304,18 @@ export const DraggableMixin = ( } private _startDrag(clientX: number, clientY: number): void { - const rect = this.getBoundingClientRect(); + const dragElement = this._getDragElement(); + if (!dragElement) { + return; + } + const rect = dragElement.getBoundingClientRect(); this._dragStartX = clientX; this._dragStartY = clientY; this._initialLeft = rect.left; this._initialTop = rect.top; this._isDragging = false; + this._activeDragElement = dragElement; document.addEventListener("mousemove", this._onMouseMove); document.addEventListener("mouseup", this._onMouseUp); @@ -208,6 +336,10 @@ export const DraggableMixin = ( }; private _handleMove(clientX: number, clientY: number): void { + const dragElement = this._activeDragElement ?? this._getDragElement(); + if (!dragElement) { + return; + } const dx = clientX - this._dragStartX; const dy = clientY - this._dragStartY; @@ -218,8 +350,8 @@ export const DraggableMixin = ( ) { this._isDragging = true; this._addOverlay(); - this.classList.add(DRAGGING_CLASS); - this.style.cursor = "grabbing"; + dragElement.classList.add(DRAGGING_CLASS); + dragElement.style.cursor = "grabbing"; } else { return; } @@ -231,7 +363,7 @@ export const DraggableMixin = ( // 确保不拖出视口 const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const rect = this.getBoundingClientRect(); + const rect = dragElement.getBoundingClientRect(); const clampedLeft = Math.max( 0, Math.min(newLeft, viewportWidth - rect.width), @@ -241,10 +373,13 @@ export const DraggableMixin = ( Math.min(newTop, viewportHeight - rect.height), ); - this.style.left = `${clampedLeft}px`; - this.style.top = `${clampedTop}px`; - this.style.right = "auto"; - this.style.bottom = "auto"; + dragElement.style.left = `${clampedLeft}px`; + dragElement.style.top = `${clampedTop}px`; + dragElement.style.right = "auto"; + dragElement.style.bottom = "auto"; + if (options.clearTransformOnPosition) { + dragElement.style.transform = "none"; + } } private _onMouseUp = (): void => { @@ -256,17 +391,23 @@ export const DraggableMixin = ( }; private _endDrag(): void { + const dragElement = this._activeDragElement ?? this._getDragElement(); if (this._isDragging) { - const rect = this.getBoundingClientRect(); - - // 保存位置 - savePosition(options.storageKey, { - left: `${rect.left}px`, - top: `${rect.top}px`, - }); - - this.classList.remove(DRAGGING_CLASS); - this.style.cursor = ""; + const rect = dragElement?.getBoundingClientRect(); + if (rect && dragElement) { + // 保存位置 + savePosition(options.storageKey, { + left: `${rect.left}px`, + top: `${rect.top}px`, + }); + + dragElement.classList.remove(DRAGGING_CLASS); + dragElement.style.cursor = ""; + this._suppressNextClick = true; + window.setTimeout(() => { + this._suppressNextClick = false; + }, 0); + } } document.removeEventListener("mousemove", this._onMouseMove); @@ -275,9 +416,19 @@ export const DraggableMixin = ( document.removeEventListener("touchend", this._onTouchEnd); this._isDragging = false; + this._activeDragElement = undefined; this._removeOverlay(); } + private _onClickCapture = (e: MouseEvent): void => { + if (!this._suppressNextClick) { + return; + } + e.preventDefault(); + e.stopImmediatePropagation(); + this._suppressNextClick = false; + }; + /** * 添加一个全屏 overlay 来捕获鼠标事件,避免拖拽过程中触发其他元素 */ @@ -299,7 +450,7 @@ export const DraggableMixin = ( } private _removeOverlay(): void { - if (this._overlay && this._overlay.parentNode) { + if (this._overlay?.parentNode) { this._overlay.parentNode.removeChild(this._overlay); this._overlay = undefined; } diff --git a/packages/live2d/tsconfig.tsbuildinfo b/packages/live2d/tsconfig.tsbuildinfo index 49b7518..17e42d2 100644 --- a/packages/live2d/tsconfig.tsbuildinfo +++ b/packages/live2d/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/demo.tsx","./src/env.d.ts","./src/halo-config.ts","./src/halo.ts","./src/index.ts","./src/api/chat-api.ts","./src/common/unolitelement.ts","./src/components/live2dcanvas.tsx","./src/components/live2dchatwindow.tsx","./src/components/live2dcontext.tsx","./src/components/live2dtips.tsx","./src/components/live2dtoggle.tsx","./src/components/live2dtools.tsx","./src/components/live2dwidget.tsx","./src/config/default-config.ts","./src/config/normalize-config.ts","./src/config/normalize-helpers.ts","./src/config/custom-tools/normalize-custom-tools.ts","./src/context/config-context.ts","./src/events/add-default-message.ts","./src/events/before-init.ts","./src/events/index.ts","./src/events/model-layout.ts","./src/events/model-ready.ts","./src/events/send-message.ts","./src/events/stream-message.ts","./src/events/tip-events.ts","./src/events/toggle-canvas.ts","./src/events/toggle-chat-window.ts","./src/events/types.ts","./src/helpers/createstreammessage.ts","./src/helpers/datewithinrange.ts","./src/helpers/getplugintips.ts","./src/helpers/loadfulltipsresource.ts","./src/helpers/loadmergedtips.ts","./src/helpers/loadtipsresource.ts","./src/helpers/mergetips.ts","./src/helpers/sendmessage.ts","./src/helpers/timewithinrange.ts","./src/helpers/widgetdrawer.ts","./src/helpers/widgetvisibility.ts","./src/live2d/console-status.ts","./src/live2d/model.ts","./src/live2d/runtime.ts","./src/live2d/tools/ai-chat.ts","./src/live2d/tools/asteroids.ts","./src/live2d/tools/custom-tool-config.ts","./src/live2d/tools/custom-tool.ts","./src/live2d/tools/exit.ts","./src/live2d/tools/hitokoto.ts","./src/live2d/tools/index.ts","./src/live2d/tools/info.ts","./src/live2d/tools/screenshot.ts","./src/live2d/tools/switch-model.ts","./src/live2d/tools/switch-texture.ts","./src/live2d/tools/tools.ts","./src/live2d/tools/custom-tool-actions/actions.generated.ts","./src/live2d/tools/custom-tool-actions/index.ts","./src/live2d/tools/custom-tool-actions/types.ts","./src/live2d/tools/custom-tool-actions/actions/emit-event.ts","./src/live2d/tools/custom-tool-actions/actions/load-model.ts","./src/live2d/tools/custom-tool-actions/actions/open-url.ts","./src/live2d/tools/custom-tool-actions/actions/screenshot.ts","./src/live2d/tools/custom-tool-actions/actions/send-message.ts","./src/live2d/tools/custom-tool-actions/actions/switch-model.ts","./src/live2d/tools/custom-tool-actions/actions/switch-texture.ts","./src/live2d/tools/custom-tool-actions/actions/toggle-chat.ts","./src/live2d/tools/custom-tool-actions/actions/widget-visibility.ts","./src/styles/unocss.global.css.d.ts","./src/types/assets.d.ts","./src/utils/distinctarray.ts","./src/utils/isnotempty.ts","./src/utils/isstring.ts","./src/utils/randomselection.ts","./src/utils/unomixin.ts","./src/utils/util.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/demo.tsx","./src/env.d.ts","./src/halo-config.ts","./src/halo.ts","./src/index.ts","./src/api/chat-api.ts","./src/common/unolitelement.ts","./src/components/live2dcanvas.tsx","./src/components/live2dchatwindow.tsx","./src/components/live2dcontext.tsx","./src/components/live2dtips.tsx","./src/components/live2dtoggle.tsx","./src/components/live2dtools.tsx","./src/components/live2dwidget.tsx","./src/config/default-config.ts","./src/config/normalize-config.ts","./src/config/normalize-helpers.ts","./src/config/custom-tools/normalize-custom-tools.ts","./src/context/config-context.ts","./src/events/add-default-message.ts","./src/events/before-init.ts","./src/events/index.ts","./src/events/model-layout.ts","./src/events/model-ready.ts","./src/events/send-message.ts","./src/events/stream-message.ts","./src/events/tip-events.ts","./src/events/toggle-canvas.ts","./src/events/toggle-chat-window.ts","./src/events/types.ts","./src/helpers/createstreammessage.ts","./src/helpers/datewithinrange.ts","./src/helpers/getplugintips.ts","./src/helpers/loadfulltipsresource.ts","./src/helpers/loadmergedtips.ts","./src/helpers/loadtipsresource.ts","./src/helpers/mergetips.ts","./src/helpers/sendmessage.ts","./src/helpers/timewithinrange.ts","./src/helpers/widgetdrawer.ts","./src/helpers/widgetvisibility.ts","./src/live2d/console-status.ts","./src/live2d/model.ts","./src/live2d/runtime.ts","./src/live2d/tools/ai-chat.ts","./src/live2d/tools/asteroids.ts","./src/live2d/tools/custom-tool-config.ts","./src/live2d/tools/custom-tool.ts","./src/live2d/tools/exit.ts","./src/live2d/tools/hitokoto.ts","./src/live2d/tools/index.ts","./src/live2d/tools/info.ts","./src/live2d/tools/screenshot.ts","./src/live2d/tools/switch-model.ts","./src/live2d/tools/switch-texture.ts","./src/live2d/tools/tools.ts","./src/live2d/tools/custom-tool-actions/actions.generated.ts","./src/live2d/tools/custom-tool-actions/index.ts","./src/live2d/tools/custom-tool-actions/types.ts","./src/live2d/tools/custom-tool-actions/actions/emit-event.ts","./src/live2d/tools/custom-tool-actions/actions/load-model.ts","./src/live2d/tools/custom-tool-actions/actions/open-url.ts","./src/live2d/tools/custom-tool-actions/actions/screenshot.ts","./src/live2d/tools/custom-tool-actions/actions/send-message.ts","./src/live2d/tools/custom-tool-actions/actions/switch-model.ts","./src/live2d/tools/custom-tool-actions/actions/switch-texture.ts","./src/live2d/tools/custom-tool-actions/actions/toggle-chat.ts","./src/live2d/tools/custom-tool-actions/actions/widget-visibility.ts","./src/mixins/draggable.ts","./src/styles/unocss.global.css.d.ts","./src/types/assets.d.ts","./src/utils/distinctarray.ts","./src/utils/drag-position.ts","./src/utils/isnotempty.ts","./src/utils/isstring.ts","./src/utils/randomselection.ts","./src/utils/unomixin.ts","./src/utils/util.ts"],"version":"5.7.3"} \ No newline at end of file From 3398b9b84c55642d852250652917a3dd0f5a5ebb Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Tue, 19 May 2026 15:38:30 +0800 Subject: [PATCH 3/3] remove tsbuildinfo --- .gitignore | 5 ++++- packages/live2d/tsconfig.tsbuildinfo | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 packages/live2d/tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 0ad2ba8..66bab4f 100755 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,7 @@ application-local.properties /admin-frontend/node_modules/ /workplace/ -/node_modules/ \ No newline at end of file +/node_modules/ + +### TypeScript +*.tsbuildinfo \ No newline at end of file diff --git a/packages/live2d/tsconfig.tsbuildinfo b/packages/live2d/tsconfig.tsbuildinfo deleted file mode 100644 index 17e42d2..0000000 --- a/packages/live2d/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/demo.tsx","./src/env.d.ts","./src/halo-config.ts","./src/halo.ts","./src/index.ts","./src/api/chat-api.ts","./src/common/unolitelement.ts","./src/components/live2dcanvas.tsx","./src/components/live2dchatwindow.tsx","./src/components/live2dcontext.tsx","./src/components/live2dtips.tsx","./src/components/live2dtoggle.tsx","./src/components/live2dtools.tsx","./src/components/live2dwidget.tsx","./src/config/default-config.ts","./src/config/normalize-config.ts","./src/config/normalize-helpers.ts","./src/config/custom-tools/normalize-custom-tools.ts","./src/context/config-context.ts","./src/events/add-default-message.ts","./src/events/before-init.ts","./src/events/index.ts","./src/events/model-layout.ts","./src/events/model-ready.ts","./src/events/send-message.ts","./src/events/stream-message.ts","./src/events/tip-events.ts","./src/events/toggle-canvas.ts","./src/events/toggle-chat-window.ts","./src/events/types.ts","./src/helpers/createstreammessage.ts","./src/helpers/datewithinrange.ts","./src/helpers/getplugintips.ts","./src/helpers/loadfulltipsresource.ts","./src/helpers/loadmergedtips.ts","./src/helpers/loadtipsresource.ts","./src/helpers/mergetips.ts","./src/helpers/sendmessage.ts","./src/helpers/timewithinrange.ts","./src/helpers/widgetdrawer.ts","./src/helpers/widgetvisibility.ts","./src/live2d/console-status.ts","./src/live2d/model.ts","./src/live2d/runtime.ts","./src/live2d/tools/ai-chat.ts","./src/live2d/tools/asteroids.ts","./src/live2d/tools/custom-tool-config.ts","./src/live2d/tools/custom-tool.ts","./src/live2d/tools/exit.ts","./src/live2d/tools/hitokoto.ts","./src/live2d/tools/index.ts","./src/live2d/tools/info.ts","./src/live2d/tools/screenshot.ts","./src/live2d/tools/switch-model.ts","./src/live2d/tools/switch-texture.ts","./src/live2d/tools/tools.ts","./src/live2d/tools/custom-tool-actions/actions.generated.ts","./src/live2d/tools/custom-tool-actions/index.ts","./src/live2d/tools/custom-tool-actions/types.ts","./src/live2d/tools/custom-tool-actions/actions/emit-event.ts","./src/live2d/tools/custom-tool-actions/actions/load-model.ts","./src/live2d/tools/custom-tool-actions/actions/open-url.ts","./src/live2d/tools/custom-tool-actions/actions/screenshot.ts","./src/live2d/tools/custom-tool-actions/actions/send-message.ts","./src/live2d/tools/custom-tool-actions/actions/switch-model.ts","./src/live2d/tools/custom-tool-actions/actions/switch-texture.ts","./src/live2d/tools/custom-tool-actions/actions/toggle-chat.ts","./src/live2d/tools/custom-tool-actions/actions/widget-visibility.ts","./src/mixins/draggable.ts","./src/styles/unocss.global.css.d.ts","./src/types/assets.d.ts","./src/utils/distinctarray.ts","./src/utils/drag-position.ts","./src/utils/isnotempty.ts","./src/utils/isstring.ts","./src/utils/randomselection.ts","./src/utils/unomixin.ts","./src/utils/util.ts"],"version":"5.7.3"} \ No newline at end of file