akari-video 1.3.0-beta.1 → 1.3.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.3.0-beta.1",
3
+ "version": "1.3.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.3.0-beta.1",
3
+ "version": "1.3.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": [
@@ -5,7 +5,7 @@ import { mkdtemp, rm, writeFile, chmod } from "node:fs/promises";
5
5
  import { syncBuiltinESMExports } from "node:module";
6
6
  import { tmpdir } from "node:os";
7
7
  import path from "node:path";
8
- import test from "node:test";
8
+ import test, { after } from "node:test";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  import { resolveFfmpeg, resolveFfprobe } from "../src/index.mjs";
@@ -37,9 +37,10 @@ function baseEnv(overrides = {}) {
37
37
  };
38
38
  }
39
39
 
40
- // PATH からシステムの ffmpeg/ffprobe を外し、vendor/ 同梱バイナリへの
41
- // フォールバックだけが効く状態を作る(task.md 記載の `env PATH=/usr/bin:/bin` と同じ狙い)。
42
- const STRIPPED_PATH_ENV = baseEnv({ PATH: "/usr/bin:/bin" });
40
+ // PATH からシステムの ffmpeg/ffprobe を OS に依らず外す。
41
+ const emptyPathDirectory = fs.mkdtempSync(path.join(tmpdir(), 'akari-media-bin-empty-path-'));
42
+ after(() => fs.rmSync(emptyPathDirectory, { recursive: true, force: true }));
43
+ const STRIPPED_PATH_ENV = baseEnv({ PATH: emptyPathDirectory });
43
44
 
