akari-video 1.2.0-beta.2 → 1.2.0-beta.3

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.2",
3
+ "version": "1.2.0-beta.3",
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": {
@@ -29,8 +29,8 @@ lint warning として無視する。legacy と v2 の投影は上表の値を
29
29
  lowcut は同一の 2 次 highpass を 2 段カスケードし、24 dB/oct の減衰特性で L1 ゲート
30
30
  (1 oct 下で 15 dB 以上の減衰)を満たすための裁定逸脱とする。
31
31
 
32
- denoise は `fft` なら `afftdn=nr=<12+strength*76>:nf=-30`、`nlm` なら
33
- `anlmdn=s=<0.00001+strength*0.0002>` とする。rubberband は `speed != 1` または
32
+ denoise は `fft` なら `afftdn=nr=<6+strength*18>:nf=-50:tn=1`、`nlm` なら
33
+ `anlmdn=s=<10^(-5+4*strength)>` とする。rubberband は `speed != 1` または
34
34
  `pitch_semitones != 0` のときだけ作る。全キーが既定なら入力もフィルタも追加せず、従来の
35
35
  filtergraph をバイト単位で維持する。
36
36
 
@@ -54,3 +54,21 @@ mtime・in/out・pad・recipe に加え、`atrim` を含む完全なフィルタ
54
54
  receipt の `provenance.audio.clip_fx` は `processed_items` と `filters` の件数を持つ。
55
55
  プレビューと書き出しで意図的に残す近似は、サイドカー FLAC の再圧縮とデコーダ/サンプル境界の
56
56
  差だけとする。
57
+
58
+ ## 2026-10-08 改訂 — ノイズ除去の対応式
59
+
60
+ - `fft`: 旧 `afftdn=nr=<12+strength*76>:nf=-30` から、ノイズ床を追従する
61
+ `afftdn=nr=<6+strength*18>:nf=-50:tn=1` へ変更した。旧式の固定ノイズ床は声の
62
+ 4〜8 kHz を 14〜18 dB、8〜16 kHz を 37〜50 dB 削ってこもらせていた。新式の声の
63
+ 4〜8 kHz の減少は実測 0.7〜1.7 dB。騒がしい録音の 8〜16 kHz は `nr=12` で最大
64
+ 5.9 dB、`nr=24` で最大 15.9 dB 減るため、`nr` の上限を 24 に抑える。
65
+ - `nlm`: 旧 `anlmdn=s=<0.00001+strength*0.0002>` から、強さを広く使える対数式
66
+ `anlmdn=s=<10^(-5+4*strength)>`(`s` は 0.00001〜0.1)へ変更した。実測では声の
67
+ 各帯域の変化は 0.4 dB 以内、弱い声の 8〜16 kHz の減少も 2.1 dB 以内。無音部の
68
+ ノイズは strength 0.5・SNR 38 dB で 21〜54 dB、strength 0.75・SNR 26 dB で
69
+ 10〜36 dB、strength 1・SNR 19 dB で 5〜20 dB 減少した。処理時間は `fft` の約
70
+ 10 倍だが、実時間の約 12 倍速で処理できた。
71
+ - 宣言済みの denoise を持つプロジェクトは、プレビューと書き出しの音が変わる。`fft` は
72
+ こもりが消え、効きは控えめになる。`nlm` は同じ strength で効きが強くなる。
73
+ サイドカーの cache key にはフィルタ文字列が入るため、自動で作り直される。
74
+ おすすめは `nlm`・`0.75`。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0-beta.2",
3
+ "version": "1.2.0-beta.3",
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": [
@@ -33,6 +33,11 @@ export type PreviewItemWriteCommand = ({
33
33
  y: number;
34
34
  };
35
35
  }[];
36
+ element?: {
37
+ ref: string;
38
+ tag: string;
39
+ style: Record<string, string | null>;
40
+ };
36
41
  };
