akari-video 1.2.0-beta.5 → 1.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0-beta.5",
3
+ "version": "1.3.0-beta.1",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/kits.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import path from 'node:path';
9
9
 
10
10
  import { resolveLauncherAssets } from './repo-assets.mjs';
11
+ import { compareVersions } from './version.mjs';
11
12
  const creatorRootModulePath = resolveLauncherAssets().creatorRootModulePath;
12
13
  const creatorRoot = creatorRootModulePath ? await import(pathToFileURL(creatorRootModulePath).href) : null;
13
14
  function resolveAssetLibraryRoots(env) {
@@ -20,15 +21,11 @@ const INSTALLED_ASSETS_SCHEMA = 'akari-installed-assets/v0';
20
21
  const PLUGIN_DESCRIPTION = 'AKARI Video 拡張キットのスキルをまとめて提供するローカルプラグイン。';
21
22
 
22
23
  function parseVersion(value) {
23
- const match = String(value).match(/^(\d+)\.(\d+)\.(\d+)$/u);
24
- return match ? match.slice(1).map(Number) : null;
25
- }
26
-
27
- function compareVersions(left, right) {
28
- for (let index = 0; index < 3; index += 1) {
29
- if (left[index] !== right[index]) return left[index] - right[index];
30
- }
31
- return 0;
24
+ const match = typeof value === 'string'
25
+ ? value.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/u)
26
+ : null;
27
+ if (!match || match[4]?.split('.').some(id => /^0\d+$/u.test(id))) return null;
28
+ return match.slice(1, 4).map(Number);
32
29
  }
33
30
 