44
45
  test("packagedBinaryPath: packageRoot の ../../media-bin を候補にし、win32 は .exe を付ける", () => {
45
46
  const packageRoot = fileURLToPath(new URL("../", import.meta.url));
@@ -192,7 +193,8 @@ test("resolveFfmpeg: PATH に ffmpeg があればコマンド名を返す", () =
192
193
  assert.equal(resolved, "ffmpeg");
193
194
  });
194
195
 
195
- test("resolveFfmpeg: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", () => {
196
+ test("resolveFfmpeg: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", t => {
197
+ if (!fs.existsSync(vendorBinaryPath('ffmpeg'))) return t.skip('vendor ffmpeg is not installed when postinstall is skipped');
196
198
  const resolved = resolveFfmpeg({ env: STRIPPED_PATH_ENV });
197
199
  assert.notEqual(resolved, "ffmpeg");
198
200
  assert.ok(path.isAbsolute(resolved), `絶対パスのはず: ${resolved}`);
@@ -202,7 +204,8 @@ test("resolveFfmpeg: PATH から外すと vendor 同梱バイナリへフォー
202
204
  assert.match(result.stdout, /ffmpeg version/);
203
205
  });
204
206
 
205
- test("resolveFfprobe: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", () => {
207
+ test("resolveFfprobe: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", t => {
208
+ if (!fs.existsSync(vendorBinaryPath('ffprobe'))) return t.skip('vendor ffprobe is not installed when postinstall is skipped');
206
209
  const resolved = resolveFfprobe({ env: STRIPPED_PATH_ENV });
207
210
  assert.notEqual(resolved, "ffprobe");
208
211
  assert.ok(path.isAbsolute(resolved), `絶対パスのはず: ${resolved}`);
@@ -100,7 +100,7 @@ test('サイドカー生成中もイベントループは止まらない(spawn
100
100
  assert.equal(result.ok, true, result.reason);
101
101
  assert.equal(result.skipped, false);
102
102
  assert.deepEqual(Object.keys(result).sort(),
103
- ['channels', 'durationSec', 'key', 'ok', 'path', 'reason', 'sampleRate', 'skipped']);
103
+ ['channels', 'durationSec', 'format', 'key', 'ok', 'path', 'reason', 'sampleRate', 'skipped']);
104
104
  assert.equal(result.sampleRate, 48000);
105
105
  assert.equal(result.channels, 1);
106
106
  assert.equal(result.durationSec, 1);
@@ -1,6 +1,16 @@
1
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;
2
+ export interface SelectionBounds { left: number; top: number; right: number; bottom: number; width: number; height: number }
3
+ export declare function drawsSelectionContent(element: Element, style?: CSSStyleDeclaration): boolean;
4
+ export declare function selectionContentBounds(element: Element, options?: {
5
+ container?: Element;
6
+ visibleRect?: (rect: DOMRect, element: Element) => SelectionBounds | null;
7
+ rawRect?: (element: Element) => DOMRect;
8
+ }): SelectionBounds | null;
9
+ export declare function isSelectionContainer(element: Element): boolean;
10
+ export declare function selectableContainer(root: Element, element: Element, outputRect: DOMRect | undefined, bounds: SelectionBounds | null, options?: SelectionOptions): boolean;
11
+ export interface SelectionOptions { boundsFor?: (element: Element) => SelectionBounds | null }
12
+ export declare function selectableElement(root: Element, element: Element, outputRect?: DOMRect, options?: SelectionOptions): boolean;
13
+ export declare function nearestSelectableElement(root: Element, hit: Element, outputRect?: DOMRect, options?: SelectionOptions): Element | null;
14
+ export declare function firstSelectableElement(root: Element, outputRect?: DOMRect, options?: SelectionOptions): Element | null;
5
15
  export declare function elementByAddress(root: Element, ref: string): Element | null;
6
16
  export declare function elementLabel(root: Element, element: Element): string;
@@ -17,30 +17,163 @@ function elementAddress(root, element) {
17
17
  return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
18
18
  }
19
19
 
20
- function selectableElement(root, element, outputRect) {
20
+ function drawsSelectionContent(element, style = getComputedStyle(element)) {
21
+ const tag = element.tagName.toUpperCase();
22
+ if (['BASE', 'HEAD', 'LINK', 'META', 'NOSCRIPT', 'SCRIPT', 'STYLE', 'TEMPLATE', 'TITLE'].includes(tag)) return false;
23
+ if (['AUDIO', 'CANVAS', 'EMBED', 'IFRAME', 'IMG', 'OBJECT', 'SVG', 'VIDEO'].includes(tag)) return true;
24
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg'
25
+ && ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'use', 'text', 'image'].includes(tag.toLowerCase())) return true;
26
+ if ([...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim())) return true;
27
+ const transparent = value => {
28
+ const color = String(value ?? '').trim().toLowerCase();
29
+ if (!color || color === 'transparent') return true;
30
+ const legacy = color.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+)\)$/);
31
+ if (legacy) return Number(legacy[1]) <= 0;
32
+ const modern = color.match(/\/\s*([\d.]+)%?\s*\)$/);
33
+ return Boolean(modern && Number(modern[1]) <= 0);
34
+ };
35
+ const shadow = String(style.boxShadow ?? '').trim().toLowerCase();
36
+ const shadowColors = shadow.match(/(?:rgba?|hsla?|color)\([^)]*\)|transparent/g) ?? [];
37
+ if (!transparent(style.backgroundColor) || style.backgroundImage && style.backgroundImage !== 'none'
38
+ || shadow && shadow !== 'none' && (shadowColors.length === 0 || shadowColors.some(color => !transparent(color)))) return true;
39
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
40
+ if (parseFloat(style[`border${side}Width`]) > 0
41
+ && !['none', 'hidden'].includes(style[`border${side}Style`])
42
+ && !transparent(style[`border${side}Color`])) return true;
43
+ }
44
+ return parseFloat(style.outlineWidth) > 0 && !['none', 'hidden'].includes(style.outlineStyle)
45
+ && !transparent(style.outlineColor);
46
+ }
47
+
48
+ function selectionVisibleRect(rect, element, container) {
49
+ if (!rect || !(rect.width > 0 && rect.height > 0)) return null;
50
+ let { left, top, right, bottom } = rect;
51
+ for (let node = element; node && node !== container; node = node.parentElement) {
52
+ const style = getComputedStyle(node);
53
+ const paint = /\b(?:paint|content|strict)\b/.test(style.contain ?? '');
54
+ const clipX = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowX || style.overflow) !== 'visible';
55
+ const clipY = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowY || style.overflow) !== 'visible';
56
+ if (clipX || clipY) {
57
+ const clip = node.getBoundingClientRect();
58
+ if (clipX) { left = Math.max(left, clip.left); right = Math.min(right, clip.right); }
59
+ if (clipY) { top = Math.max(top, clip.top); bottom = Math.min(bottom, clip.bottom); }
60
+ }
61
+ if (right <= left || bottom <= top) return null;
62
+ }
63
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
64
+ }
65
+
66
+ function selectionContentBounds(element, options = {}) {
67
+ if (!element) return null;
68
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
69
+ for (const node of [element, ...element.querySelectorAll('*')]) {
70
+ if (node.closest('[data-akari-interaction]') || node.closest('defs, symbol, pattern, clipPath, mask, marker')) continue;
71
+ let visible = true;
72
+ for (let ancestor = node; ancestor && ancestor !== options.container; ancestor = ancestor.parentElement) {
73
+ const style = getComputedStyle(ancestor);
74
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
75
+ || Number(style.opacity) === 0) { visible = false; break; }
76
+ }
77
+ if (!visible || !drawsSelectionContent(node)) continue;
78
+ if (node.tagName.toLowerCase() === 'svg'
79
+ && node.querySelector('path, rect, circle, ellipse, line, polyline, polygon, use, text, image')
80
+ && getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)') continue;
81
+ const raw = options.rawRect?.(node) ?? node.getBoundingClientRect();
82
+ const rect = options.visibleRect ? options.visibleRect(raw, node)
83
+ : selectionVisibleRect(raw, node, options.container);
84
+ if (!rect || !(rect.width > 0 && rect.height > 0)) continue;
85
+ left = Math.min(left, rect.left); top = Math.min(top, rect.top);
86
+ right = Math.max(right, rect.right); bottom = Math.max(bottom, rect.bottom);
87
+ }
88
+ return Number.isFinite(left) ? { left, top, right, bottom, width: right - left, height: bottom - top } : null;
89
+ }
90
+
91
+ function selectableContentBranches(root, element, outputRect) {
92
+ let count = 0;
93
+ const visit = node => {
94
+ if (count > 1) return;
95
+ const style = getComputedStyle(node);
96
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
97
+ || Number(style.opacity) === 0 || isRuntimeElement(node)) return;
98
+ const rect = node.getBoundingClientRect();
99
+ if (node !== element && elementAddress(root, node) && rect.width > 1 && rect.height > 1
100
+ && drawsSelectionContent(node) && (!outputRect || rect.width < outputRect.width * 0.95
101
+ || rect.height < outputRect.height * 0.95)) { count++; return; }
102
+ for (const child of node.children) visit(child);
103
+ };
104
+ for (const child of element.children) visit(child);
105
+ return count;
106
+ }
107
+
108
+ function isSelectionContainer(element) {
109
+ return Boolean(element?.children?.length) && !drawsSelectionContent(element);
110
+ }
111
+
112
+ function selectionBoundsFor(element, options) {
113
+ const memo = options.boundsMemo ??= new Map();
114
+ if (!memo.has(element)) memo.set(element,
115
+ options.boundsFor ? options.boundsFor(element) : selectionContentBounds(element));
116
+ return memo.get(element);
117
+ }
118
+
119
+ function hasUncoveredPaintedContent(root, element, outputRect, options) {
120
+ const visit = node => {
121
+ if (node.closest('[data-akari-interaction], defs, symbol, pattern, clipPath, mask, marker')) return false;
122
+ if (selectableElement(root, node, outputRect, options)) return false;
123
+ const rect = node.getBoundingClientRect();
124
+ if (rect.width > 0 && Number(getComputedStyle(node).opacity) !== 0
125
+ && drawsSelectionContent(node)) return true;
126
+ return [...node.children].some(visit);
127
+ };
128
+ return [...element.children].some(visit);
129
+ }
130
+
131
+ function selectableContainer(root, element, outputRect, bounds, options = {}) {
132
+ if (!isSelectionContainer(element)) return true;
133
+ if (!bounds) return false;
134
+ // Keep an animated wrapper reachable while one of its painted phases is visible.
135
+ if (hasUncoveredPaintedContent(root, element, outputRect, options)) return true;
136
+ if (selectableContentBranches(root, element, outputRect) <= 1) return false;
137
+ const box = element.getBoundingClientRect();
138
+ return !(outputRect && (box.width >= outputRect.width * 0.5 || box.height >= outputRect.height * 0.5)
139
+ && (box.width >= bounds.width * 1.5 || box.height >= bounds.height * 1.5));
140
+ }
141
+
142
+ function selectableElement(root, element, outputRect, options = {}) {
143
+ const memo = options.selectableMemo ??= new Map();
144
+ if (memo.has(element)) return memo.get(element);
21
145
  if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
22
146
  || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
23
- || !elementAddress(root, element)) return false;
147
+ || !elementAddress(root, element)) { memo.set(element, false); return false; }
24
148
  const rect = element.getBoundingClientRect();
