diff --git a/src/components/Menu/ContextMenu/Container.vue b/src/components/Menu/ContextMenu/Container.vue index d71ba3d9b7..c3bb9e2e03 100644 --- a/src/components/Menu/ContextMenu/Container.vue +++ b/src/components/Menu/ContextMenu/Container.vue @@ -22,6 +22,9 @@ defineProps<{ menudata: ContextMenuItemData[]; }>(); defineExpose({ + show: (event?: MouseEvent) => { + contextMenu.value?.show(event); + }, hide: () => { contextMenu.value?.hide(); }, diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 7b0597faae..2a8c316e33 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -216,18 +216,20 @@ diff --git a/src/components/Sing/SequencerVolumeToolPalette.vue b/src/components/Sing/SequencerVolumeToolPalette.vue new file mode 100644 index 0000000000..1063f9bebf --- /dev/null +++ b/src/components/Sing/SequencerVolumeToolPalette.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/composables/useAutoScrollOnEdge.ts b/src/composables/useAutoScrollOnEdge.ts index cd59f8aec1..5ae1ac4b24 100644 --- a/src/composables/useAutoScrollOnEdge.ts +++ b/src/composables/useAutoScrollOnEdge.ts @@ -10,6 +10,15 @@ import { getXInBorderBox, getYInBorderBox } from "@/sing/viewHelper"; export const useAutoScrollOnEdge = ( element: Ref, enable: ComputedRef, + options?: { + /** + * 要素外に出た時にスクロールを継続する方向 + * - "none": 要素外で停止(デフォルト) + * - "x": 水平スクロールのみ継続 + * NOTE: "y"や"xy"は必要になった時点で実装する + */ + continueScrollOutside?: "none" | "x"; + }, ) => { const baseSpeed = 100; const accelerationFactor = 1.7; @@ -103,10 +112,34 @@ export const useAutoScrollOnEdge = ( throw new Error("element.value is null."); } if (autoScrollState != undefined) { - autoScrollState.cursorPos = new Vector2D( - getXInBorderBox(event.clientX, element.value), - getYInBorderBox(event.clientY, element.value), - ); + let x = getXInBorderBox(event.clientX, element.value); + let y = getYInBorderBox(event.clientY, element.value); + const width = element.value.clientWidth; + const height = element.value.clientHeight; + + const inside = x >= 0 && y >= 0 && x <= width && y <= height; + + if (inside) { + autoScrollState.cursorPos = new Vector2D(x, y); + return; + } + + // continueScrollOutside: 要素外に出た時の挙動 + // - "none"(デフォルト): 要素外ではスクロールを止める(ScoreSequencer既存挙動) + // - "x": 水平スクロールのみ継続(VolumeEditorなど) + const continueScroll = options?.continueScrollOutside ?? "none"; + + if (continueScroll === "x") { + // 水平スクロールのみ継続:Xをクランプ、Yを中央に固定 + x = Math.min(Math.max(x, 0), width); + y = height / 2; + } else { + // 要素外ではスクロール停止 + autoScrollState.cursorPos = undefined; + return; + } + + autoScrollState.cursorPos = new Vector2D(x, y); } }; diff --git a/src/composables/useParameterPanelStateMachine.ts b/src/composables/useParameterPanelStateMachine.ts index 18fdbdb57c..ecafa95ea9 100644 --- a/src/composables/useParameterPanelStateMachine.ts +++ b/src/composables/useParameterPanelStateMachine.ts @@ -30,6 +30,7 @@ export const useParameterPanelStateMachine = ( tpqn: computed(() => store.state.tpqn), zoomX: computed(() => store.state.sequencerZoomX), zoomY: computed(() => store.state.sequencerZoomY), + nowPlaying: computed(() => store.state.nowPlaying), }; // NOTE: parameterPanelEditTargetは今のところVOLUMEのみ。 diff --git a/src/sing/graphics/volumeLine.ts b/src/sing/graphics/volumeLine.ts new file mode 100644 index 0000000000..8bcc3e8c5f --- /dev/null +++ b/src/sing/graphics/volumeLine.ts @@ -0,0 +1,167 @@ +import * as PIXI from "pixi.js"; +import { Color } from "@/sing/graphics/lineStrip"; + +export type VolumePoint = { + readonly baseX: number; + readonly normalizedY: number; +}; + +export type VolumeSegment = VolumePoint[]; + +export type VolumeViewInfo = { + readonly viewportWidth: number; + readonly viewportHeight: number; + readonly zoomX: number; + readonly offsetX: number; + readonly leftPadding: number; +}; + +type VolumeLineOptions = { + color: Color; + width: number; + dashed?: boolean; + showArea?: boolean; + areaAlpha?: number; + isVisible?: boolean; +}; + +const colorToHex = (color: Color) => { + return (color.r << 16) + (color.g << 8) + color.b; +}; + +/** + * ボリュームライン(折れ線と塗りつぶし)を描画するクラス。 + */ +export class VolumeLine { + color: Color; + width: number; + dashed: boolean; + showArea: boolean; + areaAlpha: number; + isVisible: boolean; + + private readonly container: PIXI.Container; + private readonly area: PIXI.Graphics; + private readonly line: PIXI.Graphics; + + get displayObject(): PIXI.DisplayObject { + return this.container; + } + + constructor(options: VolumeLineOptions) { + this.color = options.color; + this.width = options.width; + this.dashed = options.dashed ?? false; + this.showArea = options.showArea ?? false; + this.areaAlpha = options.areaAlpha ?? 0.15; + this.isVisible = options.isVisible ?? true; + + this.container = new PIXI.Container(); + this.area = new PIXI.Graphics(); + this.line = new PIXI.Graphics(); + + this.container.addChild(this.area); + this.container.addChild(this.line); + } + + update(segments: VolumeSegment[], viewInfo: VolumeViewInfo) { + this.container.renderable = this.isVisible; + if (!this.isVisible) { + return; + } + const alpha = this.color.a / 255; + + this.area.clear(); + this.line.clear(); + this.line.lineStyle({ + width: this.width, + color: colorToHex(this.color), + alpha, + alignment: 0.5, + }); + + for (const segment of segments) { + if (segment.length < 2) continue; + + // 画面座標に変換 + const screenPoints = segment.map((point) => ({ + x: + point.baseX * viewInfo.zoomX - + viewInfo.offsetX + + viewInfo.leftPadding, + y: (1 - point.normalizedY) * viewInfo.viewportHeight, + })); + + const firstX = screenPoints[0].x; + const lastX = screenPoints[screenPoints.length - 1].x; + if (firstX >= viewInfo.viewportWidth || lastX <= 0) { + continue; + } + + if (this.showArea) { + this.area.beginFill(colorToHex(this.color), this.areaAlpha); + this.area.moveTo(screenPoints[0].x, viewInfo.viewportHeight); + for (const p of screenPoints) { + this.area.lineTo(p.x, p.y); + } + this.area.lineTo( + screenPoints[screenPoints.length - 1].x, + viewInfo.viewportHeight, + ); + this.area.endFill(); + } + + if (this.dashed) { + const dashLength = 6; + const gapLength = 4; + let drawing = true; + let remaining = dashLength; + + this.line.moveTo(screenPoints[0].x, screenPoints[0].y); + for (let i = 1; i < screenPoints.length; i++) { + let x0 = screenPoints[i - 1].x; + let y0 = screenPoints[i - 1].y; + const x1 = screenPoints[i].x; + const y1 = screenPoints[i].y; + let segLen = Math.hypot(x1 - x0, y1 - y0); + while (segLen > 0.0001) { + const step = Math.min(segLen, remaining); + const t = step / segLen; + const nx = x0 + (x1 - x0) * t; + const ny = y0 + (y1 - y0) * t; + + if (drawing) { + this.line.lineTo(nx, ny); + } else { + this.line.moveTo(nx, ny); + } + + segLen -= step; + remaining -= step; + x0 = nx; + y0 = ny; + + if (drawing && remaining <= 0) { + drawing = false; + remaining = gapLength; + } else if (!drawing && remaining <= 0) { + drawing = true; + remaining = dashLength; + } + } + } + } else { + this.line.moveTo(screenPoints[0].x, screenPoints[0].y); + for (let i = 1; i < screenPoints.length; i++) { + this.line.lineTo(screenPoints[i].x, screenPoints[i].y); + } + } + } + } + + destroy() { + this.area.destroy(); + this.line.destroy(); + this.container.destroy(); + } +} diff --git a/src/sing/parameterPanelStateMachine/common.ts b/src/sing/parameterPanelStateMachine/common.ts index 13d4c9e224..5b3bc39767 100644 --- a/src/sing/parameterPanelStateMachine/common.ts +++ b/src/sing/parameterPanelStateMachine/common.ts @@ -43,6 +43,7 @@ export type ParameterPanelComputedRefs = { readonly tpqn: ComputedRef; readonly zoomX: ComputedRef; readonly zoomY: ComputedRef; + readonly nowPlaying: ComputedRef; }; export type ParameterPanelPartialStore = { @@ -54,6 +55,7 @@ export type ParameterPanelPartialStore = { | "sequencerZoomY" | "sequencerVolumeTool" | "parameterPanelEditTarget" + | "nowPlaying" >; readonly getters: Pick< Store["getters"], diff --git a/src/sing/parameterPanelStateMachine/states/drawVolumeState.ts b/src/sing/parameterPanelStateMachine/states/drawVolumeState.ts index b2426cac30..e6c2126ae9 100644 --- a/src/sing/parameterPanelStateMachine/states/drawVolumeState.ts +++ b/src/sing/parameterPanelStateMachine/states/drawVolumeState.ts @@ -172,9 +172,6 @@ export class DrawVolumeState // まずはUIが動くようにのみする const cursorFrame = this.currentCursorPos.frame; const cursorValue = this.currentCursorPos.value; - if (cursorFrame < 0) { - return; - } const temp = { ...context.previewVolumeEdit.value, diff --git a/src/sing/parameterPanelStateMachine/states/eraseVolumeState.ts b/src/sing/parameterPanelStateMachine/states/eraseVolumeState.ts index 3776a9c125..cd1b47a28c 100644 --- a/src/sing/parameterPanelStateMachine/states/eraseVolumeState.ts +++ b/src/sing/parameterPanelStateMachine/states/eraseVolumeState.ts @@ -149,7 +149,7 @@ export class EraseVolumeState throw new Error("previewVolumeEdit.value.type is not erase."); } - const cursorFrame = Math.max(0, this.currentCursorPos.frame); + const cursorFrame = this.currentCursorPos.frame; const temp = { ...context.previewVolumeEdit.value }; // 開始フレームがカーソルフレームより後ろの場合は、カーソルフレームまでの長さを追加する diff --git a/src/styles/v2/cursor.scss b/src/styles/v2/cursor.scss index ccf9027d5e..9a54a89b3a 100644 --- a/src/styles/v2/cursor.scss +++ b/src/styles/v2/cursor.scss @@ -23,3 +23,7 @@ url("/draw-cursor.png") 2 2, auto; } + +.cursor-erase { + cursor: crosshair; // TODO: 削除用消しゴム的カーソルが現状ないため、用意するまではcrosshair +} diff --git a/src/type/preload.ts b/src/type/preload.ts index f535d9ffe4..712eca253f 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -383,6 +383,7 @@ export const splitterPositionSchema = z.object({ portraitPaneWidth: z.number().optional(), audioInfoPaneWidth: z.number().optional(), audioDetailPaneHeight: z.number().optional(), + parameterPanelHeight: z.number().optional(), }); export type SplitterPositionType = z.infer;