34
31
  function satisfies(version, range) {
@@ -36,13 +33,14 @@ function satisfies(version, range) {
36
33
  const match = String(range).match(/^(\^|~|>=)?(\d+\.\d+\.\d+)$/u);
37
34
  if (!actual || !match) return false;
38
35
  const required = parseVersion(match[2]);
39
- if (compareVersions(actual, required) < 0) return false;
36
+ if (!required) return false;
37
+ if (compareVersions(version, match[2]) < 0) return false;
40
38
  if (match[1] === '^') {
41
39
  return actual[0] === required[0] && (required[0] !== 0 || actual[1] === required[1]);
42
40
  }
43
41
  if (match[1] === '~') return actual[0] === required[0] && actual[1] === required[1];
44
42
  if (match[1] === '>=') return true;
45
- return compareVersions(actual, required) === 0;
43
+ return compareVersions(version, match[2]) === 0;
46
44
  }
47
45
 
48
46
  export function readKitManifest(kitDir) {
@@ -14,6 +14,8 @@ export const VENDOR_SOURCES = [
14
14
  'presets/textanim',
15
15
  'assets/font/noto-sans-jp/NotoSansJP-Variable.ttf',
16
16
  'packages/schemas',
17
+ // schemas の frame-scene 検証器が参照する schema 定義だけを同梱する。
18
+ 'packages/frame-scene/src/schema.mjs',
17
19
  // schemas の asset / kit 検証は runtimes.mjs を実行し、runtime 宣言は src の
18
20
  // レジストリ・ブラウザスクリプトと既定フォントをファイルとして読み込む。
19
21
  'packages/overlay-runtime/runtimes.mjs',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0-beta.5",
3
+ "version": "1.3.0-beta.1",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,102 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import schema from '../../schemas/frame-scene.schema.json' with { type: 'json' };
3
+
4
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
5
+ const check = ajv.compile(schema);
6
+ const JOINTS = new Set('hips spine chest neck head leftUpperArm leftLowerArm leftHand rightUpperArm rightLowerArm rightHand leftUpperLeg leftLowerLeg leftFoot rightUpperLeg rightLowerLeg rightFoot'.split(' '));
7
+ const known = {
8
+ '': schema.properties, '/world': schema.$defs.world.properties,
9
+ '/objects/*': schema.$defs.object.properties, '/scenes/*': schema.$defs.scene.properties,
10
+ '/scenes/*/camera': schema.$defs.camera.properties, '/scenes/*/camera_to': schema.$defs.camera.properties,
11
+ '/scenes/*/world': schema.$defs.world.properties,
12
+ '/scenes/*/set/*': schema.$defs.setObject.properties,
13
+ '/scenes/*/annotations/*': schema.$defs.annotation.properties,
14
+ '/scenes/*/annotations/*/anchor': schema.$defs.annotation.properties.anchor.properties,
15
+ '/objects/*/pose': schema.$defs.pose.oneOf[1].properties,
16
+ '/scenes/*/set/*/pose': schema.$defs.pose.oneOf[1].properties,
17
+ '/objects/*/pose/joints/*': { x: true, y: true, z: true },
18
+ '/scenes/*/set/*/pose/joints/*': { x: true, y: true, z: true },
19
+ '/objects/*/screen': schema.$defs.object.properties.screen.properties,
20
+ '/scenes/*/set/*/screen': schema.$defs.object.properties.screen.properties
21
+ };
22
+ const ptr = (base, key) => `${base}/${String(key).replaceAll('~', '~0').replaceAll('/', '~1')}`;
23
+ const issue = (code, path, message) => ({ code, path, message });
24
+
25
+ export function validateFrameScene(doc, { strict = false, fileBytes } = {}) {
26
+ const errors = [], warnings = [];
27
+ if (doc && typeof doc === 'object' && Number.isInteger(doc.version) && doc.version > 0) {
28
+ return { ok: false, errors: [issue('unsupported_version', '/version', `version ${doc.version} is unsupported`)], warnings };
29
+ }
30
+ if ((fileBytes ?? Buffer.byteLength(JSON.stringify(doc) ?? '')) > 512 * 1024) warnings.push(issue('limit.file', '', 'file exceeds 512KB'));
31
+ if (!check(doc)) for (const e of check.errors ?? []) {
32
+ const path = e.keyword === 'required' ? ptr(e.instancePath, e.params.missingProperty) : e.instancePath;
33
+ errors.push(issue(e.keyword === 'minItems' && e.instancePath === '/scenes' ? 'scene.empty' : `schema.${e.keyword.toLowerCase()}`, path, e.message ?? e.keyword));
34
+ }
35
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errors, warnings };
36
+ const objects = Array.isArray(doc.objects) ? doc.objects : [];
37
+ const scenes = Array.isArray(doc.scenes) ? doc.scenes : [];
38
+ if (objects.length > 200) warnings.push(issue('limit.objects', '/objects', 'objects exceeds 200'));
39
+ if (scenes.length > 50) warnings.push(issue('limit.scenes', '/scenes', 'scenes exceeds 50'));
40
+ const ids = new Set(), sceneIds = new Set();
41
+ const ref = (value, path, code = 'object.reference.unresolved') => {
42
+ if (typeof value === 'string' && !ids.has(value)) errors.push(issue(code, path, `unknown object ${value}`));
43
+ };
44
+ for (const [i, o] of objects.entries()) {
45
+ if (!o || typeof o !== 'object') continue;
46
+ const p = `/objects/${i}`;
47
+ if (typeof o.id === 'string') {
48
+ if (!/^[a-z0-9_-]+$/.test(o.id)) errors.push(issue('object.id.invalid', `${p}/id`, 'id must use [a-z0-9_-]'));
49
+ if (ids.has(o.id)) errors.push(issue('object.id.duplicate', `${p}/id`, 'duplicate object id'));
50
+ ids.add(o.id);
51
+ }
52
+ }
53
+ function inspectObject(o, p) {
54
+ if (!o || typeof o !== 'object') return;
55
+ if (o.facing !== undefined && (o.ry !== undefined || o.rot !== undefined)) errors.push(issue('object.rotation.exclusive', p, 'facing and ry/rot are exclusive'));
56
+ if (o.ry !== undefined && o.rot !== undefined) errors.push(issue('object.rotation.exclusive', p, 'ry and rot are exclusive'));
57
+ if (o.h !== undefined && o.scale !== undefined) errors.push(issue('object.size.exclusive', p, 'h and scale are exclusive'));
58
+ if (o.pose !== undefined && o.kind !== 'human') errors.push(issue('pose.kind.invalid', `${p}/pose`, 'pose requires human'));
59
+ if (o.on !== undefined) ref(o.on, `${p}/on`, 'on.unresolved');
60
+ if (typeof o.facing === 'string' && o.facing !== 'camera') ref(o.facing, `${p}/facing`, 'facing.unresolved');
61
+ if (o.screen_of !== undefined) ref(o.screen_of, `${p}/screen_of`);
62
+ const pose = o.pose;
63
+ if (pose && typeof pose === 'object' && !Array.isArray(pose)) {
64
+ for (const joint of Object.keys(pose.joints ?? {})) if (!JOINTS.has(joint)) warnings.push(issue('pose.joint.unknown', `${p}/pose/joints/${joint}`, `unknown joint ${joint}`));
65
+ for (const key of ['look', 'point_at']) if (typeof pose[key] === 'string') ref(pose[key], `${p}/pose/${key}`, 'pose.target.unresolved');
66
+ }
67
+ }
68
+ objects.forEach((o, i) => inspectObject(o, `/objects/${i}`));
69
+ for (const [i, scene] of scenes.entries()) {
70
+ if (!scene || typeof scene !== 'object') continue;
71
+ const p = `/scenes/${i}`;
72
+ if (typeof scene.id === 'string') {
73
+ if (!/^[a-z0-9_-]+$/.test(scene.id)) errors.push(issue('scene.id.invalid', `${p}/id`, 'invalid scene id'));
74
+ if (sceneIds.has(scene.id)) errors.push(issue('scene.id.duplicate', `${p}/id`, 'duplicate scene id'));
75
+ sceneIds.add(scene.id);
76
+ }
77
+ for (const key of ['camera', 'camera_to']) if (typeof scene[key]?.look === 'string') ref(scene[key].look, `${p}/${key}/look`, 'camera.look.unresolved');
78
+ for (const [id, delta] of Object.entries(scene.set ?? {})) {
79
+ if (!ids.has(id)) warnings.push(issue('set.unresolved', ptr(`${p}/set`, id), `unknown object ${id}`));
80
+ else inspectObject({ ...objects.find(o => o?.id === id), ...delta }, ptr(`${p}/set`, id));
81
+ }
82
+ const annotations = Array.isArray(scene.annotations) ? scene.annotations : [];
83
+ if (annotations.length > 100) warnings.push(issue('limit.annotations', `${p}/annotations`, 'annotations exceeds 100'));
84
+ annotations.forEach((a, j) => {
85
+ if (a?.anchor?.object !== undefined) ref(a.anchor.object, `${p}/annotations/${j}/anchor/object`, 'anchor.unresolved');
86
+ if (a?.frame) for (const [key, value] of Object.entries(a.frame)) {
87
+ if (['at', 'from', 'to'].includes(key) && Array.isArray(value)) value.forEach((n, k) => { if (typeof n !== 'number' || n < 0 || n > 1) errors.push(issue('annotation.coordinate.invalid', `${p}/annotations/${j}/frame/${key}/${k}`, 'coordinate must be 0..1')); });
88
+ if (key === 'points' && Array.isArray(value)) value.flat().forEach((n, k) => { if (typeof n !== 'number' || n < 0 || n > 1) errors.push(issue('annotation.coordinate.invalid', `${p}/annotations/${j}/frame/points/${k}`, 'coordinate must be 0..1')); });
89
+ }
90
+ });
91
+ }
92
+ const walk = (value, path = '') => {
93
+ if (!value || typeof value !== 'object') return;
94
+ if (Array.isArray(value)) { value.forEach((v, i) => walk(v, `${path}/${i}`)); return; }
95
+ const normalized = path.replace(/\/\d+(?=\/|$)/g, '/*').replace(/\/set\/[^/]+/, '/set/*').replace(/\/joints\/[^/]+/, '/joints/*');
96
+ const props = normalized.includes('/annotations/*') && !normalized.endsWith('/anchor') ? undefined : known[normalized];
97
+ if (props) for (const key of Object.keys(value)) if (!(key in props)) (strict ? errors : warnings).push(issue('key.unknown', ptr(path, key), `unknown key ${key}`));
98
+ for (const [key, v] of Object.entries(value)) walk(v, ptr(path, key));
99
+ };
100
+ walk(doc);
101
+ return { ok: errors.length === 0, errors, warnings };
102
+ }
@@ -54,17 +54,25 @@ function textSpans(html, tags = []) {
54
54
  if (!frame?.inSlot || frame.isSlotRoot) tags.push('/' + closing[1].toLowerCase());
55
55
  } else if (opening) {
56
56
  const name = opening[1].toLowerCase();
57
- const parentInSlot = slotStack.length > 0 && slotStack[slotStack.length - 1].inSlot;
57
+ const parent = slotStack[slotStack.length - 1];
58
+ const parentInSlot = parent?.inSlot ?? false;
59
+ const namespace = name === 'svg' || name === 'math' ? name
60
+ : parent?.name === 'foreignobject' && parent.namespace === 'svg'
61
+ ? 'html' : (parent?.namespace ?? 'html');
62
+ const selfClosing = tag[0].endsWith('/>');
58
63
  if (!parentInSlot) tags.push(name);
59
- if ((name === 'style' || name === 'script') && !tag[0].endsWith('/>')) {
64
+ if ((name === 'style' || name === 'script') && !selfClosing) {
60
65
  const close = new RegExp(`</${name}\\s*>`, 'ig');
61
66
  close.lastIndex = cursor;
62
67
  const end = close.exec(html);
63
68
  if (!end) throw new Error(`${name} タグが閉じていません`);
64
69
  cursor = close.lastIndex;
65
- } else if (!VOID_ELEMENTS.has(name) && !tag[0].endsWith('/>')) {
70
+ } else if (selfClosing && namespace !== 'html' && name !== 'style' && name !== 'script') {
71
+ if (!parentInSlot) tags.push('/' + name);
72
+ } else if (!VOID_ELEMENTS.has(name) && !selfClosing) {
66
73
  const isSlotRoot = !parentInSlot && hasSlotAttribute(tag[0], opening[1]);
67
- slotStack.push({ inSlot: parentInSlot || isSlotRoot, isSlotRoot });
74
+ slotStack.push({ inSlot: parentInSlot || isSlotRoot, isSlotRoot,
75
+ name, namespace });
68
76
  }
69
77
  }
70
78
  continue;
@@ -85,8 +93,15 @@ export function patchFragmentSourceText(source, edited) {
85
93
  const afterTags = [];
86
94
  const before = textSpans(source, beforeTags);
87
95
  const after = textSpans(edited, afterTags);
88
- if (before.length !== after.length || beforeTags.join('\0') !== afterTags.join('\0')) {
89
- throw new Error('断片の構造が変わったため、元ソースの文字だけを安全に保存できません');
96
+ const refusal = '断片の構造が変わったため、元ソースの文字だけを安全に保存できません';
97
+ const tagIndex = beforeTags.findIndex((tag, index) => tag !== afterTags[index]);
98
+ if (tagIndex >= 0 || beforeTags.length !== afterTags.length) {
99
+ const index = tagIndex >= 0 ? tagIndex : Math.min(beforeTags.length, afterTags.length);
100
+ const label = tag => tag === undefined ? 'なし' : `<${tag}>`;
101
+ throw new Error(`${refusal}(最初の違い: ${index + 1} 番目・元 ${label(beforeTags[index])} / 編集後 ${label(afterTags[index])})`);
102
+ }
103
+ if (before.length !== after.length) {
104
+ throw new Error(`${refusal}(文字の区切りの数: 元 ${before.length} / 編集後 ${after.length})`);
90
105
  }
91
106
  let result = source;
92
107
  for (let index = before.length - 1; index >= 0; index--) {
@@ -340,6 +340,96 @@ function elementBoxCompanions(style, parentStyle, width, height) {
340
340
  // テキスト編集で断片 HTML を保存するときに作者の元指定へ戻せるよう、初回値だけ保持する。
341
341
  const hitPolicyOriginalPointerEvents = new WeakMap();
342
342
  const hitPolicyAppliedContainers = new WeakSet();
343
+ const hitPolicyTimes = new WeakMap();
344
+ const staleHitPolicyContainers = new Set();
345
+ const pausedHitPolicyContainers = new Set();
346
+ let pausedHitPolicyTimer = null;
347
+ let lastPointerHitPolicyRefresh = -Infinity;
348
+
349
+ function hitPolicyOperationBusy() {
350
+ return Boolean(activeEdit || activeDrag || activeResize || activeRotate || activeLine
351
+ || marqueeFrame || pointerOwner != null);
352
+ }
353
+
354
+ function schedulePausedHitPolicyRefresh(delay = 120) {
355
+ if (typeof setTimeout !== 'function') return;
356
+ if (pausedHitPolicyTimer !== null) clearTimeout(pausedHitPolicyTimer);
357
+ pausedHitPolicyTimer = setTimeout(flushPausedHitPolicies, delay);
358
+ }
359
+
360
+ // runtime は可視コンテナの時刻だけを記録する。CSS の読み取りはここでは行わない。
361
+ function markOverlayHitPolicyStale(container, localTimeMs, paused = false, reentered = false,
362
+ animationReady = null) {
363
+ if (!container) return;
364
+ let state = hitPolicyTimes.get(container);
365
+ if (!state) {
366
+ state = { current: localTimeMs, measured: null, paused: null };
367
+ hitPolicyTimes.set(container, state);
368
+ }
369
+ const timeChanged = state.current !== localTimeMs;
370
+ const enteredPause = paused && state.paused !== true;
371
+ state.current = localTimeMs;
372
+ state.paused = paused;
373
+ if (reentered) {
374
+ state.measured = null;
375
+ hitPolicyAppliedContainers.delete(container);
376
+ }
377
+ if (state.measured !== localTimeMs) staleHitPolicyContainers.add(container);
378
+ // pause()/currentTime の pending 補正はこの tick の測定より後に解決し得る。
379
+ // 暫定 apply が stale を消しても、停止後の一回は別の印で必ず残す。
380
+ if (paused && (reentered || timeChanged || enteredPause)) pausedHitPolicyContainers.add(container);
381
+ if (!paused) pausedHitPolicyContainers.delete(container);
382
+ if (paused && pausedHitPolicyContainers.size) {
383
+ // スクラブ中は連続 tick をまとめ、停止後にポインタ無しで確定する。
384
+ schedulePausedHitPolicyRefresh();
385
+ }
386
+ if (paused && (reentered || timeChanged || enteredPause) && animationReady?.then) {
387
+ const expectedState = state;
388
+ animationReady.then(() => {
389
+ if (hitPolicyTimes.get(container) !== expectedState || !state.paused
390
+ || state.current !== localTimeMs) return;
391
+ // ready 後の currentTime 補正が 120ms の測定より遅れた場合も追い付く。
392
+ const alreadyRefreshed = !pausedHitPolicyContainers.has(container);
393
+ pausedHitPolicyContainers.add(container);
394
+ schedulePausedHitPolicyRefresh(alreadyRefreshed ? 0 : 120);
395
+ });
396
+ }
397
+ }
398
+
399
+ function flushPausedHitPolicies() {
400
+ pausedHitPolicyTimer = null;
401
+ if (pausedHitPolicyContainers.size === 0) return;
402
+ if (hitPolicyOperationBusy()) {
403
+ // 編集・ジェスチャーが終わるまで印を保持する。
404
+ pausedHitPolicyTimer = setTimeout(flushPausedHitPolicies, 120);
405
+ return;
406
+ }
407
+ refreshStaleHitPolicies(true);
408
+ }
409
+
410
+ function refreshStaleHitPolicies(includePaused = false) {
411
+ if (hitPolicyOperationBusy()) return false;
412
+ let refreshed = false;
413
+ const candidates = includePaused ? pausedHitPolicyContainers : staleHitPolicyContainers;
414
+ for (const container of candidates) {
415
+ if (includePaused) pausedHitPolicyContainers.delete(container);
416
+ if (!container.isConnected || container.style.visibility === 'hidden') {
417
+ staleHitPolicyContainers.delete(container);
418
+ continue;
419
+ }
420
+ invalidateOverlayHitPolicy(container);
421
+ applyOverlayHitPolicy(container);
422
+ refreshed = true;
423
+ }
424
+ return refreshed;
425
+ }
426
+
427
+ function forgetOverlayHitPolicyTracking(container) {
428
+ staleHitPolicyContainers.delete(container);
429
+ pausedHitPolicyContainers.delete(container);
430
+ hitPolicyTimes.delete(container);
431
+ hitPolicyAppliedContainers.delete(container);
432
+ }
343
433
 
344
434
  // overlay_write は操作順を保存する。selftest は今回の書き込みをこの記録から待つ。
345
435
  let writeTail = Promise.resolve();
@@ -982,7 +1072,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
982
1072
 
983
1073
  // 全画面の外側コンテナと断片ルートは素通しにし、実際に背景・枠・影・文字・置換要素を
984
1074
  // 描く可視の子孫だけを拾う。data-akari-hit は最寄りの指定を配下へ継承し、機械判定より
985
- // 優先する。字幕が 1,000 件級でも全件を走査しないよう、runtime の可視化時に一度だけ呼ぶ。
1075
+ // 優先する。字幕が 1,000 件級でも全件を走査しないよう、runtime は可視コンテナの
1076
+ // 初回・停止後・ポインタ入力時だけ適用し、再生 tick では時刻の印だけを付ける。
986
1077
  function fragmentRootCoversContainer(element, container) {
987
1078
  const rootRect = element.getBoundingClientRect();
988
1079
  const containerRect = container.getBoundingClientRect();
@@ -1056,6 +1147,9 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1056
1147
 
1057
1148
  for (const root of container.children) visit(root, null, true, true);
1058
1149
  hitPolicyAppliedContainers.add(container);
1150
+ const state = hitPolicyTimes.get(container);
1151
+ if (state) state.measured = state.current;
1152
+ staleHitPolicyContainers.delete(container);
1059
1153
  updateShapeLineHitProxyWidths();
1060
1154
  }
1061
1155
 
@@ -1169,6 +1263,34 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1169
1263
  }
1170
1264
  }
1171
1265
 
1266
+ // 08-07 裁定: 背景は選択・削除できる。旧矩形フォールバックと違い、stage 面に
1267
+ // 落ちたクリックで、全面を覆って自ら塗るルートだけを拾う。実際のヒット先が
1268
+ // あるときは使わず 4680d90d8 の巻き込みを防ぎ、素通し規約は変えない。
1269
+ function paintedStageOverlayAt(clientX, clientY) {
1270
+ const candidates = [...stage.children]
1271
+ .flatMap((container, index) => {
1272
+ if (!isSelectable(container)) return [];
1273
+ const value = Number.parseFloat(getComputedStyle(container).zIndex);
1274
+ return [{ container, index, zIndex: Number.isFinite(value) ? value : 0 }];
1275
+ })
1276
+ .sort((a, b) => b.zIndex - a.zIndex || b.index - a.index);
1277
+
1278
+ for (const { container } of candidates) {
1279
+ for (const root of container.children) {
1280
+ if (root.hasAttribute("data-akari-interaction") || root.getAttribute("data-akari-hit") === "pass") continue;
1281
+ const style = getComputedStyle(root);
1282
+ if (style.display === "none" || Number(style.opacity) <= 0
1283
+ || ["hidden", "collapse"].includes(style.visibility)
1284
+ || !fragmentRootCoversContainer(root, container)
1285
+ || !drawsOwnContent(root, style)) continue;
1286
+ const rect = root.getBoundingClientRect();
1287
+ if (clientX >= rect.left && clientX <= rect.right
1288
+ && clientY >= rect.top && clientY <= rect.bottom) return container;
1289
+ }
1290
+ }
1291
+ return null;
1292
+ }
1293
+
1172
1294
  function overlayForEvent(event) {
1173
1295
  const eventTargetOverlay = findOverlayContainer(event.target);
1174
1296
  if (eventTargetOverlay && !isSelectable(eventTargetOverlay)) {
@@ -1180,6 +1302,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1180
1302
  ) {
1181
1303
  return selftestOverlayOverride;
1182
1304
  }
1305
+ if (event.target === stage && !eventTargetOverlay
1306
+ && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
1307
+ const paintedOverlay = paintedStageOverlayAt(event.clientX, event.clientY);
1308
+ if (paintedOverlay) return paintedOverlay;
1309
+ }
1183
1310
  return isSelectable(eventTargetOverlay) ? eventTargetOverlay : null;
1184
1311
  }
1185
1312
 
@@ -1722,50 +1849,143 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1722
1849
  Object.assign(selectionFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
1723
1850
  width: `${rect.width}px`, height: `${rect.height}px` });
1724
1851
  }
1852
+ let breadcrumbResizeObserver = null;
1853
+ let breadcrumbHoverActive = false;
1854
+ let breadcrumbHoverSuppressedAt = null;
1855
+ function clearBreadcrumbHover() {
1856
+ if (!breadcrumbHoverActive) return;
1857
+ hideHover();
1858
+ }
1859
+ function breadcrumbBounds(target) {
1860
+ if (target.kind === 'stage') return { rect: stage?.getBoundingClientRect() };
1861
+ if (target.kind === 'node') {
1862
+ const node = treeNode(target.id);
1863
+ const leaf = containerById(target.id);
1864
+ const lineRect = leaf && lineFrameGeometry(leaf);
1865
+ const rect = node?.kind !== 'leaf' ? unionBounds(visibleMembers(target.id))
1866
+ : lineRect ?? (leaf ? fragmentBounds(leaf) : null);
1867
+ return { rect, angle: lineRect?.angle ?? null };
1868
+ }
1869
+ if (target.kind === 'item') {
1870
+ const container = containerById(target.id);
1871
+ return { rect: container ? fragmentBounds(container) : null };
1872
+ }
1873
+ const element = target.element?.isConnected ? target.element
1874
+ : elementByAddress(fragmentRoot(containerById(target.overlayId)), target.ref);
1875
+ if (!element?.isConnected) return null;
1876
+ const geometry = elementScreenGeometry(element);
1877
+ const width = geometry.width * Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
1878
+ const height = geometry.height * Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
1879
+ return { rect: { left: geometry.center.x - width / 2, top: geometry.center.y - height / 2,
1880
+ width, height }, angle: Math.atan2(geometry.axes.x.y, geometry.axes.x.x) * 180 / Math.PI };
1881
+ }
1725
1882
  function renderScopeBreadcrumb() {
1726
1883
  const nav = document.querySelector('[data-akari-ui="preview-scope-breadcrumb"]');
1727
1884
  if (!nav) return;
1885
+ if (!breadcrumbResizeObserver && typeof ResizeObserver !== 'undefined' && nav.parentElement) {
1886
+ breadcrumbResizeObserver = new ResizeObserver(() => renderScopeBreadcrumb());
1887
+ breadcrumbResizeObserver.observe(nav.parentElement);
1888
+ }
1889
+ clearBreadcrumbHover();
1890
+ const entries = [];
1891
+ const append = (label, action, target) => entries.push({ label, action, target });
1728
1892
  if (selectedElementFocus() && selectedOverlay) {
1729
- nav.hidden = false;
1730
- nav.replaceChildren();
1731
- const append = (label, action) => {
1732
- if (nav.childNodes.length) nav.append(' › ');
1733
- const button = document.createElement('button');
1734
- button.type = 'button'; button.textContent = label;
1735
- button.addEventListener('click', action);
1736
- nav.appendChild(button);
1737
- };
1738
- append('全体', () => { clearSelection(); publishScopedSelection(); });
1893
+ append('全体', () => { clearSelection(); publishScopedSelection(); }, { kind: 'stage' });
1739
1894
  for (const id of lineage(selectionTree(), scopeId)) {
1740
- append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }));
1895
+ append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }),
1896
+ { kind: 'node', id });
1741
1897
  }