25
- if (!(rect.width > 1 && rect.height > 1)) return false;
149
+ if (!(rect.width > 1 && rect.height > 1)) { memo.set(element, false); return false; }
26
150
  for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
27
151
  const style = getComputedStyle(ancestor);
28
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
152
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
153
+ memo.set(element, false); return false;
154
+ }
29
155
  }
156
+ const bounds = selectionBoundsFor(element, options);
157
+ const coverage = bounds ?? rect;
30
158
  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;
159
+ const eligible = (replaced || !outputRect || coverage.width < outputRect.width * 0.95
160
+ || coverage.height < outputRect.height * 0.95)
161
+ && selectableContainer(root, element, outputRect, bounds, options);
162
+ memo.set(element, eligible);
163
+ return eligible;
33
164
  }
34
165
 
35
- function nearestSelectableElement(root, hit, outputRect) {
166
+ function nearestSelectableElement(root, hit, outputRect, options = {}) {
167
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
36
168
  for (let element = hit; element && element !== root; element = element.parentElement) {
37
- if (selectableElement(root, element, outputRect)) return element;
169
+ if (selectableElement(root, element, outputRect, options)) return element;
38
170
  }
39
171
  return null;
40
172
  }
41
173
 
42
- function firstSelectableElement(root, outputRect) {
43
- return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
174
+ function firstSelectableElement(root, outputRect, options = {}) {
175
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
176
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect, options)) ?? null;
44
177
  }
45
178
 
