mock: 長押しにループ方式とリスト方式を入れ、ツールバーで切り替える(T-30 単位7b)
どちらも押した位置に円形インジケータを、少し離して選択先のリストを出す。 間隔は600msで始まり1000msごとに進む(500ms/450msは早いという所感への対応)。 リスト方式は、ポインタがリストに入ると巡回を止め、離したときに指している行で確定する。 リストへ向かう動きをドラッグと取り違えないよう、リスト方式では長押し後のドラッグをしない。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShoEXYqi8PWTjB9E86Ltot
This commit is contained in:
+42
-2
@@ -201,7 +201,7 @@ test("長押しでクラスターを選び、ダブルクリックでフォー
|
|||||||
const b = await key.boundingBox();
|
const b = await key.boundingBox();
|
||||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 });
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
@@ -588,7 +588,7 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ
|
|||||||
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
|
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
|
||||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await expect(page.locator(".long-label")).toContainText("2/", { timeout: 1800 });
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 1800 });
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -597,6 +597,46 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ
|
|||||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("リスト方式では、リストに入ると巡回が止まり、指した行で離すと確定する", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
await page.getByRole("button", { name: "長押し: ループ" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "長押し: リスト" })).toBeVisible();
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
const keyId = await key.getAttribute("data-node-id");
|
||||||
|
const b = await key.boundingBox();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||||
|
const row = await page.locator(".hold-row", { hasText: "キー" }).boundingBox();
|
||||||
|
await page.mouse.move(row!.x + row!.width / 2, row!.y + row!.height / 2, { steps: 5 });
|
||||||
|
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||||
|
await expect(page.locator(".hold-ring")).toHaveCount(0);
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||||
|
await expect(page.locator(`[data-sel-id="${keyId}"]`)).toBeVisible();
|
||||||
|
// リストへ動かしてもキーはドラッグされない
|
||||||
|
expect(await key.boundingBox()).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ループ方式では、長押しのあと動かすと選んだ先をドラッグする", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
const keys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await keys.nth(1).boundingBox();
|
||||||
|
const b = (await keys.first().boundingBox())!;
|
||||||
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-ring")).toBeVisible({ timeout: 2000 });
|
||||||
|
await page.mouse.move(b.x + b.width / 2 + 80, b.y + b.height / 2, { steps: 5 });
|
||||||
|
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
// クラスターごと動くので、押していないキーも動く
|
||||||
|
expect((await keys.nth(1).boundingBox())!.x).toBeGreaterThan(before!.x + 40);
|
||||||
|
});
|
||||||
|
|
||||||
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
|
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
|
||||||
const select = page.getByRole("combobox", { name: "部品を足す" });
|
const select = page.getByRole("combobox", { name: "部品を足す" });
|
||||||
await select.selectOption("その他");
|
await select.selectOption("その他");
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import PhySelectionLayer from "./PhySelectionLayer.vue";
|
|||||||
import PhyBadgeLayer from "./PhyBadgeLayer.vue";
|
import PhyBadgeLayer from "./PhyBadgeLayer.vue";
|
||||||
import PhyToolbar from "./PhyToolbar.vue";
|
import PhyToolbar from "./PhyToolbar.vue";
|
||||||
import PhyProperties from "./PhyProperties.vue";
|
import PhyProperties from "./PhyProperties.vue";
|
||||||
|
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
|
||||||
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
|
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
|
||||||
import { usePhyClipboard } from "./usePhyClipboard";
|
import { usePhyClipboard } from "./usePhyClipboard";
|
||||||
import { usePhyViewport } from "./usePhyViewport";
|
import { usePhyViewport } from "./usePhyViewport";
|
||||||
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
|
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
|
||||||
|
import { HOLD_DELAY, HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
|
||||||
import { usePhyKeyboard } from "./usePhyKeyboard";
|
import { usePhyKeyboard } from "./usePhyKeyboard";
|
||||||
import { usePhyCommands } from "./usePhyCommands";
|
import { usePhyCommands } from "./usePhyCommands";
|
||||||
import { usePhyCanvasView } from "./usePhyCanvasView";
|
import { usePhyCanvasView } from "./usePhyCanvasView";
|
||||||
@@ -217,6 +219,7 @@ function visibleIds(): Set<string> {
|
|||||||
if (!focus.value) return new Set(allNodes().map((n) => n.id));
|
if (!focus.value) return new Set(allNodes().map((n) => n.id));
|
||||||
return visibleNodeIds(node(focus.value), variants.value);
|
return visibleNodeIds(node(focus.value), variants.value);
|
||||||
}
|
}
|
||||||
|
const holdMode = ref<HoldMode>("loop");
|
||||||
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
|
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||||
const pointer = usePhyCanvasPointer({
|
const pointer = usePhyCanvasPointer({
|
||||||
project: () => props.project,
|
project: () => props.project,
|
||||||
@@ -236,12 +239,13 @@ const pointer = usePhyCanvasPointer({
|
|||||||
isAncestor,
|
isAncestor,
|
||||||
parentLabel,
|
parentLabel,
|
||||||
pressTargets,
|
pressTargets,
|
||||||
|
holdMode,
|
||||||
setSelection: setSel,
|
setSelection: setSel,
|
||||||
beginHistory: props.beginHistory,
|
beginHistory: props.beginHistory,
|
||||||
commitHistory: props.commitHistory,
|
commitHistory: props.commitHistory,
|
||||||
dispatch,
|
dispatch,
|
||||||
});
|
});
|
||||||
const { canvasDown, down, spaceUp, longLabel, longLabelPos, spaceHeld, holdCandidates, holdIndex } = pointer;
|
const { canvasDown, down, spaceUp, hold, spaceHeld } = pointer;
|
||||||
const keyHandler = usePhyKeyboard({
|
const keyHandler = usePhyKeyboard({
|
||||||
project: () => props.project,
|
project: () => props.project,
|
||||||
mismatch: () => props.mismatch,
|
mismatch: () => props.mismatch,
|
||||||
@@ -328,14 +332,18 @@ function onFocus(_id: string) {
|
|||||||
@tree="treeOpen = !treeOpen"
|
@tree="treeOpen = !treeOpen"
|
||||||
@help="showHelp = true"
|
@help="showHelp = true"
|
||||||
@focus="focus = $event"
|
@focus="focus = $event"
|
||||||
|
:hold-mode="holdMode"
|
||||||
|
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
|
||||||
|
/>
|
||||||
|
<PhyHoldOverlay
|
||||||
|
v-if="hold.open.value"
|
||||||
|
:mode="hold.mode.value"
|
||||||
|
:pos="hold.pos.value"
|
||||||
|
:names="hold.names.value"
|
||||||
|
:index="hold.index.value"
|
||||||
|
:step="hold.step.value"
|
||||||
|
:pointing="hold.pointing.value"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-if="longLabel"
|
|
||||||
class="long-label"
|
|
||||||
:style="{ left: longLabelPos.x + 'px', top: longLabelPos.y + 'px' }"
|
|
||||||
>
|
|
||||||
{{ longLabel }}
|
|
||||||
</div>
|
|
||||||
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
|
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
|
||||||
<svg
|
<svg
|
||||||
ref="svg"
|
ref="svg"
|
||||||
@@ -391,7 +399,7 @@ function onFocus(_id: string) {
|
|||||||
:selected="selected"
|
:selected="selected"
|
||||||
:variants="variants"
|
:variants="variants"
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
:candidate="hold.open.value ? (hold.candidates.value[hold.index.value] ?? null) : null"
|
||||||
:parent="focus ? focusParentMatrix(focus) : undefined"
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
/>
|
/>
|
||||||
<PhyBadgeLayer
|
<PhyBadgeLayer
|
||||||
@@ -456,7 +464,12 @@ function onFocus(_id: string) {
|
|||||||
フォーカス · F2 プロパティ
|
フォーカス · F2 プロパティ
|
||||||
</p>
|
</p>
|
||||||
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
|
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
|
||||||
<p>長押し: キー上で500ms待つと選択先候補が450msごとに進み、離すと確定します。</p>
|
<p>
|
||||||
|
長押し: キー上で{{ HOLD_DELAY }}ms待つと選択先候補が{{
|
||||||
|
HOLD_INTERVAL
|
||||||
|
}}msごとに進み、離すと確定します。
|
||||||
|
ループ方式では、そのまま動かすと選んだ先をドラッグします。リスト方式では、リストに入ると巡回が止まり、指している行で離すと確定します。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -595,16 +608,4 @@ function onFocus(_id: string) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #6b520f;
|
color: #6b520f;
|
||||||
}
|
}
|
||||||
.long-label {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 3;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
transform: translate(10px, 10px);
|
|
||||||
pointer-events: none;
|
|
||||||
background: #fff3d8;
|
|
||||||
border: 1px solid #c87d13;
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
|
||||||
|
defineProps<{
|
||||||
|
mode: HoldMode;
|
||||||
|
pos: { x: number; y: number };
|
||||||
|
names: string[];
|
||||||
|
index: number;
|
||||||
|
step: number;
|
||||||
|
pointing: boolean;
|
||||||
|
}>();
|
||||||
|
// 円の周長。インジケータは次の切り替えまでに一周する
|
||||||
|
const ring = 2 * Math.PI * 14;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
v-if="!pointing"
|
||||||
|
:key="step"
|
||||||
|
class="hold-ring"
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
:style="{ left: pos.x - 18 + 'px', top: pos.y - 18 + 'px' }"
|
||||||
|
>
|
||||||
|
<circle cx="18" cy="18" r="14" class="track" />
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="14"
|
||||||
|
class="progress"
|
||||||
|
:stroke-dasharray="ring"
|
||||||
|
:style="{ '--ring': ring, animationDuration: HOLD_INTERVAL + 'ms' }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<ol
|
||||||
|
class="hold-list"
|
||||||
|
:class="mode"
|
||||||
|
:data-hold-mode="mode"
|
||||||
|
:style="{ left: pos.x + 22 + 'px', top: pos.y + 10 + 'px' }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in names"
|
||||||
|
:key="i"
|
||||||
|
class="hold-row"
|
||||||
|
:class="{ active: i === index }"
|
||||||
|
:data-hold-index="i"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hold-ring {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.track,
|
||||||
|
.progress {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
stroke: rgb(200 125 19 / 25%);
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
stroke: #c87d13;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: 18px 18px;
|
||||||
|
animation: hold-ring linear forwards;
|
||||||
|
}
|
||||||
|
@keyframes hold-ring {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: var(--ring);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hold-list {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
margin: 0;
|
||||||
|
padding: 3px 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff3d8;
|
||||||
|
border: 1px solid #c87d13;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.hold-list.loop {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hold-row {
|
||||||
|
padding: 3px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hold-row.active {
|
||||||
|
background: #c87d13;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ defineProps<{
|
|||||||
moveTargets: { id: string; label: string }[];
|
moveTargets: { id: string; label: string }[];
|
||||||
focus: string | null;
|
focus: string | null;
|
||||||
crumbs: { id: string | null; label: string }[];
|
crumbs: { id: string | null; label: string }[];
|
||||||
|
holdMode: "loop" | "list";
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
keyAdd: [];
|
keyAdd: [];
|
||||||
@@ -23,6 +24,7 @@ const emit = defineEmits<{
|
|||||||
tree: [];
|
tree: [];
|
||||||
help: [];
|
help: [];
|
||||||
focus: [id: string | null];
|
focus: [id: string | null];
|
||||||
|
holdMode: [];
|
||||||
}>();
|
}>();
|
||||||
function addPart(e: Event) {
|
function addPart(e: Event) {
|
||||||
const select = e.target as HTMLSelectElement;
|
const select = e.target as HTMLSelectElement;
|
||||||
@@ -94,6 +96,13 @@ function addPart(e: Event) {
|
|||||||
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
|
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
|
||||||
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
|
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
|
||||||
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
|
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
|
||||||
|
<button
|
||||||
|
:title="`長押しの方式: ${holdMode === 'loop' ? 'ループ' : 'リスト'}(押すと切り替え)`"
|
||||||
|
:aria-label="`長押し: ${holdMode === 'loop' ? 'ループ' : 'リスト'}`"
|
||||||
|
@click="emit('holdMode')"
|
||||||
|
>
|
||||||
|
{{ holdMode === "loop" ? "⟳" : "☰" }}
|
||||||
|
</button>
|
||||||
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
|
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
|
||||||
<span v-if="focus">
|
<span v-if="focus">
|
||||||
<template v-for="(crumb, i) in crumbs" :key="i">
|
<template v-for="(crumb, i) in crumbs" :key="i">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { KeyNode, Node } from "../../domain/phy";
|
|||||||
import { apply, worldTransform } from "../../domain/geometry";
|
import { apply, worldTransform } from "../../domain/geometry";
|
||||||
import { moveNode } from "../../domain/phyCommands";
|
import { moveNode } from "../../domain/phyCommands";
|
||||||
import { normalizeSelection, snap } from "./logic";
|
import { normalizeSelection, snap } from "./logic";
|
||||||
import { usePhyLongPress } from "./usePhyLongPress";
|
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
|
||||||
import { autoPanVelocity } from "./viewport";
|
import { autoPanVelocity } from "./viewport";
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
type Point = { x: number; y: number };
|
||||||
@@ -27,6 +27,7 @@ type Options = {
|
|||||||
isAncestor: (candidate: string, id: string) => boolean;
|
isAncestor: (candidate: string, id: string) => boolean;
|
||||||
parentLabel: (id: string | null) => string;
|
parentLabel: (id: string | null) => string;
|
||||||
pressTargets: (id: string) => string[];
|
pressTargets: (id: string) => string[];
|
||||||
|
holdMode: Ref<HoldMode>;
|
||||||
setSelection: (id: string, add?: boolean) => void;
|
setSelection: (id: string, add?: boolean) => void;
|
||||||
beginHistory: () => void;
|
beginHistory: () => void;
|
||||||
commitHistory: () => void;
|
commitHistory: () => void;
|
||||||
@@ -34,7 +35,7 @@ type Options = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function usePhyCanvasPointer(options: Options) {
|
export function usePhyCanvasPointer(options: Options) {
|
||||||
const longPress = usePhyLongPress(options);
|
const longPress = usePhyLongPress({ ...options, mode: () => options.holdMode.value });
|
||||||
const spaceHeld = ref(false);
|
const spaceHeld = ref(false);
|
||||||
let preClickSelection: string[] = [];
|
let preClickSelection: string[] = [];
|
||||||
let preClickTarget: string | null = null;
|
let preClickTarget: string | null = null;
|
||||||
@@ -238,6 +239,10 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
}
|
}
|
||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
lastPointer = { x: event.clientX, y: event.clientY };
|
lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
|
if (longPress.picking()) {
|
||||||
|
longPress.point(event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = dragState;
|
const state = dragState;
|
||||||
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
||||||
longPress.cancelPending();
|
longPress.cancelPending();
|
||||||
@@ -373,11 +378,17 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
move,
|
move,
|
||||||
up,
|
up,
|
||||||
spaceUp,
|
spaceUp,
|
||||||
longLabel: longPress.label,
|
hold: {
|
||||||
longLabelPos: longPress.labelPos,
|
open: longPress.open,
|
||||||
|
mode: longPress.mode,
|
||||||
|
pos: longPress.pos,
|
||||||
|
candidates: longPress.candidates,
|
||||||
|
names: longPress.names,
|
||||||
|
index: longPress.index,
|
||||||
|
step: longPress.step,
|
||||||
|
pointing: longPress.pointing,
|
||||||
|
},
|
||||||
spaceHeld,
|
spaceHeld,
|
||||||
holdCandidates: longPress.candidates,
|
|
||||||
holdIndex: longPress.index,
|
|
||||||
priorSelection,
|
priorSelection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export type HoldMode = "loop" | "list";
|
||||||
|
|
||||||
|
// feedback「長押しでの選択の切り替え」で 500ms / 450ms は早いとされたため延ばす。
|
||||||
|
export const HOLD_DELAY = 600;
|
||||||
|
export const HOLD_INTERVAL = 1000;
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
parentLabel: (id: string | null) => string;
|
parentLabel: (id: string | null) => string;
|
||||||
pressTargets: (id: string) => string[];
|
pressTargets: (id: string) => string[];
|
||||||
|
mode: () => HoldMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LongPressPick = { id: string; shift: boolean };
|
export type LongPressPick = { id: string; shift: boolean };
|
||||||
|
|
||||||
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
|
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
|
||||||
|
// loop: 巡回だけで選び、長押し後に動かすと選んだ先をドラッグする。
|
||||||
|
// list: 巡回は同じだが、ポインタがリストに入ると巡回をやめ、指している行を離した時点で選ぶ。ドラッグはしない。
|
||||||
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
|
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
|
||||||
export function usePhyLongPress(options: Options) {
|
export function usePhyLongPress(options: Options) {
|
||||||
const label = ref("");
|
const open = ref(false);
|
||||||
const labelPos = ref({ x: 0, y: 0 });
|
const mode = ref<HoldMode>("loop");
|
||||||
|
const pos = ref({ x: 0, y: 0 });
|
||||||
const candidates = ref<string[]>([]);
|
const candidates = ref<string[]>([]);
|
||||||
|
const names = ref<string[]>([]);
|
||||||
const index = ref(0);
|
const index = ref(0);
|
||||||
|
// 巡回が進むたびに増やし、インジケータのアニメーションをやり直させる
|
||||||
|
const step = ref(0);
|
||||||
|
// list 方式で、ポインタがリストに入ったあと
|
||||||
|
const pointing = ref(false);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
let cycle: number | undefined;
|
let cycle: number | undefined;
|
||||||
let shift = false;
|
let shift = false;
|
||||||
@@ -23,31 +38,54 @@ export function usePhyLongPress(options: Options) {
|
|||||||
activated = false;
|
activated = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopCycle() {
|
||||||
|
if (cycle) window.clearInterval(cycle);
|
||||||
|
cycle = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function start(event: PointerEvent, keyId: string) {
|
function start(event: PointerEvent, keyId: string) {
|
||||||
candidates.value = options.pressTargets(keyId);
|
candidates.value = options.pressTargets(keyId);
|
||||||
index.value = 0;
|
index.value = 0;
|
||||||
shift = event.shiftKey;
|
shift = event.shiftKey;
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
|
timer = undefined;
|
||||||
if (candidates.value.length <= 1) return;
|
if (candidates.value.length <= 1) return;
|
||||||
activated = true;
|
activated = true;
|
||||||
|
mode.value = options.mode();
|
||||||
|
names.value = candidates.value.map((id, i) => (i === 0 ? "キー" : options.parentLabel(id)));
|
||||||
index.value = 1;
|
index.value = 1;
|
||||||
labelPos.value = { x: event.clientX, y: event.clientY };
|
step.value++;
|
||||||
label.value = `${options.parentLabel(candidates.value[1]!)} 2/${candidates.value.length}`;
|
pointing.value = false;
|
||||||
|
pos.value = { x: event.clientX, y: event.clientY };
|
||||||
|
open.value = true;
|
||||||
cycle = window.setInterval(() => {
|
cycle = window.setInterval(() => {
|
||||||
index.value = (index.value + 1) % candidates.value.length;
|
index.value = (index.value + 1) % candidates.value.length;
|
||||||
const id = candidates.value[index.value]!;
|
step.value++;
|
||||||
const name = index.value === 0 ? "キー" : options.parentLabel(id);
|
}, HOLD_INTERVAL);
|
||||||
label.value = `${name} ${index.value + 1}/${candidates.value.length}`;
|
}, HOLD_DELAY);
|
||||||
}, 450);
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycling() {
|
function cycling() {
|
||||||
return label.value !== "";
|
return open.value && mode.value === "loop";
|
||||||
|
}
|
||||||
|
|
||||||
|
function picking() {
|
||||||
|
return open.value && mode.value === "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 方式: ポインタの下にあるリストの行を指す。リストの外では直前の状態を保つ。
|
||||||
|
function point(clientX: number, clientY: number) {
|
||||||
|
const row = document
|
||||||
|
.elementsFromPoint(clientX, clientY)
|
||||||
|
.find((element) => element instanceof HTMLElement && element.dataset.holdIndex !== undefined);
|
||||||
|
if (!(row instanceof HTMLElement)) return;
|
||||||
|
stopCycle();
|
||||||
|
pointing.value = true;
|
||||||
|
index.value = Number(row.dataset.holdIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelPending() {
|
function cancelPending() {
|
||||||
if (timer && !label.value) {
|
if (timer && !open.value) {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
timer = undefined;
|
timer = undefined;
|
||||||
}
|
}
|
||||||
@@ -55,16 +93,17 @@ export function usePhyLongPress(options: Options) {
|
|||||||
|
|
||||||
function take(): LongPressPick {
|
function take(): LongPressPick {
|
||||||
const id = candidates.value[index.value]!;
|
const id = candidates.value[index.value]!;
|
||||||
if (cycle) window.clearInterval(cycle);
|
stopCycle();
|
||||||
label.value = "";
|
open.value = false;
|
||||||
return { id, shift };
|
return { id, shift };
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish(): LongPressPick | null {
|
function finish(): LongPressPick | null {
|
||||||
if (timer) window.clearTimeout(timer);
|
if (timer) window.clearTimeout(timer);
|
||||||
if (cycle) window.clearInterval(cycle);
|
timer = undefined;
|
||||||
const id = label.value ? candidates.value[index.value] : undefined;
|
stopCycle();
|
||||||
label.value = "";
|
const id = open.value ? candidates.value[index.value] : undefined;
|
||||||
|
open.value = false;
|
||||||
return id ? { id, shift } : null;
|
return id ? { id, shift } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +112,19 @@ export function usePhyLongPress(options: Options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
open,
|
||||||
labelPos,
|
mode,
|
||||||
|
pos,
|
||||||
candidates,
|
candidates,
|
||||||
|
names,
|
||||||
index,
|
index,
|
||||||
|
step,
|
||||||
|
pointing,
|
||||||
reset,
|
reset,
|
||||||
start,
|
start,
|
||||||
cycling,
|
cycling,
|
||||||
|
picking,
|
||||||
|
point,
|
||||||
cancelPending,
|
cancelPending,
|
||||||
take,
|
take,
|
||||||
finish,
|
finish,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ T-29 のコードを作り直さず、[phy-editor-feedback.md](phy-editor-feedba
|
|||||||
| 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 |
|
| 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 |
|
||||||
| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。クラスターを選んでいればその中に、バリアントグループを選んでいれば表示中のバリアントの中に、同じように新しい行を作る。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` |
|
| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。クラスターを選んでいればその中に、バリアントグループを選んでいれば表示中のバリアントの中に、同じように新しい行を作る。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` |
|
||||||
| 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` |
|
| 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` |
|
||||||
| 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、切り替えて比べる | 7aの composable |
|
| 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、ツールバーで切り替えて比べる。間隔は、押してから600msで始まり1000msごとに進む。リスト方式は、押した位置から少し離してリストを出し、ポインタがリストに入ったら巡回を止め、離したときに指している行で確定する。リストに入らずに離せば、そのときの巡回先で確定する。ループ方式は長押しのあと動かすと選んだ先をドラッグし、リスト方式はドラッグしない | 7aの composable |
|
||||||
| 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄を置く | 1の層、`PhyProperties.vue` |
|
| 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄を置く | 1の層、`PhyProperties.vue` |
|
||||||
|
|
||||||
順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。
|
順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。
|
||||||
@@ -94,7 +94,6 @@ T-29 のコードを作り直さず、[phy-editor-feedback.md](phy-editor-feedba
|
|||||||
|
|
||||||
着手前に決めること:
|
着手前に決めること:
|
||||||
|
|
||||||
- 7b: 長押しの間隔の値。リスト方式でどう選ぶか(離した位置で決めるのか、リストを出したあとにクリックするのか)。
|
|
||||||
- 8: スタビライザーを付けるキー幅と、幅ごとの位置。これは事実なので、出典で裏取りしてから決める。推測で実装しない。
|
- 8: スタビライザーを付けるキー幅と、幅ごとの位置。これは事実なので、出典で裏取りしてから決める。推測で実装しない。
|
||||||
|
|
||||||
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
||||||
|
|||||||
Reference in New Issue
Block a user