1742
1898
  const name = window.akari.state?.summary?.overlays?.find(overlay => overlay.id === selectedId)?.name ?? selectedId;
1743
- append(name, () => focusElement(null));
1899
+ append(name, () => focusElement(null), { kind: 'item', id: selectedId });
1744
1900
  const root = fragmentRoot(selectedOverlay);
1745
1901
  const path = [];
1746
1902
  for (let node = focusedElement(); node && node !== root; node = node.parentElement) {
1747
1903
  if (selectableElement(root, node, stage?.getBoundingClientRect())) path.unshift(node);
1748
1904
  }
1749
- for (const node of path) append(elementLabel(root, node), () => focusElement(node));
1750
- return;
1751
- }
1752
- nav.hidden = scopeId === floorScopeId || !selectionTree().length;
1753
- nav.replaceChildren();
1754
- if (nav.hidden) return;
1755
- const path = lineage(selectionTree(), scopeId);
1756
- const ids = floorScopeId === null ? [null, ...path] : path.slice(path.indexOf(floorScopeId));
1757
- ids.forEach((id, index) => {
1758
- if (index) nav.append(' › ');
1759
- const button = document.createElement('button');
1760
- button.type = 'button';
1761
- button.textContent = id === null ? '全体' : treeNode(id)?.label ?? id;
1762
- button.addEventListener('click', () => {
1905
+ for (const node of path) append(elementLabel(root, node), () => focusElement(node),
1906
+ { kind: 'element', element: node, overlayId: selectedId, ref: elementAddress(root, node) });
1907
+ } else if (scopeId !== floorScopeId && selectionTree().length) {
1908
+ const path = lineage(selectionTree(), scopeId);
1909
+ const ids = floorScopeId === null ? [null, ...path] : path.slice(path.indexOf(floorScopeId));
1910
+ ids.forEach(id => append(id === null ? '全体' : treeNode(id)?.label ?? id, () => {
1763
1911
  const hit = descendantLeafIds(selectionTree(), selectedId)[0] ?? selectedId;
1764
1912
  const next = resolveScopedSelection(selectionTree(), id, hit);
1765
1913
  applyScopedSelection({ scopeId: id, selectId: next.scopeId === id ? next.selectId : null });
1914
+ }, id === null ? { kind: 'stage' } : { kind: 'node', id }));
1915
+ }
1916
+ nav.replaceChildren();
1917
+ nav.hidden = entries.length === 0;
1918
+ if (nav.hidden) return;
1919
+ const separator = () => {
1920
+ const span = document.createElement('span');
1921
+ span.className = 'akari-breadcrumb-separator'; span.textContent = ' › ';
1922
+ return span;
1923
+ };
1924
+ const button = entry => {
1925
+ const node = document.createElement('button');
1926
+ node.type = 'button'; node.textContent = entry.label;
1927
+ const show = () => {
1928
+ let geometry;
1929
+ try { geometry = breadcrumbBounds(entry.target); } catch { return; }
1930
+ if (!geometry?.rect || geometry.rect.width <= 0 || geometry.rect.height <= 0) return;
1931
+ breadcrumbHoverActive = true;
1932
+ showHoverFrame(geometry.rect, geometry.angle ?? null, entry.target.id ?? '', true);
1933
+ };
1934
+ node.addEventListener('pointerenter', () => {
1935
+ if (!breadcrumbHoverSuppressedAt) show();
1766
1936
  });
1767
- nav.appendChild(button);
1768
- });
1937
+ node.addEventListener('pointermove', event => {
1938
+ const point = breadcrumbHoverSuppressedAt;
1939
+ if (!point || Math.hypot(event.clientX - point.x, event.clientY - point.y) < 1) return;
1940
+ breadcrumbHoverSuppressedAt = null;
1941
+ show();
1942
+ });
1943
+ node.addEventListener('focus', show);
1944
+ node.addEventListener('pointerleave', () => {
1945
+ if (node.isConnected) breadcrumbHoverSuppressedAt = null;
1946
+ clearBreadcrumbHover();
1947
+ });
1948
+ node.addEventListener('blur', clearBreadcrumbHover);
1949
+ node.addEventListener('click', event => {
1950
+ breadcrumbHoverSuppressedAt = { x: event.clientX, y: event.clientY };
1951
+ clearBreadcrumbHover(); entry.action();
1952
+ });
1953
+ return node;
1954
+ };
1955
+ const add = (entry, index) => {
1956
+ if (index) nav.appendChild(separator());
1957
+ nav.appendChild(button(entry));
1958
+ };
1959
+ entries.forEach(add);
1960
+ const buttons = [...nav.querySelectorAll('button')];
1961
+ const widths = buttons.map(node => node.getBoundingClientRect().width);
1962
+ const separatorWidth = nav.querySelector('.akari-breadcrumb-separator')?.getBoundingClientRect().width ?? 0;
1963
+ const available = Math.max(0, nav.parentElement.getBoundingClientRect().width - 16);
1964
+ if (widths.reduce((sum, width) => sum + width, 0) + separatorWidth * (entries.length - 1) <= available) return;
1965
+ if (entries.length < 4) {
1966
+ const each = Math.max(3 * 10, (available - separatorWidth * (entries.length - 1) - widths[0]) / Math.max(1, entries.length - 1));
1967
+ buttons.slice(1).forEach(node => { node.style.maxWidth = `${each}px`; });
1968
+ return;
1969
+ }
1970
+ const ellipsis = document.createElement('span');
1971
+ ellipsis.textContent = '…'; ellipsis.setAttribute('aria-hidden', 'true');
1972
+ nav.appendChild(ellipsis);
1973
+ const ellipsisWidth = ellipsis.getBoundingClientRect().width;
1974
+ ellipsis.remove();
1975
+ let start = entries.length - 2;
1976
+ let used = widths[0] + widths[start] + widths[start + 1] + ellipsisWidth + 3 * separatorWidth;
1977
+ while (start > 2 && used + widths[start - 1] + separatorWidth <= available) {
1978
+ start--; used += widths[start] + separatorWidth;
1979
+ }
1980
+ nav.replaceChildren();
1981
+ add(entries[0], 0);
1982
+ nav.appendChild(separator()); nav.appendChild(ellipsis);
1983
+ for (let index = start; index < entries.length; index++) add(entries[index], index);
1984
+ if (used > available) {
1985
+ const tails = [...nav.querySelectorAll('button')].slice(1);
1986
+ const tailWidth = Math.max(30, (available - widths[0] - ellipsisWidth - 3 * separatorWidth) / tails.length);
1987
+ tails.forEach(node => { node.style.maxWidth = `${tailWidth}px`; });
1988
+ }
1769
1989
  }