46
179
  function elementByAddress(root, ref) {
@@ -57,5 +190,6 @@ function elementLabel(root, element) {
57
190
  return siblings.length > 1 ? `${name} ${siblings.indexOf(element) + 1}` : name;
58
191
  }
59
192
  // END element-selection
60
- export { isRuntimeElement, elementAddress, selectableElement, nearestSelectableElement,
193
+ export { isRuntimeElement, elementAddress, drawsSelectionContent, selectionContentBounds,
194
+ isSelectionContainer, selectableContainer, selectableElement, nearestSelectableElement,
61
195
  firstSelectableElement, elementByAddress, elementLabel };
@@ -122,30 +122,163 @@ function elementAddress(root, element) {
122
122
  return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
123
123
  }
124
124
 
125
- function selectableElement(root, element, outputRect) {
125
+ function drawsSelectionContent(element, style = getComputedStyle(element)) {
126
+ const tag = element.tagName.toUpperCase();
127
+ if (['BASE', 'HEAD', 'LINK', 'META', 'NOSCRIPT', 'SCRIPT', 'STYLE', 'TEMPLATE', 'TITLE'].includes(tag)) return false;
128
+ if (['AUDIO', 'CANVAS', 'EMBED', 'IFRAME', 'IMG', 'OBJECT', 'SVG', 'VIDEO'].includes(tag)) return true;
129
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg'
130
+ && ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'use', 'text', 'image'].includes(tag.toLowerCase())) return true;
131
+ if ([...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim())) return true;
132
+ const transparent = value => {
133
+ const color = String(value ?? '').trim().toLowerCase();
134
+ if (!color || color === 'transparent') return true;
135
+ const legacy = color.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+)\)$/);
136
+ if (legacy) return Number(legacy[1]) <= 0;
137
+ const modern = color.match(/\/\s*([\d.]+)%?\s*\)$/);
138
+ return Boolean(modern && Number(modern[1]) <= 0);
139
+ };
140
+ const shadow = String(style.boxShadow ?? '').trim().toLowerCase();
141
+ const shadowColors = shadow.match(/(?:rgba?|hsla?|color)\([^)]*\)|transparent/g) ?? [];
142
+ if (!transparent(style.backgroundColor) || style.backgroundImage && style.backgroundImage !== 'none'
143
+ || shadow && shadow !== 'none' && (shadowColors.length === 0 || shadowColors.some(color => !transparent(color)))) return true;
144
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
145
+ if (parseFloat(style[`border${side}Width`]) > 0
146
+ && !['none', 'hidden'].includes(style[`border${side}Style`])
147
+ && !transparent(style[`border${side}Color`])) return true;
148
+ }
149
+ return parseFloat(style.outlineWidth) > 0 && !['none', 'hidden'].includes(style.outlineStyle)
150
+ && !transparent(style.outlineColor);
151
+ }
152
+
153
+ function selectionVisibleRect(rect, element, container) {
154
+ if (!rect || !(rect.width > 0 && rect.height > 0)) return null;
155
+ let { left, top, right, bottom } = rect;
156
+ for (let node = element; node && node !== container; node = node.parentElement) {
157
+ const style = getComputedStyle(node);
158
+ const paint = /\b(?:paint|content|strict)\b/.test(style.contain ?? '');
159
+ const clipX = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowX || style.overflow) !== 'visible';
160
+ const clipY = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowY || style.overflow) !== 'visible';
161
+ if (clipX || clipY) {
162
+ const clip = node.getBoundingClientRect();
163
+ if (clipX) { left = Math.max(left, clip.left); right = Math.min(right, clip.right); }
164
+ if (clipY) { top = Math.max(top, clip.top); bottom = Math.min(bottom, clip.bottom); }
165
+ }
166
+ if (right <= left || bottom <= top) return null;
167
+ }
168
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
169
+ }
170
+
171
+ function selectionContentBounds(element, options = {}) {
172
+ if (!element) return null;
173
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
174
+ for (const node of [element, ...element.querySelectorAll('*')]) {
175
+ if (node.closest('[data-akari-interaction]') || node.closest('defs, symbol, pattern, clipPath, mask, marker')) continue;
176
+ let visible = true;
177
+ for (let ancestor = node; ancestor && ancestor !== options.container; ancestor = ancestor.parentElement) {
178
+ const style = getComputedStyle(ancestor);
179
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
180
+ || Number(style.opacity) === 0) { visible = false; break; }
181
+ }
182
+ if (!visible || !drawsSelectionContent(node)) continue;
183
+ if (node.tagName.toLowerCase() === 'svg'
184
+ && node.querySelector('path, rect, circle, ellipse, line, polyline, polygon, use, text, image')
185
+ && getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)') continue;
186
+ const raw = options.rawRect?.(node) ?? node.getBoundingClientRect();
187
+ const rect = options.visibleRect ? options.visibleRect(raw, node)
188
+ : selectionVisibleRect(raw, node, options.container);
189
+ if (!rect || !(rect.width > 0 && rect.height > 0)) continue;
190
+ left = Math.min(left, rect.left); top = Math.min(top, rect.top);
191
+ right = Math.max(right, rect.right); bottom = Math.max(bottom, rect.bottom);
192
+ }
193
+ return Number.isFinite(left) ? { left, top, right, bottom, width: right - left, height: bottom - top } : null;
194
+ }
195
+
196
+ function selectableContentBranches(root, element, outputRect) {
197
+ let count = 0;
198
+ const visit = node => {
199
+ if (count > 1) return;
200
+ const style = getComputedStyle(node);
201
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
202
+ || Number(style.opacity) === 0 || isRuntimeElement(node)) return;
203
+ const rect = node.getBoundingClientRect();
204
+ if (node !== element && elementAddress(root, node) && rect.width > 1 && rect.height > 1
205
+ && drawsSelectionContent(node) && (!outputRect || rect.width < outputRect.width * 0.95
206
+ || rect.height < outputRect.height * 0.95)) { count++; return; }
207
+ for (const child of node.children) visit(child);
208
+ };
209
+ for (const child of element.children) visit(child);
210
+ return count;
211
+ }
212
+
213
+ function isSelectionContainer(element) {
214
+ return Boolean(element?.children?.length) && !drawsSelectionContent(element);
215
+ }
216
+
217
+ function selectionBoundsFor(element, options) {
218
+ const memo = options.boundsMemo ??= new Map();
219
+ if (!memo.has(element)) memo.set(element,
220
+ options.boundsFor ? options.boundsFor(element) : selectionContentBounds(element));
221
+ return memo.get(element);
222
+ }
223
+
224
+ function hasUncoveredPaintedContent(root, element, outputRect, options) {
225
+ const visit = node => {
226
+ if (node.closest('[data-akari-interaction], defs, symbol, pattern, clipPath, mask, marker')) return false;
227
+ if (selectableElement(root, node, outputRect, options)) return false;
228
+ const rect = node.getBoundingClientRect();
229
+ if (rect.width > 0 && Number(getComputedStyle(node).opacity) !== 0
230
+ && drawsSelectionContent(node)) return true;
231
+ return [...node.children].some(visit);
232
+ };
233
+ return [...element.children].some(visit);
234
+ }
235
+
236
+ function selectableContainer(root, element, outputRect, bounds, options = {}) {
237
+ if (!isSelectionContainer(element)) return true;
238
+ if (!bounds) return false;
239
+ // Keep an animated wrapper reachable while one of its painted phases is visible.
240
+ if (hasUncoveredPaintedContent(root, element, outputRect, options)) return true;
241
+ if (selectableContentBranches(root, element, outputRect) <= 1) return false;
242
+ const box = element.getBoundingClientRect();
243
+ return !(outputRect && (box.width >= outputRect.width * 0.5 || box.height >= outputRect.height * 0.5)
244
+ && (box.width >= bounds.width * 1.5 || box.height >= bounds.height * 1.5));
245
+ }
246
+
247
+ function selectableElement(root, element, outputRect, options = {}) {
248
+ const memo = options.selectableMemo ??= new Map();
249
+ if (memo.has(element)) return memo.get(element);
126
250
  if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
127
251
  || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
128
- || !elementAddress(root, element)) return false;
252
+ || !elementAddress(root, element)) { memo.set(element, false); return false; }
129
253
  const rect = element.getBoundingClientRect();
130
- if (!(rect.width > 1 && rect.height > 1)) return false;
254
+ if (!(rect.width > 1 && rect.height > 1)) { memo.set(element, false); return false; }
131
255
  for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
132
256
  const style = getComputedStyle(ancestor);
133
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
257
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
258
+ memo.set(element, false); return false;
259
+ }
134
260
  }
261
+ const bounds = selectionBoundsFor(element, options);
262
+ const coverage = bounds ?? rect;
135
263
  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;
264
+ const eligible = (replaced || !outputRect || coverage.width < outputRect.width * 0.95
265
+ || coverage.height < outputRect.height * 0.95)
266
+ && selectableContainer(root, element, outputRect, bounds, options);
267
+ memo.set(element, eligible);
268
+ return eligible;
138
269
  }
139
270
 
140
- function nearestSelectableElement(root, hit, outputRect) {
271
+ function nearestSelectableElement(root, hit, outputRect, options = {}) {
272
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
141
273
  for (let element = hit; element && element !== root; element = element.parentElement) {
142
- if (selectableElement(root, element, outputRect)) return element;
274
+ if (selectableElement(root, element, outputRect, options)) return element;
143
275
  }
144
276
  return null;
145
277
  }
146
278
 
147
- function firstSelectableElement(root, outputRect) {
148
- return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
279
+ function firstSelectableElement(root, outputRect, options = {}) {
280
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
281
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect, options)) ?? null;
149
282
  }
150
283
 
