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 +1 -1
- package/vendor/docs/contract-2026-09-02-audio-clip-fx-v1.md +20 -2
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +5 -0
- package/vendor/packages/edit-store/lib/edit-v2-item-write.js +35 -1
- package/vendor/packages/edit-store/lib/tasks-store.js +7 -4
- package/vendor/packages/media-bin/src/preview-audio-sidecar.mjs +5 -2
- package/vendor/packages/media-bin/test/audio-clip-fx.test.mjs +46 -10
- package/vendor/packages/overlay-runtime/src/element-selection.d.ts +6 -0
- package/vendor/packages/overlay-runtime/src/element-selection.mjs +61 -0
- package/vendor/packages/overlay-runtime/src/interaction.js +390 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.2.0-beta.
|
|
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=<
|
|
33
|
-
`anlmdn=s=<
|
|
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.
|
|
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": [
|
|
@@ -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
|
|
206
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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=
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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 ||
|
|
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; },
|