1770
1990
  function publishScopedSelection(notify = true) {
1771
1991
  syncLazyBag();
@@ -2117,6 +2337,13 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2117
2337
  const parts = String(value ?? '').match(/[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?px/giu) ?? [];
2118
2338
  return { x: Number.parseFloat(parts[0]) || 0, y: Number.parseFloat(parts[1]) || 0 };
2119
2339
  }
2340
+ const elementDecimal = new Intl.NumberFormat('en-US', { useGrouping: false, maximumFractionDigits: 2 });
2341
+ function formatElementNumber(value) {
2342
+ return elementDecimal.format(Math.round((value + Number.EPSILON) * 100) / 100 || 0);
2343
+ }
2344
+ function formatElementTranslate(x, y) {
2345
+ return `${formatElementNumber(x)}px ${formatElementNumber(y)}px`;
2346
+ }
2120
2347
  function elementScreenGeometry(element) {
2121
2348
  const rect = element.getBoundingClientRect();
2122
2349
  const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
@@ -2185,14 +2412,18 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2185
2412
  }
2186
2413
  function elementWriteStyle(gesture, dimensionNames = []) {
2187
2414
  const style = {};
2188
- for (const name of dimensionNames) style[name] = gesture.element.style.getPropertyValue(name);
2415
+ for (const name of dimensionNames) {
2416
+ style[name] = `${formatElementNumber(Number.parseFloat(gesture.element.style.getPropertyValue(name)))}px`;
2417
+ }
2189
2418
  for (const name of elementInlineProperties) {
2190
2419
  if (name === 'translate' || dimensionNames.includes(name)) continue;
2191
2420
  const now = gesture.element.style.getPropertyValue(name);
2192
- if (now && now !== gesture.originalInline[name][0]) style[name] = now;
2421
+ if (now && now !== gesture.originalInline[name][0]) {
2422
+ style[name] = name === 'rotate' ? `${formatElementNumber(Number.parseFloat(now))}deg` : now;
2423
+ }
2193
2424
  }
2194
- const translate = gesture.element.style.translate;
2195
- if (translate && translate !== gesture.originalInline.translate[0]) style.translate = translate;
2425
+ const translate = formatElementTranslate(gesture.x, gesture.y);
2426
+ if (translate !== formatElementTranslate(gesture.startX, gesture.startY)) style.translate = translate;
2196
2427
  return style;
2197
2428
  }
2198
2429
  function beginElementGesture(event, handleEl) {
@@ -2214,7 +2445,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2214
2445
  startGeometry: before, startClientX: event.clientX, startClientY: event.clientY,
2215
2446
  startWidth: Number.parseFloat(declared.width) || before.width,
2216
2447
  startHeight: Number.parseFloat(declared.height) || before.height,
2217
- x: translate.x, y: translate.y, moved: false, writeContext: captureWriteContext() };
2448
+ x: translate.x, y: translate.y, startX: translate.x, startY: translate.y,
2449
+ moved: false, writeContext: captureWriteContext() };
2218
2450
  if (startStyle.display === 'inline') {
2219
2451
  element.style.display = 'inline-block';
2220
2452
  const next = element.getBoundingClientRect();
@@ -2362,12 +2594,15 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2362
2594
  const gesture = elementNudge;
2363
2595
  elementNudge = null;
2364
2596
  if (!gesture) return;
2365
- if (Math.abs(gesture.x - gesture.startX) < 0.5 && Math.abs(gesture.y - gesture.startY) < 0.5) {
2597
+ const translate = formatElementTranslate(gesture.x, gesture.y);
2598
+ if (Math.abs(gesture.x - gesture.startX) < 0.5 && Math.abs(gesture.y - gesture.startY) < 0.5
2599
+ || translate === formatElementTranslate(gesture.startX, gesture.startY)) {
2366
2600
  gesture.element.style.translate = gesture.originalInline;
2367
2601
  if (gesture.originalDisplay !== undefined) gesture.element.style.display = gesture.originalDisplay;
2602
+ refreshSelectionFrame();
2368
2603
  return;
2369
2604
  }
2370
- const style = { translate: `${gesture.x}px ${gesture.y}px` };
2605
+ const style = { translate };
2371
2606
  if (gesture.originalDisplay !== undefined) style.display = 'inline-block';
2372
2607
  enqueueWrite(gesture.writeContext, gesture.overlayId,
2373
2608
  { element: { ref: gesture.ref, tag: gesture.tag,
@@ -2475,17 +2710,41 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2475
2710
  }
2476
2711
 
2477
2712
  function hideHover() {
2713
+ breadcrumbHoverActive = false;
2478
2714
  hoverEvent = null;
2479
2715
  if (hoverTick !== null) cancelAnimationFrame(hoverTick);
2480
2716
  hoverTick = null;
2481
- if (hoverFrame) hoverFrame.hidden = true;
2717
+ if (hoverFrame) {
2718
+ hoverFrame.hidden = true;
2719
+ hoverFrame.removeAttribute('data-breadcrumb-hover');
2720
+ }
2721
+ }
2722
+ function showHoverFrame(rect, angle = null, overlayId = '', breadcrumb = false, ariaHidden = true) {
2723
+ if (!hoverFrame) {
2724
+ hoverFrame = document.createElement('div');
2725
+ hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2726
+ if (ariaHidden) hoverFrame.setAttribute('aria-hidden', 'true');
2727
+ Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none',
2728
+ boxSizing: 'border-box', border: '1px solid var(--akari-accent, #4da3ff)',
2729
+ opacity: '0.45', zIndex: '90' });
2730
+ document.body.appendChild(hoverFrame);
2731
+ }
2732
+ if (breadcrumb) hoverFrame.setAttribute('data-breadcrumb-hover', 'true');
2733
+ else hoverFrame.removeAttribute('data-breadcrumb-hover');
2734
+ hoverFrame.dataset.overlayId = overlayId;
2735
+ hoverFrame.hidden = false;
2736
+ Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2737
+ width: `${rect.width}px`, height: `${rect.height}px`,
2738
+ transform: angle === null ? '' : `rotate(${angle}deg)` });
2482
2739
  }