151
284
  function elementByAddress(root, ref) {
@@ -289,6 +422,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
289
422
  let selectedId = null;
290
423
  let selectedIds = [];
291
424
  let elementFocus = null;
425
+ // Inner selection belongs only to the currently selected telop, never to edit.json.
426
+ let telopInnerId = null;
292
427
  let elementGeometryCache = null;
293
428
  let elementNudge = null;
294
429
  let elementNudgeTimer = null;
@@ -576,6 +711,21 @@ function elementBoxCompanions(style, parentStyle, width, height) {
576
711
  return null;
577
712
  }
578
713
 
714
+ function contentBounds(element, container) {
715
+ return selectionContentBounds(element, {
716
+ container,
717
+ visibleRect: (rect, node) => visibleFragmentRect(rect, node, container),
718
+ rawRect: node => node.tagName.toUpperCase() === "CANVAS"
719
+ ? (canvasHasDecoration(node) ? node.getBoundingClientRect()
720
+ : window.akari.threeRuntime?.contentBounds?.(node)
721
+ ?? measureCanvasBounds(node) ?? node.getBoundingClientRect())
722
+ : node.getBoundingClientRect(),
723
+ });
724
+ }
725
+ function selectionOptions(root) {
726
+ return { boundsFor: element => contentBounds(element, root?.parentElement) };
727
+ }
728
+
579
729
  // 断片ルートは「inset:0 全画面ラッパー + flex/絶対配置」パターン(overlay-authoring
580
730
  // 規約・text-behind-person.md 等)を許容するため、ルート自身の矩形がコンテナ(断片の
581
731
  // 親 = [data-overlay-id] 要素。overlay-runtime.js の mount() が inset:0 で全画面付与)
@@ -594,7 +744,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
594
744
  );
595
745
  }
596
746
 