37
42
  } | {
38
43
  kind: 'layer';
@@ -51,7 +51,7 @@ function resolvePreviewItemWriteBatch(editText, commands) {
51
51
  }
52
52
  let candidateText = editText;
53
53
  for (const command of commands) {
54
- if (command.kind === 'overlay' && 'html' in command.patch) {
54
+ if (command.kind === 'overlay' && ('html' in command.patch || 'element' in command.patch)) {
55
55
  throw new Error('バッチでは外部 HTML 本文を書き込めません');
56
56
  }
57
57
  const resolved = resolvePreviewItemWrite(candidateText, command);
@@ -142,6 +142,40 @@ function resolveV2Write(parsed, command) {
142
142
  }
143
143
  };
144
144
  if (command.kind === 'overlay') {
145
+ if (command.patch.element) {
146
+ const { ref, tag, style } = command.patch.element;
147
+ if (item.source.kind !== 'html' || item.source.part) {
148
+ throw new Error(`要素の書き戻し対象ではありません: ${itemId}`);
149
+ }
150
+ if (['transform', 'html', 'text', 'duplicate', 'params', 'vars', 'xyKeyframes']
151
+ .some(key => key in command.patch)
152
+ || !/^[#.][^\s\[\]]+\[(0|[1-9]\d*)\]$/u.test(ref)
153
+ || !/^[a-z][a-z0-9-]*$/u.test(tag)
154
+ || !isRecord(style) || Object.keys(style).length === 0) {
155
+ throw new Error('要素の書き戻し指定が不正です');
156
+ }
157
+ const source = item.source;
158
+ const elements = { ...source.elements };
159
+ const current = { ...elements[ref]?.style };
160
+ for (const [key, value] of Object.entries(style)) {
161
+ if (!/^[a-z][a-z0-9-]*$/u.test(key) || (value !== null && typeof value !== 'string')) {
162
+ throw new Error('要素の style が不正です');
163
+ }
164
+ if (value === null || value === '')
165
+ delete current[key];
166
+ else
167
+ current[key] = value;
168
+ }
169
+ if (Object.keys(current).length)
170
+ elements[ref] = { style: current };
171
+ else
172
+ delete elements[ref];
173
+ if (Object.keys(elements).length)
174
+ source.elements = elements;
175
+ else
176
+ delete source.elements;
177
+ return { candidateText: stringifyEdit(edit) };
178
+ }
145
179
  if ('text' in command.patch) {
146
180
  if (typeof command.patch.text !== 'string') {
147
181
  throw new Error('部品の text は文字列である必要があります');
@@ -202,8 +202,9 @@ function deriveTasks(reviewJsonText, overlayDoc) {
202
202
  if (!found)
203
203
  used.push({ id: virtualId, source: 'annotation', state: 'unsent', createdAt: String(annotation.createdAt ?? '') });
204
204
  const status = annotation.status;
205
- const state = status === 'addressed' ? 'review' : status === 'resolved' ? 'done'
206
- : found?.state === 'sent' ? 'sent' : found?.state === 'done' && found.outcome === 'dismissed' ? 'done' : 'unsent';
205
+ const dismissed = found?.state === 'done' && found.outcome === 'dismissed';
206
+ const state = dismissed ? 'done' : status === 'addressed' ? 'review' : status === 'resolved' ? 'done'
207
+ : found?.state === 'sent' ? 'sent' : 'unsent';
207
208
  const text = typeof annotation.text === 'string' ? annotation.text : '';
208
209
  const derived = {
209
210
  ...found, id: virtualId, source: 'annotation', state,
@@ -217,9 +218,11 @@ function deriveTasks(reviewJsonText, overlayDoc) {
217
218
  };
218
219
  if (text.startsWith('[要確認]'))
219
220
  derived.gate = 'ask';
220
- if (status === 'addressed')
221
+ if (dismissed)
222
+ derived.outcome = 'dismissed';
223
+ else if (status === 'addressed')
221
224
  derived.outcome = isObject(annotation.response) ? annotation.response.action ?? null : null;
222
- if (status !== 'addressed' && found?.outcome !== 'dismissed')
225
+ else
223
226
  delete derived.outcome;
224
227
  if (isObject(annotation.response))
225
228
  derived.response = annotation.response;
@@ -323,6 +323,9 @@ export function hasAudioClipFx(value = {}) {
323
323
  }
324
324
 
325
325
  // Single canonical clip-FX chain shared by render-cut and preview sidecars.
326
+ // A fixed nf=-30 treated the voice's high frequencies as noise and muffled them.
327
+ // Track the noise floor (tn=1) with a restrained nr instead.
328
+ // NLM preserves the voice, so its strength spans a wider range logarithmically.
326
329
  export function buildAudioClipFxFilters(value = {}) {
327
330
  const normalized = normalizeAudioClipFx(value);
328
331
  const filters = [];
@@ -331,9 +334,9 @@ export function buildAudioClipFxFilters(value = {}) {
331
334
  filters.push(highpass, highpass);
332
335
  }
333
336
  if (normalized.denoise?.method === 'fft') {
334
- filters.push(`afftdn=nr=${formatNumber(12 + normalized.denoise.strength * 76)}:nf=-30`);
337
+ filters.push(`afftdn=nr=${formatNumber(6 + normalized.denoise.strength * 18)}:nf=-50:tn=1`);
335
338
  } else if (normalized.denoise?.method === 'nlm') {
336
- filters.push(`anlmdn=s=${formatNumber(0.00001 + normalized.denoise.strength * 0.0002)}`);
339
+ filters.push(`anlmdn=s=${formatNumber(10 ** (-5 + 4 * normalized.denoise.strength))}`);
337
340
  }
338
341
  if (normalized.speed !== 1 || normalized.pitchSemitones !== 0) {
339
342
  filters.push([
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
+ import { join, resolve } from 'node:path';
5
6
  import test from 'node:test';
6
7
 
7
8
  import {
@@ -33,21 +34,56 @@ test('clip FX chain orders trim, two-stage lowcut, denoise, then rubberband', ()
33
34
  'asetpts=PTS-STARTPTS',
34
35
  'highpass=f=120:p=2',
35
36
  'highpass=f=120:p=2',
36
- 'afftdn=nr=57.6:nf=-30',
37
+ 'afftdn=nr=16.8:nf=-50:tn=1',
37
38
  'rubberband=tempo=2:pitch=2:formant=preserved:pitchq=quality',
38
39
  ]);
39
40
  });
40
41
 
41
- test('fft denoise coefficient follows 12 + strength * 76', () => {
42
- assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'fft', strength: 0.25 } }), [
43
- 'afftdn=nr=31:nf=-30',
44
- ]);
42
+ test('fft denoise coefficient follows 6 + strength * 18 with a tracked noise floor', () => {
43
+ for (const [strength, nr] of [[0, '6'], [0.25, '10.5'], [0.6, '16.8'], [1, '24']]) {
44
+ assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'fft', strength } }), [
45
+ `afftdn=nr=${nr}:nf=-50:tn=1`,
46
+ ]);
47
+ }
45
48
  });
46
49
 
47
- test('nlm denoise coefficient follows 0.00001 + strength * 0.0002', () => {
48
- assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'nlm', strength: 1 } }), [
49
- 'anlmdn=s=0.00021',
50
- ]);
50
+ test('nlm denoise coefficient follows 10^(-5 + 4 * strength) in decimal notation', () => {
51
+ for (const [strength, s] of [[0, '0.00001'], [0.5, '0.001'], [0.75, '0.01'], [1, '0.1']]) {
52
+ assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'nlm', strength } }), [
53
+ `anlmdn=s=${s}`,
54
+ ]);
55
+ }
56
+ });
57
+
58
+ test('fft nr and nlm s increase monotonically with strength', () => {
59
+ for (const method of ['fft', 'nlm']) {
60
+ const coefficients = [0, 0.25, 0.5, 0.75, 1].map(strength => {
61
+ const filter = buildAudioClipFxFilters({ denoise: { method, strength } })[0];
62
+ return Number(filter.match(/(?:nr|s)=([\d.]+)/u)[1]);
63
+ });
64
+ for (let index = 1; index < coefficients.length; index += 1) {
65
+ assert.ok(coefficients[index] > coefficients[index - 1], `${method} at index ${index}`);
66
+ }
67
+ }
68
+ });
69
+
70
+ test('each new denoise filter differs from the old filter and produces a fresh sidecar key', () => {
71
+ const sourcePath = resolve(baseKeyInput.sourcePath);
72
+ const oldKey = oldFilter => createHash('sha1').update([
73
+ sourcePath, baseKeyInput.size, baseKeyInput.mtimeMs,
74
+ baseKeyInput.inSec, baseKeyInput.outSec, baseKeyInput.speed,
75
+ baseKeyInput.padBeforeSec, baseKeyInput.padAfterSec,
76
+ 'atrim=start=1:end=5', 'asetpts=PTS-STARTPTS', oldFilter, PREVIEW_AUDIO_RECIPE,
77
+ ].join('|')).digest('hex');
78
+ for (const [method, strength, oldFilter] of [
79
+ ['fft', 0.6, 'afftdn=nr=57.6:nf=-30'],
80
+ ['nlm', 0.5, 'anlmdn=s=0.00011'],
81
+ ]) {
82
+ const clipFx = { denoise: { method, strength } };
83
+ const newFilter = buildAudioClipFxFilters(clipFx)[0];
84
+ assert.notEqual(newFilter, oldFilter);
85
+ assert.notEqual(previewAudioSidecarKey({ ...baseKeyInput, clipFx }), oldKey(oldFilter));
86
+ }
51
87
  });