2483
2740
  function scheduleHover(event) {
2741
+ if (breadcrumbHoverActive) return;
2484
2742
  if (!interactionEnabled || activeDrag || activeResize || activeEdit || event.buttons) { hideHover(); return; }
2485
2743
  hoverEvent = event;
2486
2744
  if (hoverTick !== null) return;
2487
2745
  hoverTick = requestAnimationFrame(() => {
2488
2746
  hoverTick = null;
2747
+ if (breadcrumbHoverActive) { hoverEvent = null; return; }
2489
2748
  const event = hoverEvent;
2490
2749
  if (!event || activeDrag || activeResize || activeEdit) { hideHover(); return; }
2491
2750
  const container = overlayForEvent(event);
@@ -2501,17 +2760,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2501
2760
  const rect = element?.getBoundingClientRect() ?? fragmentBounds(container);
2502
2761
  if (!rect || selectedOverlay === container
2503
2762
  && selectedElementFocus()?.ref === elementAddress(root, element)) { hideHover(); return; }
2504
- if (!hoverFrame) {
2505
- hoverFrame = document.createElement('div');
2506
- hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2507
- Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none', boxSizing: 'border-box',
2508
- border: '1px solid var(--akari-accent, #4da3ff)', opacity: '0.45', zIndex: '90' });
2509
- document.body.appendChild(hoverFrame);
2510
- }
2511
- hoverFrame.dataset.overlayId = container.dataset.overlayId;
2512
- hoverFrame.hidden = false;
2513
- Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2514
- width: `${rect.width}px`, height: `${rect.height}px`, transform: '' });
2763
+ showHoverFrame(rect, null, container.dataset.overlayId, false, false);
2515
2764
  return;