597
- function fragmentBounds(container) {
747
+ function legacyFragmentBounds(container) {
598
748
  const root = fragmentRoot(container);
599
749
  if (!root) return null;
600
750
 
@@ -684,6 +834,13 @@ function elementBoxCompanions(style, parentStyle, width, height) {
684
834
  return { left, top, right, bottom, width: right - left, height: bottom - top };
685
835
  }
686
836
 
837
+ function fragmentBounds(container) {
838
+ const root = fragmentRoot(container);
839
+ if (!root) return null;
840
+ if (drawsOwnContent(root, getComputedStyle(root))) return legacyFragmentBounds(container);
841
+ return contentBounds(root, container) ?? legacyFragmentBounds(container);
842
+ }
843
+
687
844
  function svgReferenceRect(element, value) {
688
845
  const id = String(value ?? '').match(/url\(["']?(?:[^#)]*#)([^)"']+)["']?\)/)?.[1];
689
846
  const svg = element.namespaceURI === 'http://www.w3.org/2000/svg'
@@ -1507,7 +1664,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1507
1664
  const transform = readTransform(selectedOverlay);
1508
1665
  const lineRect = lineFrameGeometry(selectedOverlay);
1509
1666
  const focused = focusedElement();
1510
- const focusedGeometry = focused ? elementScreenGeometry(focused) : null;
1667
+ const focusedGeometry = focused ? focusedSelectionGeometry(focused) : null;
1511
1668
  const focusedWidth = focusedGeometry && focusedGeometry.width * Math.hypot(focusedGeometry.axes.x.x, focusedGeometry.axes.x.y);
1512
1669
  const focusedHeight = focusedGeometry && focusedGeometry.height * Math.hypot(focusedGeometry.axes.y.x, focusedGeometry.axes.y.y);
1513
1670
  const focusedAngle = focusedGeometry && Math.atan2(focusedGeometry.axes.x.y, focusedGeometry.axes.x.x) * 180 / Math.PI;
@@ -1564,7 +1721,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1564
1721
  }
1565
1722
  const layout = elementHandleLayout(focusedWidth, focusedHeight);
1566
1723
  const name = [...handle.classList].find(token => /^is-(?:n|e|s|w|nw|ne|se|sw|rotate|move)$/.test(token))?.slice(3);
1567
- handle.style.display = name === 'move' ||
1724
+ handle.style.display = name === 'move' || (isSelectionContainer(focused) && name !== 'rotate') ||
1568
1725
  (['n', 's'].includes(name) && layout.hideHorizontalEdges) ||
1569
1726
  (['e', 'w'].includes(name) && layout.hideVerticalEdges) ? 'none' : '';
1570
1727
  if (name === 'rotate') {
@@ -1575,6 +1732,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1575
1732
  const y = name.includes('n') ? -layout.cornerOffsetY : layout.cornerOffsetY;
1576
1733
  if (x || y) handle.style.translate = `${x}px ${y}px`;
1577
1734
  else handle.style.removeProperty('translate');
1735
+ } else if (['n', 's', 'e', 'w'].includes(name)) {
1736
+ const x = name === 'w' ? -layout.cornerOffsetX : name === 'e' ? layout.cornerOffsetX : 0;
1737
+ const y = name === 'n' ? -layout.cornerOffsetY : name === 's' ? layout.cornerOffsetY : 0;
1738
+ if (x || y) handle.style.translate = `${x}px ${y}px`;
1739
+ else handle.style.removeProperty('translate');
1578
1740
  }
1579
1741
  if (name && name !== 'move' && name !== 'rotate') {
1580
1742
  const directions = ['ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize'];
@@ -1659,8 +1821,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1659
1821
 
1660
1822
  function clearSelection() {
1661
1823
  const hadElementFocus = window.akari.capabilities?.elementSelection === true && Boolean(selectedElementFocus());
1824
+ const hadTelopSelection = window.akari.capabilities?.elementSelection === true
1825
+ && selectedOverlay && isTelopOverlay(selectedOverlay);
1662
1826
  flushElementNudge();
1663
1827
  elementFocus = null;
1828
+ telopInnerId = null;
1664
1829
  if (activeRotate) cancelRotate();
1665
1830
  if (activeLine) finishLineEndpoint(true);
1666
1831
  flushNudge();
@@ -1679,7 +1844,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1679
1844
  selectionFrame?.remove();
1680
1845
  selectionFrame = null;
1681
1846
  hideSnapGuides();
1682
- if (hadElementFocus) renderScopeBreadcrumb();
1847
+ if (hadElementFocus || hadTelopSelection) renderScopeBreadcrumb();
1683
1848
  }
1684
1849
 
1685
1850
  function handleSelectedOverlayUnavailable(container) {
@@ -1719,27 +1884,45 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1719
1884
  selectedOverlay.setAttribute('data-akari-interaction-selected', 'true');
1720
1885
  reconcileElementFocus();
1721
1886
  } else {
1887
+ const keepTelopInner = selectedId === nextId && telopInnerId === nextId
1888
+ && selectedIds.length === 1 && !groupSelection;
1722
1889
  clearSelection();
1723
1890
  selectedOverlay = container;
1724
1891
  selectedId = nextId;
1725
1892
  selectedIds = selectedId === null ? [] : [selectedId];
1893
+ if (keepTelopInner) telopInnerId = nextId;
1726
1894
  selectedOverlay.setAttribute("data-akari-interaction-selected", "true");
1727
1895
  }
1728
1896
  }
1729
1897
 
1730
1898
  refreshSelectionFrame();
1899
+ if (window.akari.capabilities?.elementSelection === true && isTelopOverlay(selectedOverlay)) renderScopeBreadcrumb();
1731
1900
  startSelectionTracking();
1732
1901
  return true;
1733
1902
  }
1734
1903
 
1735
- function canFocusElement(container) {
1904
+ function elementSelectionEnabled(container) {
1736
1905
  if (window.akari.capabilities?.elementSelection !== true || !container || selectedIds.length > 1) return false;
1737
1906
  return window.akari.state?.summary?.overlays?.some(overlay => overlay.id === container.dataset.overlayId
1738
1907
  && overlay.elementSelection === true) === true;
1739
1908
  }
1909
+ function canFocusElement(container) {
1910
+ return elementSelectionEnabled(container)
1911
+ && (!isTelopOverlay(container) || telopInnerId === selectedId && selectedId === container.dataset.overlayId);
1912
+ }
1913
+ function setTelopInnerSelection(overlayId, inner) {
1914
+ if (typeof overlayId !== 'string' || selectedId !== overlayId || selectedIds.length !== 1
1915
+ || groupSelection || !selectedOverlay || !isTelopOverlay(selectedOverlay)
1916
+ || !elementSelectionEnabled(selectedOverlay)) return false;
1917
+ telopInnerId = inner === true ? overlayId : null;
1918
+ if (!telopInnerId && selectedElementFocus()) focusElement(null);
1919
+ hideHover();
1920
+ return true;
1921
+ }
1740
1922
  function focusedElement() {
1741
1923
  if (!selectedElementFocus() || !selectedOverlay) return null;
1742
- return elementByAddress(fragmentRoot(selectedOverlay), elementFocus.ref);
1924
+ const root = fragmentRoot(selectedOverlay);
1925
+ return elementByAddress(root, elementFocus.ref);
1743
1926
  }
1744
1927
  function selectedElementFocus() {
1745
1928
  return elementFocus?.overlayId === selectedId
@@ -1748,13 +1931,27 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1748
1931
  function reconcileElementFocus() {
1749
1932
  if (!selectedElementFocus()) return;
1750
1933
  const root = fragmentRoot(selectedOverlay);
1751
- if (root && !elementByAddress(root, elementFocus.ref)) focusElement(null);
1934
+ if (!root) return;
1935
+ const ref = elementFocus.ref;
1936
+ const element = elementByAddress(root, ref);
1937
+ if (!element) { focusElement(null); return; }
1938
+ if (selectedOverlay?.isConnected && !selectableElement(root, element, stage?.getBoundingClientRect(), selectionOptions(root))) {
1939
+ requestAnimationFrame(() => {
1940
+ if (elementFocus?.ref === ref && selectedOverlay?.isConnected) {
1941
+ const currentRoot = fragmentRoot(selectedOverlay);
1942
+ const current = elementByAddress(currentRoot, ref);
1943
+ if (!current || !selectableElement(currentRoot, current, stage?.getBoundingClientRect(), selectionOptions(currentRoot))) focusElement(null);
1944
+ }
1945
+ });
1946
+ }
1752
1947
  }
1753
1948
  function focusElement(element, { notify = true } = {}) {
1754
1949
  const root = fragmentRoot(selectedOverlay);
1950
+ if (element && !selectableElement(root, element, stage?.getBoundingClientRect(), selectionOptions(root))) element = null;
1755
1951
  const ref = element && elementAddress(root, element);
1756
1952
  const next = ref ? { overlayId: selectedId, ref, tag: element.tagName.toLowerCase(),
1757
1953
  label: elementLabel(root, element) } : null;
1954
+ if (!next && telopInnerId === selectedId) telopInnerId = null;
1758
1955
  if (elementFocus?.ref === next?.ref && elementFocus?.overlayId === next?.overlayId) return;
1759
1956
  flushElementNudge();
1760
1957
  elementFocus = next;
@@ -1770,7 +1967,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1770
1967
  const root = fragmentRoot(container);
1771
1968
  const hit = event.target instanceof Element && root?.contains(event.target) ? event.target
1772
1969
  : document.elementsFromPoint(event.clientX, event.clientY).find(element => root?.contains(element)) ?? null;
1773
- focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect()));
1970
+ focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect(), selectionOptions(root)));
1774
1971
  }
1775
1972
  function focusElementAtPoint(overlayId, clientX, clientY, attempt = 0) {
1776
1973
  if (window.akari.capabilities?.elementSelection !== true || !Number.isFinite(clientX)
@@ -1789,7 +1986,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1789
1986
  return false;
1790
1987
  }
1791
1988
  const hit = document.elementsFromPoint(clientX, clientY).find(element => root.contains(element)) ?? null;
1792
- focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect()));
1989
+ focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect(), selectionOptions(root)));
1793
1990
  return true;
1794
1991
  }
1795
1992
 
@@ -1873,7 +2070,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1873
2070
  const element = target.element?.isConnected ? target.element
1874
2071
  : elementByAddress(fragmentRoot(containerById(target.overlayId)), target.ref);