52
88
 
53
89
  test('rubberband pitch uses 2^(semitones/12)', () => {
@@ -0,0 +1,6 @@
1
+ export declare function elementAddress(root: Element, element: Element): string | null;
2
+ export declare function selectableElement(root: Element, element: Element, outputRect?: DOMRect): boolean;
3
+ export declare function nearestSelectableElement(root: Element, hit: Element, outputRect?: DOMRect): Element | null;
4
+ export declare function firstSelectableElement(root: Element, outputRect?: DOMRect): Element | null;
5
+ export declare function elementByAddress(root: Element, ref: string): Element | null;
6
+ export declare function elementLabel(root: Element, element: Element): string;
@@ -0,0 +1,61 @@
1
+ // Pure DOM selection rules. interaction.js carries this marked body verbatim.
2
+ // BEGIN element-selection
3
+ function isRuntimeElement(element) {
4
+ return element?.matches?.('script, style, template, [data-akari-hit-proxy], [data-akari-interaction], [data-akari-part-mask], .akari-u')
5
+ || Boolean(element?.closest?.('[data-akari-hit-proxy], [data-akari-interaction], [data-akari-part-mask]'));
6
+ }
7
+
8
+ function elementAddress(root, element) {
9
+ if (!root || !element || isRuntimeElement(element) || !root.contains(element)) return null;
10
+ const id = element.getAttribute('id');
11
+ const token = element.getAttribute('class')?.trim().split(/\s+/u).find(value => value && value !== 'akari-u');
12
+ if (!id && !token) return null;
13
+ const candidates = [root, ...root.querySelectorAll('*')].filter(candidate => !isRuntimeElement(candidate)
14
+ && (id ? candidate.getAttribute('id') === id
15
+ : candidate.getAttribute('class')?.split(/\s+/u).includes(token)));
16
+ const index = candidates.indexOf(element);
17
+ return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
18
+ }
19
+
20
+ function selectableElement(root, element, outputRect) {
21
+ if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
22
+ || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
23
+ || !elementAddress(root, element)) return false;
24
+ const rect = element.getBoundingClientRect();
25
+ if (!(rect.width > 1 && rect.height > 1)) return false;
26
+ for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
27
+ const style = getComputedStyle(ancestor);
28
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
29
+ }
30
+ const replaced = ['img', 'video', 'canvas', 'svg'].includes(element.tagName.toLowerCase());
31
+ return replaced || !outputRect || rect.width < outputRect.width * 0.95
32
+ || rect.height < outputRect.height * 0.95;
33
+ }
34
+
35
+ function nearestSelectableElement(root, hit, outputRect) {
36
+ for (let element = hit; element && element !== root; element = element.parentElement) {
37
+ if (selectableElement(root, element, outputRect)) return element;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function firstSelectableElement(root, outputRect) {
43
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
44
+ }
45
+
46
+ function elementByAddress(root, ref) {
47
+ if (!root || typeof ref !== 'string') return null;
48
+ return [root, ...root.querySelectorAll('*')].find(element => elementAddress(root, element) === ref) ?? null;
49
+ }
50
+
51
+ function elementLabel(root, element) {
52
+ const ref = elementAddress(root, element);
53
+ if (!ref) return '';
54
+ if (ref.startsWith('#')) return ref.slice(0, ref.lastIndexOf('['));
55
+ const name = ref.slice(1, ref.lastIndexOf('['));
56
+ const siblings = [...element.parentElement.children].filter(sibling => sibling.getAttribute('class')?.split(/\s+/u).includes(name));
57
+ return siblings.length > 1 ? `${name} ${siblings.indexOf(element) + 1}` : name;
58
+ }
59
+ // END element-selection
60
+ export { isRuntimeElement, elementAddress, selectableElement, nearestSelectableElement,
61
+ firstSelectableElement, elementByAddress, elementLabel };
@@ -104,6 +104,65 @@ function marqueeHits(candidates, rect) {
104
104
 
105
105
  // END selection-scope
106
106
 
107
+ // BEGIN element-selection
108
+ function isRuntimeElement(element) {
109
+ return element?.matches?.('script, style, template, [data-akari-hit-proxy], [data-akari-interaction], [data-akari-part-mask], .akari-u')
110
+ || Boolean(element?.closest?.('[data-akari-hit-proxy], [data-akari-interaction], [data-akari-part-mask]'));
111
+ }
112
+
113
+ function elementAddress(root, element) {
114
+ if (!root || !element || isRuntimeElement(element) || !root.contains(element)) return null;
115
+ const id = element.getAttribute('id');
116
+ const token = element.getAttribute('class')?.trim().split(/\s+/u).find(value => value && value !== 'akari-u');
117
+ if (!id && !token) return null;
118
+ const candidates = [root, ...root.querySelectorAll('*')].filter(candidate => !isRuntimeElement(candidate)
119
+ && (id ? candidate.getAttribute('id') === id
120
+ : candidate.getAttribute('class')?.split(/\s+/u).includes(token)));
121
+ const index = candidates.indexOf(element);
122
+ return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
123
+ }
124
+
125
+ function selectableElement(root, element, outputRect) {
126
+ if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
127
+ || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
128
+ || !elementAddress(root, element)) return false;
129
+ const rect = element.getBoundingClientRect();
130
+ if (!(rect.width > 1 && rect.height > 1)) return false;
131
+ for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
132
+ const style = getComputedStyle(ancestor);
133
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
134
+ }
135
+ const replaced = ['img', 'video', 'canvas', 'svg'].includes(element.tagName.toLowerCase());
136
+ return replaced || !outputRect || rect.width < outputRect.width * 0.95
137
+ || rect.height < outputRect.height * 0.95;
138
+ }
139
+
140
+ function nearestSelectableElement(root, hit, outputRect) {
141
+ for (let element = hit; element && element !== root; element = element.parentElement) {
142
+ if (selectableElement(root, element, outputRect)) return element;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function firstSelectableElement(root, outputRect) {
148
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
149
+ }
150
+
151
+ function elementByAddress(root, ref) {
152
+ if (!root || typeof ref !== 'string') return null;
153
+ return [root, ...root.querySelectorAll('*')].find(element => elementAddress(root, element) === ref) ?? null;
154
+ }
155
+
156
+ function elementLabel(root, element) {
157
+ const ref = elementAddress(root, element);
158
+ if (!ref) return '';
159
+ if (ref.startsWith('#')) return ref.slice(0, ref.lastIndexOf('['));
160
+ const name = ref.slice(1, ref.lastIndexOf('['));
161
+ const siblings = [...element.parentElement.children].filter(sibling => sibling.getAttribute('class')?.split(/\s+/u).includes(name));
162
+ return siblings.length > 1 ? `${name} ${siblings.indexOf(element) + 1}` : name;
163
+ }
164
+ // END element-selection
165
+
107
166
  const stage = document.getElementById("overlay-stage");
108
167
  const dragStartDistance = 4;
109
168
  const SNAP_DISTANCE = 6;
@@ -156,6 +215,9 @@ function marqueeHits(candidates, rect) {
156
215
  let selectedOverlay = null;
157
216
  let selectedId = null;
158
217
  let selectedIds = [];
218
+ let elementFocus = null;
219
+ let elementNudge = null;
220
+ let elementNudgeTimer = null;
159
221
  let scopeId = null;
160
222
  let floorScopeId = window.akari.state?.selectionFloor ?? null;
161
223
  scopeId = floorScopeId;
@@ -1242,7 +1304,8 @@ function marqueeHits(candidates, rect) {
1242
1304
 
1243
1305
  const transform = readTransform(selectedOverlay);
1244
1306
  const lineRect = lineFrameGeometry(selectedOverlay);
1245
- const rect = selectedOverlay.dataset.role === 'shape-line' ? lineRect
1307
+ const focused = focusedElement();
1308
+ const rect = focused ? focused.getBoundingClientRect() : selectedOverlay.dataset.role === 'shape-line' ? lineRect
1246
1309
  : transform.rotate ? unrotatedLeafBounds(selectedOverlay) : fragmentBounds(selectedOverlay);
1247
1310
  if (!rect) {
1248
1311
  if (selectionFrame) selectionFrame.hidden = true;
@@ -1278,6 +1341,11 @@ function marqueeHits(candidates, rect) {
1278
1341
  selectionFrame.classList.toggle('is-text', selectedOverlay.dataset.role === 'text');
1279
1342
  selectionFrame.classList.toggle('is-telop', isTelopOverlay(selectedOverlay));
1280
1343
  selectionFrame.classList.toggle('is-line', selectedOverlay.dataset.role === 'shape-line');
1344
+ if (window.akari.capabilities?.elementSelection === true) {
1345
+ for (const handle of selectionFrame.querySelectorAll('.akari-interaction-handle')) {
1346
+ handle.style.display = focused ? 'none' : '';
1347
+ }
1348
+ }
1281
1349
  for (const endpoint of selectionFrame.querySelectorAll('.is-line-start, .is-line-end')) {
1282
1350
  endpoint.hidden = selectedOverlay.dataset.role !== 'shape-line';
1283
1351
  endpoint.style.display = selectedOverlay.dataset.role === 'shape-line' ? '' : 'none';
@@ -1305,7 +1373,7 @@ function marqueeHits(candidates, rect) {
1305
1373
  const pivot = leafPivotClient(transform);
1306
1374
  selectionFrame.style.transformOrigin = pivot
1307
1375
  ? `${pivot.x - rect.left}px ${pivot.y - rect.top}px` : 'center';
1308
- selectionFrame.style.transform = transform.rotate ? `rotate(${transform.rotate}deg)` : '';
1376
+ selectionFrame.style.transform = focused ? '' : transform.rotate ? `rotate(${transform.rotate}deg)` : '';
1309
1377
  }
1310
1378
 
1311
1379
  function trackSelectionFrame() {
@@ -1315,23 +1383,31 @@ function marqueeHits(candidates, rect) {
1315
1383
  selectionTrackingFrame = requestAnimationFrame(trackSelectionFrame);
1316
1384
  return;
1317
1385
  }
1318
- if (!selectedOverlay && selectedId && selectionTree().length) {
1319
- if (!treeNode(selectedId)) { clearSelection(); publishScopedSelection(); return; }
1386
+ if (!selectedOverlay && selectedId && (selectionTree().length || elementFocus?.overlayId === selectedId)) {
1387
+ if (selectionTree().length && !treeNode(selectedId)) { clearSelection(); publishScopedSelection(); return; }
1320
1388
  const replacement = containerById(selectedId);
1321
1389
  if (replacement) {
1322
1390
  selectedOverlay = replacement;
1323
1391
  replacement.setAttribute('data-akari-interaction-selected', 'true');
1392
+ reconcileElementFocus();
1324
1393
  } else {
1325
1394
  selectionTrackingFrame = requestAnimationFrame(trackSelectionFrame);
1326
1395
  return;
1327
1396
  }
1328
1397
  }
1329
1398
  if (!selectedOverlay) return;
1399
+ if (!selectedOverlay.isConnected && elementFocus?.overlayId === selectedId) {
1400
+ selectedOverlay = containerById(selectedId);
1401
+ if (!selectedOverlay) { selectionTrackingFrame = requestAnimationFrame(trackSelectionFrame); return; }
1402
+ selectedOverlay.setAttribute('data-akari-interaction-selected', 'true');
1403
+ reconcileElementFocus();
1404
+ }
1330
1405
  if (!isSelectable(selectedOverlay)) {
1331
1406
  handleSelectedOverlayUnavailable(selectedOverlay);
1332
1407
  return;
1333
1408
  }
1334
1409
 
1410
+ reconcileElementFocus();
1335
1411
  refreshSelectionFrame();
1336
1412
  selectionTrackingFrame = requestAnimationFrame(trackSelectionFrame);
1337
1413
  }
@@ -1343,6 +1419,9 @@ function marqueeHits(candidates, rect) {
1343
1419
  }
1344
1420
 
1345
1421
  function clearSelection() {
1422
+ const hadElementFocus = window.akari.capabilities?.elementSelection === true && Boolean(selectedElementFocus());
1423
+ flushElementNudge();
1424
+ elementFocus = null;
1346
1425
  if (activeRotate) cancelRotate();
1347
1426
  if (activeLine) finishLineEndpoint(true);
1348
1427
  flushNudge();
@@ -1361,6 +1440,7 @@ function marqueeHits(candidates, rect) {
1361
1440
  selectionFrame?.remove();
1362
1441
  selectionFrame = null;
1363
1442
  hideSnapGuides();
1443
+ if (hadElementFocus) renderScopeBreadcrumb();
1364
1444
  }
1365
1445
 
1366
1446
  function handleSelectedOverlayUnavailable(container) {
@@ -1369,10 +1449,11 @@ function marqueeHits(candidates, rect) {
1369
1449
  if (replacement && replacement !== container) {
1370
1450
  selectedOverlay = replacement;
1371
1451
  replacement.setAttribute('data-akari-interaction-selected', 'true');
1452
+ reconcileElementFocus();
1372
1453
  startSelectionTracking();
1373
1454
  return;
1374
1455
  }
1375
- if (selectionTree().length && !container.isConnected && treeNode(selectedId)) {
1456
+ if ((selectionTree().length && treeNode(selectedId) || elementFocus?.overlayId === selectedId) && !container.isConnected) {
1376
1457
  selectedOverlay = null;
1377
1458
  if (selectionFrame) selectionFrame.hidden = true;
1378
1459
  startSelectionTracking();
@@ -1391,11 +1472,20 @@ function marqueeHits(candidates, rect) {
1391
1472
  if (!isSelectable(container)) return false;
1392
1473
 
1393
1474
  if (selectedOverlay !== container || selectedIds.length > 1) {
1394
- clearSelection();
1395
- selectedOverlay = container;
1396
- selectedId = container.dataset.overlayId ?? null;
1397
- selectedIds = selectedId === null ? [] : [selectedId];
1398
- selectedOverlay.setAttribute("data-akari-interaction-selected", "true");
1475
+ const nextId = container.dataset.overlayId ?? null;
1476
+ if (elementFocus?.overlayId === nextId && selectedId === nextId
1477
+ && selectedIds.length === 1 && !groupSelection) {
1478
+ selectedOverlay?.removeAttribute('data-akari-interaction-selected');
1479
+ selectedOverlay = container;
1480
+ selectedOverlay.setAttribute('data-akari-interaction-selected', 'true');
1481
+ reconcileElementFocus();
1482
+ } else {
1483
+ clearSelection();
1484
+ selectedOverlay = container;
1485
+ selectedId = nextId;
1486
+ selectedIds = selectedId === null ? [] : [selectedId];
1487
+ selectedOverlay.setAttribute("data-akari-interaction-selected", "true");
1488
+ }
1399
1489
  }
1400
1490
 
1401
1491
  refreshSelectionFrame();
@@ -1403,6 +1493,67 @@ function marqueeHits(candidates, rect) {
1403
1493
  return true;
1404
1494
  }
1405
1495
 
1496
+ function canFocusElement(container) {
1497
+ if (window.akari.capabilities?.elementSelection !== true || !container || selectedIds.length > 1) return false;
1498
+ return window.akari.state?.summary?.overlays?.some(overlay => overlay.id === container.dataset.overlayId
1499
+ && overlay.elementSelection === true) === true;
1500
+ }
1501
+ function focusedElement() {
1502
+ if (!selectedElementFocus() || !selectedOverlay) return null;
1503
+ return elementByAddress(fragmentRoot(selectedOverlay), elementFocus.ref);
1504
+ }
1505
+ function selectedElementFocus() {
1506
+ return elementFocus?.overlayId === selectedId
1507
+ && (!selectedOverlay || selectedOverlay.dataset.overlayId === selectedId) ? elementFocus : null;
1508
+ }
1509
+ function reconcileElementFocus() {
1510
+ if (!selectedElementFocus()) return;
1511
+ const root = fragmentRoot(selectedOverlay);
1512
+ if (root && !elementByAddress(root, elementFocus.ref)) focusElement(null);
1513
+ }
1514
+ function focusElement(element, { notify = true } = {}) {
1515
+ const root = fragmentRoot(selectedOverlay);
1516
+ const ref = element && elementAddress(root, element);
1517
+ const next = ref ? { overlayId: selectedId, ref, tag: element.tagName.toLowerCase(),
1518
+ label: elementLabel(root, element) } : null;
1519
+ if (elementFocus?.ref === next?.ref && elementFocus?.overlayId === next?.overlayId) return;
1520
+ flushElementNudge();
1521
+ elementFocus = next;
1522
+ refreshSelectionFrame();
1523
+ publishScopedSelection(notify);
1524
+ }
1525
+ function focusElementAt(container, event) {
1526
+ if (event.isTrusted === false) return;
1527
+ if (!canFocusElement(container) || selectedOverlay !== container || collectiveSelection()) {
1528
+ if (elementFocus) focusElement(null);
1529
+ return;
1530
+ }
1531
+ const root = fragmentRoot(container);
1532
+ const hit = event.target instanceof Element && root?.contains(event.target) ? event.target
1533
+ : document.elementsFromPoint(event.clientX, event.clientY).find(element => root?.contains(element)) ?? null;
1534
+ focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect()));
1535
+ }
1536
+ function focusElementAtPoint(overlayId, clientX, clientY, attempt = 0) {
1537
+ if (window.akari.capabilities?.elementSelection !== true || !Number.isFinite(clientX)
1538
+ || !Number.isFinite(clientY) || typeof overlayId !== 'string') return false;
1539
+ if (selectedId !== overlayId || !containerById(overlayId)) {
1540
+ if (attempt < 2) requestAnimationFrame(() => focusElementAtPoint(overlayId, clientX, clientY, attempt + 1));
1541
+ return false;
1542
+ }
1543
+ if (selectedIds.length > 1 || collectiveSelection()) return false;
1544
+ const container = containerById(overlayId);
1545
+ if (!canFocusElement(container)) return false;
1546
+ if (selectedOverlay !== container) selectOverlay(container);
1547
+ const root = fragmentRoot(container);
1548
+ if (!root) {
1549
+ if (attempt < 2) requestAnimationFrame(() => focusElementAtPoint(overlayId, clientX, clientY, attempt + 1));
1550
+ return false;
1551
+ }
1552
+ const hit = document.elementsFromPoint(clientX, clientY).find(element => root.contains(element)) ?? null;
1553
+ focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect()));
1554
+ return true;
1555
+ }
1556
+
1406
1557
  function selectionTree() {
1407
1558
  const tree = window.akari.state?.summary?.tree;
1408
1559
  return Array.isArray(tree) ? tree : [];
@@ -1462,6 +1613,30 @@ function marqueeHits(candidates, rect) {
1462
1613
  function renderScopeBreadcrumb() {
1463
1614
  const nav = document.querySelector('[data-akari-ui="preview-scope-breadcrumb"]');
1464
1615
  if (!nav) return;
1616
+ if (selectedElementFocus() && selectedOverlay) {
1617
+ nav.hidden = false;
1618
+ nav.replaceChildren();
1619
+ const append = (label, action) => {
1620
+ if (nav.childNodes.length) nav.append(' › ');
1621
+ const button = document.createElement('button');
1622
+ button.type = 'button'; button.textContent = label;
1623
+ button.addEventListener('click', action);
1624
+ nav.appendChild(button);
1625
+ };
1626
+ append('全体', () => { clearSelection(); publishScopedSelection(); });
1627
+ for (const id of lineage(selectionTree(), scopeId)) {
1628
+ append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }));
1629
+ }
1630
+ const name = window.akari.state?.summary?.overlays?.find(overlay => overlay.id === selectedId)?.name ?? selectedId;
1631
+ append(name, () => focusElement(null));
1632
+ const root = fragmentRoot(selectedOverlay);
1633
+ const path = [];
1634
+ for (let node = focusedElement(); node && node !== root; node = node.parentElement) {
1635
+ if (selectableElement(root, node, stage?.getBoundingClientRect())) path.unshift(node);
1636
+ }
1637
+ for (const node of path) append(elementLabel(root, node), () => focusElement(node));
1638
+ return;
1639
+ }
1465
1640
  nav.hidden = scopeId === floorScopeId || !selectionTree().length;
1466
1641
  nav.replaceChildren();
1467
1642
  if (nav.hidden) return;
@@ -1826,7 +2001,54 @@ function marqueeHits(candidates, rect) {
1826
2001
  return record;
1827
2002
  }
1828
2003
 
2004
+ function parseElementTranslate(value) {
2005
+ const parts = String(value ?? '').match(/[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?px/giu) ?? [];
2006
+ return { x: Number.parseFloat(parts[0]) || 0, y: Number.parseFloat(parts[1]) || 0 };
2007
+ }
2008
+ function moveElementToCenter(gesture, targetX, targetY) {
2009
+ const element = gesture.element;
2010
+ const center = () => {
2011
+ const rect = element.getBoundingClientRect();
2012
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
2013
+ };
2014
+ const write = (x, y) => { element.style.translate = `${x}px ${y}px`; };
2015
+ for (let attempt = 0; attempt < 4; attempt++) {
2016
+ write(gesture.x, gesture.y);
2017
+ const current = center();
2018
+ const rx = targetX - current.x, ry = targetY - current.y;
2019
+ if (Math.hypot(rx, ry) <= 0.2) break;
2020
+ write(gesture.x + 1, gesture.y);
2021
+ const xProbe = center();
2022
+ write(gesture.x, gesture.y + 1);
2023
+ const yProbe = center();
2024
+ const ax = xProbe.x - current.x, ay = xProbe.y - current.y;
2025
+ const bx = yProbe.x - current.x, by = yProbe.y - current.y;
2026
+ const determinant = ax * by - ay * bx;
2027
+ if (Math.abs(determinant) < 0.000001) break;
2028
+ gesture.x += (rx * by - ry * bx) / determinant;
2029
+ gesture.y += (ry * ax - rx * ay) / determinant;
2030
+ }
2031
+ write(gesture.x, gesture.y);
2032
+ }
2033
+ function flushElementNudge() {
2034
+ clearTimeout(elementNudgeTimer);
2035
+ elementNudgeTimer = null;
2036
+ const gesture = elementNudge;
2037
+ elementNudge = null;
2038
+ if (!gesture) return;
2039
+ if (Math.abs(gesture.x - gesture.startX) < 0.5 && Math.abs(gesture.y - gesture.startY) < 0.5) {
2040
+ gesture.element.style.translate = gesture.originalInline;
2041
+ return;
2042
+ }
2043
+ enqueueWrite(gesture.writeContext, gesture.overlayId,
2044
+ { element: { ref: gesture.ref, tag: gesture.tag,
2045
+ style: { translate: `${gesture.x}px ${gesture.y}px` } } }, 'element').promise.catch(() => {
2046
+ gesture.element.style.translate = gesture.originalInline;
2047
+ refreshSelectionFrame();
2048
+ });
2049
+ }
1829
2050
  function flushNudge() {
2051
+ flushElementNudge();
1830
2052
  clearTimeout(nudgeTimer);
1831
2053
  nudgeTimer = null;
1832
2054
  const session = nudge;
@@ -1847,13 +2069,37 @@ function marqueeHits(candidates, rect) {
1847
2069
  });
1848
2070
  }
1849
2071
 
2072
+ function isControl(target) {
2073
+ return target instanceof Element
2074
+ && (target.isContentEditable || target.closest('input, textarea, select, button, [role="textbox"]'));
2075
+ }
2076
+
1850
2077
  function handleNudge(event) {
1851
2078
  const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
1852
2079
  if (!delta || !interactionEnabled || !selectedId || activeEdit || activeDrag || activeResize
1853
2080
  || event.metaKey || event.ctrlKey || event.altKey || !document.hasFocus()) return false;
1854
- const isControl = target => target instanceof Element
1855
- && (target.isContentEditable || target.closest('input, textarea, select, button, [role="textbox"]'));
1856
2081
  if (isControl(event.target) || isControl(document.activeElement)) return false;
2082
+ if (elementFocus && focusedElement()) {
2083
+ if (!elementNudge) {
2084
+ const element = focusedElement();
2085
+ const source = window.akari.state?.summary?.overlays?.find(overlay => overlay.id === selectedId);
2086
+ const current = parseElementTranslate(source?.elements?.[elementFocus.ref]?.style?.translate
2087
+ ?? getComputedStyle(element).translate);
2088
+ elementNudge = { element, ref: elementFocus.ref, tag: elementFocus.tag,
2089
+ originalInline: element.style.translate, startX: current.x, startY: current.y,
2090
+ x: current.x, y: current.y, overlayId: selectedId, writeContext: captureWriteContext() };
2091
+ }
2092
+ const step = event.shiftKey ? 10 : 1;
2093
+ const rect = elementNudge.element.getBoundingClientRect();
2094
+ const scale = stage?.getBoundingClientRect().width / outputSize().width || 1;
2095
+ moveElementToCenter(elementNudge, rect.left + rect.width / 2 + delta[0] * step * scale,
2096
+ rect.top + rect.height / 2 + delta[1] * step * scale);
2097
+ clearTimeout(elementNudgeTimer);
2098
+ elementNudgeTimer = setTimeout(flushElementNudge, 400);
2099
+ refreshSelectionFrame();
2100
+ event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation();
2101
+ return true;
2102
+ }
1857
2103
  const members = collectiveSelection() ? selectionMembers() : [selectedOverlay];
1858
2104
  if (!members.length || members.some(element => !isMovable(element))) return false;
1859
2105
  if (nudge && nudge.overlayId !== selectedId) flushNudge();
@@ -1906,6 +2152,31 @@ function marqueeHits(candidates, rect) {
1906
2152
  const event = hoverEvent;
1907
2153
  if (!event || activeDrag || activeResize || activeEdit) { hideHover(); return; }
1908
2154
  const container = overlayForEvent(event);
2155
+ const elementHit = window.akari.capabilities?.elementSelection === true && canFocusElement(container);
2156
+ const scoped = elementHit && selectionTree().length
2157
+ ? resolveScopedSelection(selectionTree(), scopeId, scopedHitId(container, event),
2158
+ { deep: Boolean(event.metaKey || event.ctrlKey) }) : null;
2159
+ if (elementHit && !event.shiftKey
2160
+ && (!scoped || scoped.selectId === container.dataset.overlayId)) {
2161
+ const root = fragmentRoot(container);
2162
+ const hit = event.target instanceof Element && root?.contains(event.target) ? event.target : null;
2163
+ const element = nearestSelectableElement(root, hit, stage?.getBoundingClientRect());
2164
+ const rect = element?.getBoundingClientRect() ?? fragmentBounds(container);
2165
+ if (!rect || selectedOverlay === container
2166
+ && selectedElementFocus()?.ref === elementAddress(root, element)) { hideHover(); return; }
2167
+ if (!hoverFrame) {
2168
+ hoverFrame = document.createElement('div');
2169
+ hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2170
+ Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none', boxSizing: 'border-box',
2171
+ border: '1px solid var(--akari-accent, #4da3ff)', opacity: '0.45', zIndex: '90' });
2172
+ document.body.appendChild(hoverFrame);
2173
+ }
2174
+ hoverFrame.dataset.overlayId = container.dataset.overlayId;
2175
+ hoverFrame.hidden = false;
2176
+ Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2177
+ width: `${rect.width}px`, height: `${rect.height}px`, transform: '' });
2178
+ return;
2179
+ }
1909
2180
  const next = isSelectable(container) && resolveScopedSelection(selectionTree(), scopeId,
1910
2181
  scopedHitId(container, event), { deep: Boolean(event.metaKey || event.ctrlKey) });
1911
2182
  if (!next || selectedIds.includes(next.selectId) || selectionMembers().includes(container) || (floorScopeId !== null
@@ -1950,6 +2221,12 @@ function marqueeHits(candidates, rect) {
1950
2221
  clearDragSettleTimer(drag);
1951
2222
  reportLiveValues(drag.overlayId, undefined, true);
1952
2223
  activeDrag = null;
2224
+ if (drag.element) {
2225
+ drag.element.style.translate = drag.originalInline;
2226
+ releasePointer(drag);
2227
+ refreshSelectionFrame();
2228
+ return;
2229
+ }
1953
2230
  if (drag.group) {
1954
2231
  moveGroupMembers(drag, 0, 0);
1955
2232
  releasePointer(drag); hideSnapGuides(); return;
@@ -1972,6 +2249,26 @@ function marqueeHits(candidates, rect) {
1972
2249
  releasePointer(drag);
1973
2250
  hideSnapGuides();
1974
2251
 
2252
+ if (drag.element) {
2253
+ const rect = drag.element.getBoundingClientRect();
2254
+ const displayScale = stage?.getBoundingClientRect().width / outputSize().width || 1;
2255
+ const outputDx = (rect.left + rect.width / 2 - drag.startCenterX) / displayScale;
2256
+ const outputDy = (rect.top + rect.height / 2 - drag.startCenterY) / displayScale;
2257
+ if (!drag.moved || (Math.abs(outputDx) < 0.5 && Math.abs(outputDy) < 0.5)) {
2258
+ drag.element.style.translate = drag.originalInline;
2259
+ refreshSelectionFrame();
2260
+ return null;
2261
+ }
2262
+ const record = enqueueWrite(drag.writeContext, drag.overlayId,
2263
+ { element: { ref: drag.elementRef, tag: drag.elementTag,
2264
+ style: { translate: `${drag.x}px ${drag.y}px` } } }, 'element');
2265
+ record.promise.catch(() => {
2266
+ drag.element.style.translate = drag.originalInline;
2267
+ refreshSelectionFrame();
2268
+ });
2269
+ return record;
2270
+ }
2271
+
1975
2272
  if (drag.group) return finishGroupDrag(drag);
1976
2273
  if (!drag.moved) {
1977
2274
  endDragGesture();
@@ -3266,6 +3563,13 @@ function marqueeHits(candidates, rect) {
3266
3563
  if (activeEdit?.container === hit && eventHitsElement(event, activeEdit.element)) return;
3267
3564
  clickOrigin.scopedHit = true;
3268
3565
  if (activeEdit) void commitEdit();
3566
+ if (window.akari.capabilities?.elementSelection === true && event.isTrusted && !event.shiftKey) {
3567
+ const root = fragmentRoot(hit);
3568
+ const target = event.target instanceof Element && root?.contains(event.target)
3569
+ ? nearestSelectableElement(root, event.target, stage?.getBoundingClientRect()) : null;
3570
+ clickOrigin.hitId = hit.dataset.overlayId;
3571
+ clickOrigin.elementRef = target ? elementAddress(root, target) : null;
3572
+ }
3269
3573
  const next = resolveScopedSelection(selectionTree(), scopeId, scopedHitId(hit, event),
3270
3574
  { deep: Boolean(event.metaKey || event.ctrlKey) });
3271
3575
  // Preserve the set while pressing an already selected sibling, so a plain
@@ -3274,7 +3578,17 @@ function marqueeHits(candidates, rect) {
3274
3578
  && next.scopeId === scopeId && selectedIds.includes(next.selectId);
3275
3579
  if (!keepSet && !selectScopedHit(hit, event)) return;
3276
3580
  if (collectiveSelection()) { beginGroupDrag(event, hit); return; }
3277
- if (!selectedOverlay || selectedOverlay !== hit) return;
3581
+ if (!selectedOverlay || (window.akari.capabilities?.elementSelection === true
3582
+ ? selectedId !== hit.dataset.overlayId : selectedOverlay !== hit)) return;
3583
+ if (!event.shiftKey) {
3584
+ if (clickOrigin?.hitId === selectedId) {
3585
+ focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
3586
+ } else focusElementAt(selectedOverlay, event);
3587
+ } else if (selectedElementFocus()) focusElement(null);
3588
+ if (window.akari.capabilities?.elementSelection === true && clickOrigin) {
3589
+ clickOrigin.hitId = selectedId;
3590
+ clickOrigin.elementRef = selectedElementFocus()?.ref ?? null;
3591
+ }
3278
3592
  // Continue the existing leaf drag with the already-resolved container.
3279
3593
  }
3280
3594
  const handleEl = findHandleElement(event.target);
@@ -3303,6 +3617,12 @@ function marqueeHits(candidates, rect) {
3303
3617
  }
3304
3618
 
3305
3619
  selectOverlay(container);
3620
+ if (!event.shiftKey) focusElementAt(container, event);
3621
+ else if (selectedElementFocus()) focusElement(null);
3622
+ if (window.akari.capabilities?.elementSelection === true && clickOrigin) {
3623
+ clickOrigin.hitId = selectedId;
3624
+ clickOrigin.elementRef = selectedElementFocus()?.ref ?? null;
3625
+ }
3306
3626
 
3307
3627
  if (
3308
3628
  activeEdit?.container === container &&
@@ -3323,6 +3643,23 @@ function marqueeHits(candidates, rect) {
3323
3643
 
3324
3644
  function beginLeafDrag(event, container) {
3325
3645
  if (!container || !isMovable(container)) return;
3646
+ if (elementFocus && selectedOverlay === container && focusedElement()) {
3647
+ const element = focusedElement();
3648
+ const rect = element.getBoundingClientRect();
3649
+ const originalInline = element.style.translate;
3650
+ const source = window.akari.state?.summary?.overlays?.find(overlay => overlay.id === selectedId);
3651
+ const declared = source?.elements?.[elementFocus.ref]?.style?.translate;
3652
+ const computed = getComputedStyle(element).translate;
3653
+ const initial = parseElementTranslate(declared ?? computed);
3654
+ activeDrag = { element, elementRef: elementFocus.ref, elementTag: elementFocus.tag, container,
3655
+ overlayId: selectedId, pointerId: event.pointerId, startClientX: event.clientX,
3656
+ startClientY: event.clientY, startCenterX: rect.left + rect.width / 2,
3657
+ startCenterY: rect.top + rect.height / 2, startX: initial.x, startY: initial.y,
3658
+ x: initial.x, y: initial.y,
3659
+ originalInline, moved: false, writeContext: captureWriteContext() };
3660
+ try { container.setPointerCapture?.(event.pointerId); } catch { /* synthetic pointer */ }
3661
+ return;
3662
+ }
3326
3663
  const transform = readTransform(container);
3327
3664
  window.akari.reportGesture?.('begin');
3328
3665
  const motionDriven = container.dataset.akariMotionDriven === 'true';
@@ -3415,6 +3752,12 @@ function marqueeHits(candidates, rect) {
3415
3752
 
3416
3753
  drag.moved = true;
3417
3754
  if (clickOrigin) clickOrigin.moved = true;
3755
+ if (drag.element) {
3756
+ moveElementToCenter(drag, drag.startCenterX + deltaX, drag.startCenterY + deltaY);
3757
+ refreshSelectionFrame();
3758
+ if (event.cancelable) event.preventDefault();
3759
+ return;
3760
+ }
3418
3761
  // 始点と現在点をその時点の舞台矩形で動画座標へ戻す。ズーム・全画面切替が
3419
3762
  // ドラッグ中に入っても、stale な倍率で --x/--y を計算しない。
3420
3763
  const currentStagePoint = stageLocalPoint(event.clientX, event.clientY);
@@ -4029,6 +4372,12 @@ function marqueeHits(candidates, rect) {
4029
4372
  if (nextId) applyScopedSelection({ selectId: nextId, scopeId });
4030
4373
  else if (selectionTree().length) selectScopedHit(hit, event);
4031
4374
  else selectOverlay(hit);
4375
+ if (window.akari.capabilities?.elementSelection === true
4376
+ && event.isTrusted && !event.shiftKey && selectedOverlay) {
4377
+ if (!nextId && clickOrigin?.hitId === selectedId) {
4378
+ focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
4379
+ } else focusElementAt(selectedOverlay, event);
4380
+ }
4032
4381
  if (event.shiftKey) { clickOrigin = null; return; }
4033
4382
  lastClick = event.detail === 1 && !origin.moved && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey
4034
4383
  ? { x: event.clientX, y: event.clientY, scopeId, time: now } : null;
@@ -4085,6 +4434,31 @@ function marqueeHits(candidates, rect) {
4085
4434
  function onKeyDown(event) {
4086
4435
  if (!canBeginPointerInteraction(pointerOwner)) return;
4087
4436
  if (event.isComposing) return;
4437
+ if (selectedElementFocus() && !activeEdit) {
4438
+ const stop = () => { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); };
4439
+ if (event.key === 'Escape' && activeDrag) { cancelDrag(); stop(); return; }
4440
+ if (event.key === 'Escape' || event.key === 'Enter' && event.shiftKey) {
4441
+ const root = fragmentRoot(selectedOverlay);
4442
+ const parent = nearestSelectableElement(root, focusedElement()?.parentElement,
4443
+ stage?.getBoundingClientRect());
4444
+ focusElement(parent); stop(); return;
4445
+ }
4446
+ if ((event.key === 'Delete' || event.key === 'Backspace'
4447
+ || (event.key.toLowerCase() === 'x' && (event.metaKey || event.ctrlKey)))
4448
+ && !isControl(event.target) && !isControl(document.activeElement)) {
4449
+ window.akari.showWriteError?.('要素は削除できません(Esc でアイテムを選ぶと削除できます)');
4450
+ stop(); return;
4451
+ }
4452
+ }
4453
+ if (event.key === 'Enter' && !event.shiftKey && !activeEdit && !selectedElementFocus()
4454
+ && selectedOverlay && canFocusElement(selectedOverlay)) {
4455
+ const first = firstSelectableElement(fragmentRoot(selectedOverlay), stage?.getBoundingClientRect());
4456
+ if (first) {
4457
+ focusElement(first);
4458
+ event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation();
4459
+ return;
4460
+ }
4461
+ }
4088
4462
  if (handleNudge(event)) return;
4089
4463
  if (selectionTree().length) {
4090
4464
  if (event.key === 'Enter' && event.target instanceof Element
@@ -4552,6 +4926,9 @@ function marqueeHits(candidates, rect) {
4552
4926
  setPointerOwner,
4553
4927
  releasePointerOwner,
4554
4928
  get selectedId() { return selectedId; },
4929
+ get elementFocus() { return selectedElementFocus(); },
4930
+ clearElementFocus() { if (selectedElementFocus()) focusElement(null, { notify: false }); },
4931
+ focusElementAtPoint,
4555
4932
  get selectedIds() { return [...selectedIds]; },
4556
4933
  get selectionKind() { return selectionKind(); },
4557
4934
  get scopeId() { return scopeId; },