2516
2765
  }
2517
2766
  const next = isSelectable(container) && resolveScopedSelection(selectionTree(), scopeId,
@@ -2524,20 +2773,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2524
2773
  const rect = node && node.kind !== 'leaf' ? unionBounds(visibleMembers(next.selectId))
2525
2774
  : leaf?.dataset.role === 'shape-line' ? lineRect : leaf ? fragmentBounds(leaf) : null;
2526
2775
  if (!rect) { hideHover(); return; }
2527
- if (!hoverFrame) {
2528
- hoverFrame = document.createElement('div');
2529
- hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2530
- hoverFrame.setAttribute('aria-hidden', 'true');
2531
- // Standalone hosts also get a non-interactive, viewport-based frame.
2532
- Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none',
2533
- boxSizing: 'border-box', border: '1px solid var(--akari-accent, #4da3ff)', opacity: '0.45', zIndex: '90' });
2534
- document.body.appendChild(hoverFrame);
2535
- }
2536
- hoverFrame.dataset.overlayId = next.selectId;
2537
- hoverFrame.hidden = false;
2538
- Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2539
- width: `${rect.width}px`, height: `${rect.height}px`,
2540
- transform: lineRect ? `rotate(${lineRect.angle}deg)` : '' });
2776
+ showHoverFrame(rect, lineRect?.angle ?? null, next.selectId);
2541
2777
  });