1875
2072
  if (!element?.isConnected) return null;
1876
- const geometry = elementScreenGeometry(element);
2073
+ const geometry = focusedSelectionGeometry(element);
1877
2074
  const width = geometry.width * Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
1878
2075
  const height = geometry.height * Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
1879
2076
  return { rect: { left: geometry.center.x - width / 2, top: geometry.center.y - height / 2,
@@ -1889,7 +2086,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1889
2086
  clearBreadcrumbHover();
1890
2087
  const entries = [];
1891
2088
  const append = (label, action, target) => entries.push({ label, action, target });
1892
- if (selectedElementFocus() && selectedOverlay) {
2089
+ if (selectedOverlay && (selectedElementFocus() || window.akari.capabilities?.elementSelection === true
2090
+ && selectedIds.length === 1 && isTelopOverlay(selectedOverlay))) {
1893
2091
  append('全体', () => { clearSelection(); publishScopedSelection(); }, { kind: 'stage' });
1894
2092
  for (const id of lineage(selectionTree(), scopeId)) {
1895
2093
  append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }),
@@ -1900,7 +2098,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1900
2098
  const root = fragmentRoot(selectedOverlay);
1901
2099
  const path = [];
1902
2100
  for (let node = focusedElement(); node && node !== root; node = node.parentElement) {
1903
- if (selectableElement(root, node, stage?.getBoundingClientRect())) path.unshift(node);
2101
+ if (selectableElement(root, node, stage?.getBoundingClientRect(), selectionOptions(root))) path.unshift(node);
1904
2102
  }
1905
2103
  for (const node of path) append(elementLabel(root, node), () => focusElement(node),
1906
2104
  { kind: 'element', element: node, overlayId: selectedId, ref: elementAddress(root, node) });
@@ -2391,6 +2589,48 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2391
2589
  elementGeometryCache = { element, signature, geometry };
2392
2590
  return geometry;
2393
2591
  }
2592
+ function focusedSelectionGeometry(element) {
2593
+ const geometry = elementScreenGeometry(element);
2594
+ if (!isSelectionContainer(element)) return geometry;
2595
+ const container = selectedOverlay ?? element.parentElement;
2596
+ const bounds = contentBounds(element, container);
2597
+ if (!bounds) return geometry;
2598
+ const scaleX = Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
2599
+ const scaleY = Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
2600
+ if (!(scaleX > 0 && scaleY > 0)) return geometry;
2601
+ const ux = { x: geometry.axes.x.x / scaleX, y: geometry.axes.x.y / scaleX };
2602
+ const uy = { x: geometry.axes.y.x / scaleY, y: geometry.axes.y.y / scaleY };
2603
+ const angle = Math.abs(Math.atan2(ux.y, ux.x));
2604
+ if (angle < 0.001) return { ...geometry,
2605
+ center: { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 },
2606
+ width: bounds.width / scaleX, height: bounds.height / scaleY };
2607
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2608
+ for (const node of element.querySelectorAll('*')) {
2609
+ if (!drawsSelectionContent(node) || !isPaintedElement(node, container)) continue;
2610
+ const rect = visibleFragmentRect(node.getBoundingClientRect(), node, container);
2611
+ if (!rect) continue;
2612
+ const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
2613
+ const dx = center.x - geometry.center.x, dy = center.y - geometry.center.y;
2614
+ const x = dx * ux.x + dy * ux.y, y = dx * uy.x + dy * uy.y;
2615
+ const halfX = (node.offsetWidth || rect.width / scaleX) * scaleX / 2;
2616
+ const halfY = (node.offsetHeight || rect.height / scaleY) * scaleY / 2;
2617
+ minX = Math.min(minX, x - halfX); maxX = Math.max(maxX, x + halfX);
2618
+ minY = Math.min(minY, y - halfY); maxY = Math.max(maxY, y + halfY);
2619
+ }
2620
+ if (!Number.isFinite(minX)) return geometry;
2621
+ const midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;
2622
+ return { ...geometry, center: { x: geometry.center.x + midX * ux.x + midY * uy.x,
2623
+ y: geometry.center.y + midX * ux.y + midY * uy.y },
2624
+ width: (maxX - minX) / scaleX, height: (maxY - minY) / scaleY };
2625
+ }
2626
+ function focusedSelectionRect(element) {
2627
+ const geometry = focusedSelectionGeometry(element);
2628
+ const width = geometry.width * Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
2629
+ const height = geometry.height * Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
2630
+ return { left: geometry.center.x - width / 2, top: geometry.center.y - height / 2,
2631
+ right: geometry.center.x + width / 2, bottom: geometry.center.y + height / 2,
2632
+ width, height };
2633
+ }
2394
2634
  function moveElementPointTo(gesture, horizontal, vertical, target) {
2395
2635
  const geometry = elementScreenGeometry(gesture.element);
2396
2636
  const point = elementPoint(geometry, horizontal, vertical);
@@ -2756,8 +2996,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2756
2996
  && (!scoped || scoped.selectId === container.dataset.overlayId)) {
2757
2997
  const root = fragmentRoot(container);
2758
2998
  const hit = event.target instanceof Element && root?.contains(event.target) ? event.target : null;
2759
- const element = nearestSelectableElement(root, hit, stage?.getBoundingClientRect());
2760
- const rect = element?.getBoundingClientRect() ?? fragmentBounds(container);
2999
+ const element = nearestSelectableElement(root, hit, stage?.getBoundingClientRect(), selectionOptions(root));
3000
+ const rect = element ? focusedSelectionRect(element) : fragmentBounds(container);
2761
3001
  if (!rect || selectedOverlay === container
2762
3002
  && selectedElementFocus()?.ref === elementAddress(root, element)) { hideHover(); return; }
2763
3003
  showHoverFrame(rect, null, container.dataset.overlayId, false, false);
@@ -4167,10 +4407,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4167
4407
  if (activeEdit?.container === hit && eventHitsElement(event, activeEdit.element)) return;
4168
4408
  clickOrigin.scopedHit = true;
4169
4409
  if (activeEdit) void commitEdit();
4170
- if (window.akari.capabilities?.elementSelection === true && event.isTrusted && !event.shiftKey) {
4410
+ if (window.akari.capabilities?.elementSelection === true && event.isTrusted && !event.shiftKey
4411
+ && (!isTelopOverlay(hit) || canFocusElement(hit))) {
4171
4412
  const root = fragmentRoot(hit);
4172
4413
  const target = event.target instanceof Element && root?.contains(event.target)
4173
- ? nearestSelectableElement(root, event.target, stage?.getBoundingClientRect()) : null;
4414
+ ? nearestSelectableElement(root, event.target, stage?.getBoundingClientRect(), selectionOptions(root)) : null;
4174
4415
  clickOrigin.hitId = hit.dataset.overlayId;
4175
4416
  clickOrigin.elementRef = target ? elementAddress(root, target) : null;
4176
4417
  }
@@ -4185,7 +4426,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4185
4426
  if (!selectedOverlay || (window.akari.capabilities?.elementSelection === true
4186
4427
  ? selectedId !== hit.dataset.overlayId : selectedOverlay !== hit)) return;
4187
4428
  if (!event.shiftKey) {
4188
- if (clickOrigin?.hitId === selectedId) {
4429
+ const focused = focusedElement();
4430
+ if (focused && isSelectionContainer(focused) && focused.contains(event.target)) {
4431
+ clickOrigin.preservedContainer = true;
4432
+ } else if ((!isTelopOverlay(selectedOverlay) || canFocusElement(selectedOverlay))
4433
+ && clickOrigin?.hitId === selectedId) {
4189
4434
  focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
4190
4435
  } else focusElementAt(selectedOverlay, event);
4191
4436
  } else if (selectedElementFocus()) focusElement(null);
@@ -4221,7 +4466,12 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4221
4466
  }
4222
4467
 
4223
4468
  selectOverlay(container);
4224
- if (!event.shiftKey) focusElementAt(container, event);
4469
+ if (!event.shiftKey) {
4470
+ const focused = focusedElement();
4471
+ if (focused && isSelectionContainer(focused) && focused.contains(event.target)) {
4472
+ clickOrigin.preservedContainer = true;
4473
+ } else focusElementAt(container, event);
4474
+ }
4225
4475
  else if (selectedElementFocus()) focusElement(null);
4226
4476
  if (window.akari.capabilities?.elementSelection === true && clickOrigin) {
4227
4477
  clickOrigin.hitId = selectedId;
@@ -4984,7 +5234,10 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4984
5234
  else selectOverlay(hit);
4985
5235
  if (window.akari.capabilities?.elementSelection === true
4986
5236
  && event.isTrusted && !event.shiftKey && selectedOverlay) {
4987
- if (!nextId && clickOrigin?.hitId === selectedId) {
5237
+ if (clickOrigin?.preservedContainer) focusElementAt(selectedOverlay, event);
5238
+ else if ((!isTelopOverlay(selectedOverlay) || canFocusElement(selectedOverlay))
5239
+ && (!nextId || isTelopOverlay(selectedOverlay) && telopInnerId === selectedId
5240
+ && nextId === selectedId) && clickOrigin?.hitId === selectedId) {
4988
5241
  focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
4989
5242
  } else focusElementAt(selectedOverlay, event);
4990
5243
  }
@@ -5052,7 +5305,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5052
5305
  if (event.key === 'Escape' || event.key === 'Enter' && event.shiftKey) {
5053
5306
  const root = fragmentRoot(selectedOverlay);
5054
5307
  const parent = nearestSelectableElement(root, focusedElement()?.parentElement,
5055
- stage?.getBoundingClientRect());
5308
+ stage?.getBoundingClientRect(), selectionOptions(root));
5056
5309
  focusElement(parent); stop(); return;
5057
5310
  }
5058
5311
  if ((event.key === 'Delete' || event.key === 'Backspace'
@@ -5063,9 +5316,12 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5063
5316
  }
5064
5317
  }
5065
5318
  if (event.key === 'Enter' && !event.shiftKey && !activeEdit && !selectedElementFocus()
5066
- && selectedOverlay && canFocusElement(selectedOverlay)) {
5067
- const first = firstSelectableElement(fragmentRoot(selectedOverlay), stage?.getBoundingClientRect());
5319
+ && selectedOverlay && elementSelectionEnabled(selectedOverlay)
5320
+ && (!isTelopOverlay(selectedOverlay) || selectedIds.length === 1)) {
5321
+ const root = fragmentRoot(selectedOverlay);
5322
+ const first = firstSelectableElement(root, stage?.getBoundingClientRect(), selectionOptions(root));
5068
5323
  if (first) {
5324
+ if (isTelopOverlay(selectedOverlay)) telopInnerId = selectedId;
5069
5325
  focusElement(first);
5070
5326
  event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation();
5071
5327
  return;
@@ -5113,7 +5369,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5113
5369
  if (groupSelection) {
5114
5370
  applyScopedSelection(enterScope(selectionTree(), selectedId)); handled(); return;
5115
5371
  }
5116
- if (selectedOverlay) {
5372
+ if (selectedOverlay && (!isTelopOverlay(selectedOverlay)
5373
+ || telopInnerId === selectedId && Boolean(selectedElementFocus()))) {
5117
5374
  const root = fragmentRoot(selectedOverlay);
5118
5375
  const text = root && [root, ...root.querySelectorAll('*')].find(canEditText);
5119
5376
  if (text) { beginEdit(selectedOverlay, text); handled(); }
@@ -5601,6 +5858,9 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5601
5858
  releasePointerOwner,
5602
5859
  get selectedId() { return selectedId; },
5603
5860
  get elementFocus() { return selectedElementFocus(); },
5861
+ get selectedTelop() { return Boolean(selectedOverlay && selectedIds.length === 1 && isTelopOverlay(selectedOverlay)); },
5862
+ get telopInner() { return telopInnerId !== null && telopInnerId === selectedId; },
5863
+ setTelopInnerSelection,
5604
5864
  clearElementFocus() { if (selectedElementFocus()) focusElement(null, { notify: false }); },
5605
5865
  focusElementAtPoint,
5606
5866
  get selectedIds() { return [...selectedIds]; },