2542
2778
  }
2543
2779
 
@@ -2592,13 +2828,15 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2592
2828
  const displayScale = stage?.getBoundingClientRect().width / outputSize().width || 1;
2593
2829
  const outputDx = (rect.left + rect.width / 2 - drag.startCenterX) / displayScale;
2594
2830
  const outputDy = (rect.top + rect.height / 2 - drag.startCenterY) / displayScale;
2595
- if (!drag.moved || (Math.abs(outputDx) < 0.5 && Math.abs(outputDy) < 0.5)) {
2831
+ const translate = formatElementTranslate(drag.x, drag.y);
2832
+ if (!drag.moved || (Math.abs(outputDx) < 0.5 && Math.abs(outputDy) < 0.5)
2833
+ || translate === formatElementTranslate(drag.startX, drag.startY)) {
2596
2834
  drag.element.style.translate = drag.originalInline;
2597
2835
  if (drag.originalDisplay !== undefined) drag.element.style.display = drag.originalDisplay;
2598
2836
  refreshSelectionFrame();
2599
2837
  return null;
2600
2838
  }
2601
- const style = { translate: `${drag.x}px ${drag.y}px` };
2839
+ const style = { translate };
2602
2840
  if (drag.originalDisplay !== undefined) style.display = 'inline-block';
2603
2841
  const record = enqueueWrite(drag.writeContext, drag.overlayId,
2604
2842
  { element: { ref: drag.elementRef, tag: drag.elementTag,
@@ -3872,6 +4110,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
3872
4110
 
3873
4111
  function onPointerDown(event) {
3874
4112
  if (!interactionEnabled || !canBeginPointerInteraction(pointerOwner)) return;
4113
+ event = correctedHitPolicyEvent(event);
3875
4114
  if (event.button !== 0 || activeDrag || activeResize || activeRotate || activeLine) return;
3876
4115
  if (selectedId && stage && event.target instanceof Element) {
3877
4116
  const bounds = stage.getBoundingClientRect();
@@ -3887,8 +4126,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
3887
4126
  hideHover();
3888
4127
  clickOrigin = { selectedId, scopeId, moved: false, hadMultiple: selectedIds.length > 1 };
3889
4128
 
4129
+ if (event.target instanceof Element && event.target.closest('[data-akari-ui="preview-scope-breadcrumb"]')) return;
3890
4130
  if (selectionTree().length) {
3891
- if (event.target instanceof Element && event.target.closest('[data-akari-ui="preview-scope-breadcrumb"]')) return;
3892
4131
  const handle = findHandleElement(event.target);
3893
4132
  if (handle) {
3894
4133
  if (selectedIds.length > 1) return;
@@ -4722,6 +4961,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4722
4961
 
4723
4962
  function onClick(event) {
4724
4963
  if (!interactionEnabled || activeEdit) return;
4964
+ event = correctedHitPolicyEvent(event);
4725
4965
  const hit = overlayForEvent(event);
4726
4966
  // pointerdown already toggled. Do not toggle twice or update the cycle clock.
4727
4967
  if (event.shiftKey && clickOrigin?.scopedHit) {
@@ -5218,11 +5458,73 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5218
5458
  for (const type of ["pointerdown", "pointerup", "click", "dblclick"]) {
5219
5459
  window.addEventListener(type, passTransparentCanvasEvent, true);
5220
5460
  }
5461
+ const correctedHitPolicyEvents = new WeakMap();
5462
+ let redirectedHitPolicyClick = null;
5463
+ function pointerInsideStage(event) {
5464
+ if (!stage || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false;
5465
+ const rect = stage.getBoundingClientRect();
5466
+ return event.clientX >= rect.left && event.clientX <= rect.right
5467
+ && event.clientY >= rect.top && event.clientY <= rect.bottom;
5468
+ }
5469
+ function trustedEventAtTarget(event, target) {
5470
+ // 合成 Event は isTrusted=false になり E2 の焦点を失う。元の信頼済み
5471
+ // イベントを保ったまま、選択処理に見せる target だけ現在の hit-test にする。
5472
+ return new Proxy(event, { get(original, key) {
5473
+ if (key === 'target') return target;
5474
+ const value = Reflect.get(original, key, original);
5475
+ return typeof value === 'function' ? value.bind(original) : value;
5476
+ } });
5477
+ }
5478
+ function correctHitPolicyEvent(event, target) {
5479
+ correctedHitPolicyEvents.set(event, target);
5480
+ // Window capture の後続ホストにも素の HTML hit と同じ target を見せる。
5481
+ // Event 自体は元の信頼済みイベントなので、再生停止や混在選択は一度だけ走る。
5482
+ try { Object.defineProperty(event, 'target', { configurable: true, value: target }); }
5483
+ catch { /* document 側は WeakMap の target へフォールバックする。 */ }
5484
+ }
5485
+ function correctedHitPolicyEvent(event) {
5486
+ const target = correctedHitPolicyEvents.get(event);
5487
+ return target && event.target !== target ? trustedEventAtTarget(event, target) : event;
5488
+ }
5489
+ function stopCorrectedHitPolicyPropagation(event) {
5490
+ // window のホスト捕捉は通し、古い preview-stage への下流だけ止める。
5491
+ if (findOverlayContainer(correctedHitPolicyEvents.get(event))) event.stopPropagation();
5492
+ }
5493
+ window.addEventListener('pointermove', event => {
5494
+ if (!pointerInsideStage(event) || !staleHitPolicyContainers.size
5495
+ || hitPolicyOperationBusy()) return;
5496
+ const now = performance.now();
5497
+ if (now - lastPointerHitPolicyRefresh < 125) return;
5498
+ if (!refreshStaleHitPolicies()) return;
5499
+ lastPointerHitPolicyRefresh = now;
5500
+ const target = document.elementFromPoint(event.clientX, event.clientY);
5501
+ if (target && target !== event.target) correctHitPolicyEvent(event, target);
5502
+ }, true);
5503
+ window.addEventListener('pointerdown', event => {
5504
+ if (event.button !== 0 || !pointerInsideStage(event) || hitPolicyOperationBusy()) return;
5505
+ redirectedHitPolicyClick = null;
5506
+ if (!refreshStaleHitPolicies()) return;
5507
+ const target = document.elementFromPoint(event.clientX, event.clientY);
5508
+ if (!target || target === event.target) return;
5509
+ redirectedHitPolicyClick = { x: event.clientX, y: event.clientY };
5510
+ correctHitPolicyEvent(event, target);
5511
+ }, true);
5512
+ window.addEventListener('click', event => {
5513
+ if (!redirectedHitPolicyClick) return;
5514
+ const redirect = redirectedHitPolicyClick;
5515
+ redirectedHitPolicyClick = null;
5516
+ if (Math.hypot(event.clientX - redirect.x, event.clientY - redirect.y) > 6) return;
5517
+ const target = document.elementFromPoint(event.clientX, event.clientY);
5518
+ if (!target || target === event.target) return;
5519
+ correctHitPolicyEvent(event, target);
5520
+ }, true);
5221
5521
  listenerRoot.addEventListener('pointermove', updateShapeLineHitProxyWidths, true);
5222
5522
  listenerRoot.addEventListener('pointerdown', updateShapeLineHitProxyWidths, true);
5223
5523
  window.addEventListener('resize', updateShapeLineHitProxyWidths);
5224
5524
  listenerRoot.addEventListener("click", onClick, true);
5225
5525
  listenerRoot.addEventListener("pointerdown", onPointerDown, true);
5526
+ listenerRoot.addEventListener("click", stopCorrectedHitPolicyPropagation, true);
5527
+ listenerRoot.addEventListener("pointerdown", stopCorrectedHitPolicyPropagation, true);
5226
5528
  listenerRoot.addEventListener("dblclick", onDoubleClick, true);
5227
5529
  listenerRoot.addEventListener("blur", onBlur, true);
5228
5530
  listenerRoot.addEventListener("input", onEditableInput, true);
@@ -5333,6 +5635,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5333
5635
  // ㉑ 素通し: overlay-runtime.js の tick() が可視化タイミングで呼ぶ。
5334
5636
  applyOverlayHitPolicy,
5335
5637
  invalidateOverlayHitPolicy,
5638
+ markOverlayHitPolicyStale,
5639
+ forgetOverlayHitPolicyTracking,
5336
5640
  syncOverlayHitRegion,
5337
5641
  // Web UI(preview-server)が編集モードを抜けるときに選択枠を畳むための公開口
5338
5642
  // (Phase 2-4 一本化。shell では未使用の追加 export で挙動不変)。
@@ -341,6 +341,7 @@ function createOverlayRuntime(options = {}) {
341
341
 
342
342
  function unmount() {
343
343
  for (const overlay of mountedOverlays) {
344
+ window.akari.interaction?.forgetOverlayHitPolicyTracking?.(overlay.container);
344
345
  for (const animation of overlay.animations ?? []) releaseAnimation(animation);
345
346
  for (const runtime of overlayRuntimes(overlay)) runtime.dispose(overlay.container);
346
347
  }
@@ -490,6 +491,7 @@ function createOverlayRuntime(options = {}) {
490
491
  runtimesVersion: runtimeRegistryVersion,
491
492
  keepLayoutWhenHidden,
492
493
  hitPolicyPending: false,
494
+ hitPolicyEntry: false,
493
495
  // getAnimations({ subtree: true }) の 250ms キャッシュ(tick() 参照)
494
496
  animations: undefined,
495
497
  animationsAt: 0,
@@ -574,6 +576,7 @@ function createOverlayRuntime(options = {}) {
574
576
  }
575
577
  mounted.container.style.setProperty("opacity", String(statics.opacity));
576
578
  mounted.hitPolicyPending = true;
579
+ mounted.hitPolicyEntry = true;
577
580
  mounted.statics = statics;
578
581
  mounted.keyframes = Array.isArray(value.keyframes) ? value.keyframes : undefined;
579
582
  mounted.fps = finiteNumber(summary?.output?.fps, 30);
@@ -595,6 +598,7 @@ function createOverlayRuntime(options = {}) {
595
598
  mounted.animationsAt = 0;
596
599
  mounted.animationEndTimes = [];
597
600
  mounted.hitPolicyPending = true;
601
+ mounted.hitPolicyEntry = true;
598
602
  return true;
599
603
  }
600
604
 
@@ -636,7 +640,14 @@ function createOverlayRuntime(options = {}) {
636
640
  // することで、実際に存在する CSS Animation を「今可視のオーバーレイ分だけ」に
637
641
  // 抑え、この地雷を踏まない。
638
642
  overlay.container.toggleAttribute("data-akari-active", visible);
643
+ if (!visible && !isPlaying && overlay.animations?.length) {
644
+ // 停止中の連続シークが同一描画フレームで外→内へ戻っても、CSS が
645
+ // inactive を一度観測するようにする。これが無いとゲートの off/on が
646
+ // 相殺され、新しい Animation が生成されず初期 opacity 0 に残る。
647
+ void overlay.container.offsetWidth;
648
+ }
639
649
  overlay.hitPolicyPending = visible;
650
+ overlay.hitPolicyEntry = visible;
640
651
  // getAnimations() のキャッシュ(下記)は可視化フリップで必ず捨てる。ゲート属性の付け外しで
641
652
  // CSS animation の顔ぶれが変わるため、可視化直後の tick は引き直す。非表示化でも捨て、
642
653
  // 非可視の間 Animation 参照を持ち越さない。
@@ -650,6 +661,7 @@ function createOverlayRuntime(options = {}) {
650
661
  overlay.animationsAt = 0;
651
662
  overlay.animationEndTimes = [];
652
663
  if (!visible) {
664
+ window.akari.interaction?.forgetOverlayHitPolicyTracking?.(overlay.container);
653
665
  if (!overlay.keepLayoutWhenHidden) {
654
666
  overlay.container.setAttribute(RUNTIME_HIDDEN_ATTRIBUTE, "");
655
667
  }
@@ -762,15 +774,18 @@ function createOverlayRuntime(options = {}) {
762
774
  runtime.render(overlay.container, localTimeMs / 1000,
763
775
  { syncVideos: true, maxRenderSize, playing: Boolean(playing) });
764
776
  }
765
- // opacity と clip-path は現在時刻へ合わせ、可視な間は入場アニメの終了まで毎 tick
766
- // 測り直す。フリップ時だけでは通常再生の localTimeMs がほぼ 0 となり、0% 姿勢の
767
- // bbox が焼き付くため。キャッシュした有限アニメの終端とローカル時刻で確定を判断する。
768
- // 終わった tick で確定する。以後は呼ばず、無限ループも終端無しとして数えないので、
769
- // 対象を可視オーバーレイだけにする性能原則「見えている分だけ」は維持される。
777
+ const ready = !isPlaying
778
+ ? overlay.animations.map(animation => pendingReady.get(animation)).filter(Boolean) : [];
779
+ window.akari.interaction?.markOverlayHitPolicyStale?.(
780
+ overlay.container, localTimeMs, !isPlaying, overlay.hitPolicyEntry,
781
+ ready.length ? Promise.allSettled(ready) : null);
782
+ overlay.hitPolicyEntry = false;
783
+ // 初回の仮判定と有限アニメ終端の確定判定だけをここで適用する。
784
+ // 途中の姿は上の時刻印を使い、停止後またはポインタ入力時に interaction が
785
+ // 必要な可視コンテナだけ測り直す。終端 tick が存在しない断片も停止後に更新される。
770
786
  if (overlay.hitPolicyPending) {
771
787
  window.akari.interaction?.syncOverlayHitRegion?.(overlay.container);
772
- // 当たり判定ポリシーは初回適用が WeakSet でガードされるため、ここでの再呼び出しは
773
- // 実質 no-op(暫定適用)。確定姿勢に達した tick で invalidate してから測り直す。
788
+ // 初回以外は WeakSet のガードで no-op。再入場時は時刻印側が解除する。
774
789
  window.akari.interaction?.applyOverlayHitPolicy?.(overlay.container);
775
790
  if (entryAnimationsSettled(overlay.animationEndTimes, localTimeMs)) {
776
791
  window.akari.interaction?.invalidateOverlayHitPolicy?.(overlay.container);