akari-video 1.2.0-beta.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0-beta.5",
3
+ "version": "1.2.0",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/kits.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import path from 'node:path';
9
9
 
10
10
  import { resolveLauncherAssets } from './repo-assets.mjs';
11
+ import { compareVersions } from './version.mjs';
11
12
  const creatorRootModulePath = resolveLauncherAssets().creatorRootModulePath;
12
13
  const creatorRoot = creatorRootModulePath ? await import(pathToFileURL(creatorRootModulePath).href) : null;
13
14
  function resolveAssetLibraryRoots(env) {
@@ -20,15 +21,11 @@ const INSTALLED_ASSETS_SCHEMA = 'akari-installed-assets/v0';
20
21
  const PLUGIN_DESCRIPTION = 'AKARI Video 拡張キットのスキルをまとめて提供するローカルプラグイン。';
21
22
 
22
23
  function parseVersion(value) {
23
- const match = String(value).match(/^(\d+)\.(\d+)\.(\d+)$/u);
24
- return match ? match.slice(1).map(Number) : null;
25
- }
26
-
27
- function compareVersions(left, right) {
28
- for (let index = 0; index < 3; index += 1) {
29
- if (left[index] !== right[index]) return left[index] - right[index];
30
- }
31
- return 0;
24
+ const match = typeof value === 'string'
25
+ ? value.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/u)
26
+ : null;
27
+ if (!match || match[4]?.split('.').some(id => /^0\d+$/u.test(id))) return null;
28
+ return match.slice(1, 4).map(Number);
32
29
  }
33
30
 
34
31
  function satisfies(version, range) {
@@ -36,13 +33,14 @@ function satisfies(version, range) {
36
33
  const match = String(range).match(/^(\^|~|>=)?(\d+\.\d+\.\d+)$/u);
37
34
  if (!actual || !match) return false;
38
35
  const required = parseVersion(match[2]);
39
- if (compareVersions(actual, required) < 0) return false;
36
+ if (!required) return false;
37
+ if (compareVersions(version, match[2]) < 0) return false;
40
38
  if (match[1] === '^') {
41
39
  return actual[0] === required[0] && (required[0] !== 0 || actual[1] === required[1]);
42
40
  }
43
41
  if (match[1] === '~') return actual[0] === required[0] && actual[1] === required[1];
44
42
  if (match[1] === '>=') return true;
45
- return compareVersions(actual, required) === 0;
43
+ return compareVersions(version, match[2]) === 0;
46
44
  }
47
45
 
48
46
  export function readKitManifest(kitDir) {
@@ -14,6 +14,8 @@ export const VENDOR_SOURCES = [
14
14
  'presets/textanim',
15
15
  'assets/font/noto-sans-jp/NotoSansJP-Variable.ttf',
16
16
  'packages/schemas',
17
+ // schemas の frame-scene 検証器が参照する schema 定義だけを同梱する。
18
+ 'packages/frame-scene/src/schema.mjs',
17
19
  // schemas の asset / kit 検証は runtimes.mjs を実行し、runtime 宣言は src の
18
20
  // レジストリ・ブラウザスクリプトと既定フォントをファイルとして読み込む。
19
21
  'packages/overlay-runtime/runtimes.mjs',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0-beta.5",
3
+ "version": "1.2.0",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,102 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import schema from '../../schemas/frame-scene.schema.json' with { type: 'json' };
3
+
4
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
5
+ const check = ajv.compile(schema);
6
+ const JOINTS = new Set('hips spine chest neck head leftUpperArm leftLowerArm leftHand rightUpperArm rightLowerArm rightHand leftUpperLeg leftLowerLeg leftFoot rightUpperLeg rightLowerLeg rightFoot'.split(' '));
7
+ const known = {
8
+ '': schema.properties, '/world': schema.$defs.world.properties,
9
+ '/objects/*': schema.$defs.object.properties, '/scenes/*': schema.$defs.scene.properties,
10
+ '/scenes/*/camera': schema.$defs.camera.properties, '/scenes/*/camera_to': schema.$defs.camera.properties,
11
+ '/scenes/*/world': schema.$defs.world.properties,
12
+ '/scenes/*/set/*': schema.$defs.setObject.properties,
13
+ '/scenes/*/annotations/*': schema.$defs.annotation.properties,
14
+ '/scenes/*/annotations/*/anchor': schema.$defs.annotation.properties.anchor.properties,
15
+ '/objects/*/pose': schema.$defs.pose.oneOf[1].properties,
16
+ '/scenes/*/set/*/pose': schema.$defs.pose.oneOf[1].properties,
17
+ '/objects/*/pose/joints/*': { x: true, y: true, z: true },
18
+ '/scenes/*/set/*/pose/joints/*': { x: true, y: true, z: true },
19
+ '/objects/*/screen': schema.$defs.object.properties.screen.properties,
20
+ '/scenes/*/set/*/screen': schema.$defs.object.properties.screen.properties
21
+ };
22
+ const ptr = (base, key) => `${base}/${String(key).replaceAll('~', '~0').replaceAll('/', '~1')}`;
23
+ const issue = (code, path, message) => ({ code, path, message });
24
+
25
+ export function validateFrameScene(doc, { strict = false, fileBytes } = {}) {
26
+ const errors = [], warnings = [];
27
+ if (doc && typeof doc === 'object' && Number.isInteger(doc.version) && doc.version > 0) {
28
+ return { ok: false, errors: [issue('unsupported_version', '/version', `version ${doc.version} is unsupported`)], warnings };
29
+ }
30
+ if ((fileBytes ?? Buffer.byteLength(JSON.stringify(doc) ?? '')) > 512 * 1024) warnings.push(issue('limit.file', '', 'file exceeds 512KB'));
31
+ if (!check(doc)) for (const e of check.errors ?? []) {
32
+ const path = e.keyword === 'required' ? ptr(e.instancePath, e.params.missingProperty) : e.instancePath;
33
+ errors.push(issue(e.keyword === 'minItems' && e.instancePath === '/scenes' ? 'scene.empty' : `schema.${e.keyword.toLowerCase()}`, path, e.message ?? e.keyword));
34
+ }
35
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errors, warnings };
36
+ const objects = Array.isArray(doc.objects) ? doc.objects : [];
37
+ const scenes = Array.isArray(doc.scenes) ? doc.scenes : [];
38
+ if (objects.length > 200) warnings.push(issue('limit.objects', '/objects', 'objects exceeds 200'));
39
+ if (scenes.length > 50) warnings.push(issue('limit.scenes', '/scenes', 'scenes exceeds 50'));
40
+ const ids = new Set(), sceneIds = new Set();
41
+ const ref = (value, path, code = 'object.reference.unresolved') => {
42
+ if (typeof value === 'string' && !ids.has(value)) errors.push(issue(code, path, `unknown object ${value}`));
43
+ };
44
+ for (const [i, o] of objects.entries()) {
45
+ if (!o || typeof o !== 'object') continue;
46
+ const p = `/objects/${i}`;
47
+ if (typeof o.id === 'string') {
48
+ if (!/^[a-z0-9_-]+$/.test(o.id)) errors.push(issue('object.id.invalid', `${p}/id`, 'id must use [a-z0-9_-]'));
49
+ if (ids.has(o.id)) errors.push(issue('object.id.duplicate', `${p}/id`, 'duplicate object id'));
50
+ ids.add(o.id);
51
+ }
52
+ }
53
+ function inspectObject(o, p) {
54
+ if (!o || typeof o !== 'object') return;
55
+ if (o.facing !== undefined && (o.ry !== undefined || o.rot !== undefined)) errors.push(issue('object.rotation.exclusive', p, 'facing and ry/rot are exclusive'));
56
+ if (o.ry !== undefined && o.rot !== undefined) errors.push(issue('object.rotation.exclusive', p, 'ry and rot are exclusive'));
57
+ if (o.h !== undefined && o.scale !== undefined) errors.push(issue('object.size.exclusive', p, 'h and scale are exclusive'));
58
+ if (o.pose !== undefined && o.kind !== 'human') errors.push(issue('pose.kind.invalid', `${p}/pose`, 'pose requires human'));
59
+ if (o.on !== undefined) ref(o.on, `${p}/on`, 'on.unresolved');
60
+ if (typeof o.facing === 'string' && o.facing !== 'camera') ref(o.facing, `${p}/facing`, 'facing.unresolved');
61
+ if (o.screen_of !== undefined) ref(o.screen_of, `${p}/screen_of`);
62
+ const pose = o.pose;
63
+ if (pose && typeof pose === 'object' && !Array.isArray(pose)) {
64
+ for (const joint of Object.keys(pose.joints ?? {})) if (!JOINTS.has(joint)) warnings.push(issue('pose.joint.unknown', `${p}/pose/joints/${joint}`, `unknown joint ${joint}`));
65
+ for (const key of ['look', 'point_at']) if (typeof pose[key] === 'string') ref(pose[key], `${p}/pose/${key}`, 'pose.target.unresolved');
66
+ }
67
+ }
68
+ objects.forEach((o, i) => inspectObject(o, `/objects/${i}`));
69
+ for (const [i, scene] of scenes.entries()) {
70
+ if (!scene || typeof scene !== 'object') continue;
71
+ const p = `/scenes/${i}`;
72
+ if (typeof scene.id === 'string') {
73
+ if (!/^[a-z0-9_-]+$/.test(scene.id)) errors.push(issue('scene.id.invalid', `${p}/id`, 'invalid scene id'));
74
+ if (sceneIds.has(scene.id)) errors.push(issue('scene.id.duplicate', `${p}/id`, 'duplicate scene id'));
75
+ sceneIds.add(scene.id);
76
+ }
77
+ for (const key of ['camera', 'camera_to']) if (typeof scene[key]?.look === 'string') ref(scene[key].look, `${p}/${key}/look`, 'camera.look.unresolved');
78
+ for (const [id, delta] of Object.entries(scene.set ?? {})) {
79
+ if (!ids.has(id)) warnings.push(issue('set.unresolved', ptr(`${p}/set`, id), `unknown object ${id}`));
80
+ else inspectObject({ ...objects.find(o => o?.id === id), ...delta }, ptr(`${p}/set`, id));
81
+ }
82
+ const annotations = Array.isArray(scene.annotations) ? scene.annotations : [];
83
+ if (annotations.length > 100) warnings.push(issue('limit.annotations', `${p}/annotations`, 'annotations exceeds 100'));
84
+ annotations.forEach((a, j) => {
85
+ if (a?.anchor?.object !== undefined) ref(a.anchor.object, `${p}/annotations/${j}/anchor/object`, 'anchor.unresolved');
86
+ if (a?.frame) for (const [key, value] of Object.entries(a.frame)) {
87
+ if (['at', 'from', 'to'].includes(key) && Array.isArray(value)) value.forEach((n, k) => { if (typeof n !== 'number' || n < 0 || n > 1) errors.push(issue('annotation.coordinate.invalid', `${p}/annotations/${j}/frame/${key}/${k}`, 'coordinate must be 0..1')); });
88
+ if (key === 'points' && Array.isArray(value)) value.flat().forEach((n, k) => { if (typeof n !== 'number' || n < 0 || n > 1) errors.push(issue('annotation.coordinate.invalid', `${p}/annotations/${j}/frame/points/${k}`, 'coordinate must be 0..1')); });
89
+ }
90
+ });
91
+ }
92
+ const walk = (value, path = '') => {
93
+ if (!value || typeof value !== 'object') return;
94
+ if (Array.isArray(value)) { value.forEach((v, i) => walk(v, `${path}/${i}`)); return; }
95
+ const normalized = path.replace(/\/\d+(?=\/|$)/g, '/*').replace(/\/set\/[^/]+/, '/set/*').replace(/\/joints\/[^/]+/, '/joints/*');
96
+ const props = normalized.includes('/annotations/*') && !normalized.endsWith('/anchor') ? undefined : known[normalized];
97
+ if (props) for (const key of Object.keys(value)) if (!(key in props)) (strict ? errors : warnings).push(issue('key.unknown', ptr(path, key), `unknown key ${key}`));
98
+ for (const [key, v] of Object.entries(value)) walk(v, ptr(path, key));
99
+ };
100
+ walk(doc);
101
+ return { ok: errors.length === 0, errors, warnings };
102
+ }
@@ -54,17 +54,25 @@ function textSpans(html, tags = []) {
54
54
  if (!frame?.inSlot || frame.isSlotRoot) tags.push('/' + closing[1].toLowerCase());
55
55
  } else if (opening) {
56
56
  const name = opening[1].toLowerCase();
57
- const parentInSlot = slotStack.length > 0 && slotStack[slotStack.length - 1].inSlot;
57
+ const parent = slotStack[slotStack.length - 1];
58
+ const parentInSlot = parent?.inSlot ?? false;
59
+ const namespace = name === 'svg' || name === 'math' ? name
60
+ : parent?.name === 'foreignobject' && parent.namespace === 'svg'
61
+ ? 'html' : (parent?.namespace ?? 'html');
62
+ const selfClosing = tag[0].endsWith('/>');
58
63
  if (!parentInSlot) tags.push(name);
59
- if ((name === 'style' || name === 'script') && !tag[0].endsWith('/>')) {
64
+ if ((name === 'style' || name === 'script') && !selfClosing) {
60
65
  const close = new RegExp(`</${name}\\s*>`, 'ig');
61
66
  close.lastIndex = cursor;
62
67
  const end = close.exec(html);
63
68
  if (!end) throw new Error(`${name} タグが閉じていません`);
64
69
  cursor = close.lastIndex;
65
- } else if (!VOID_ELEMENTS.has(name) && !tag[0].endsWith('/>')) {
70
+ } else if (selfClosing && namespace !== 'html' && name !== 'style' && name !== 'script') {
71
+ if (!parentInSlot) tags.push('/' + name);
72
+ } else if (!VOID_ELEMENTS.has(name) && !selfClosing) {
66
73
  const isSlotRoot = !parentInSlot && hasSlotAttribute(tag[0], opening[1]);
67
- slotStack.push({ inSlot: parentInSlot || isSlotRoot, isSlotRoot });
74
+ slotStack.push({ inSlot: parentInSlot || isSlotRoot, isSlotRoot,
75
+ name, namespace });
68
76
  }
69
77
  }
70
78
  continue;
@@ -85,8 +93,15 @@ export function patchFragmentSourceText(source, edited) {
85
93
  const afterTags = [];
86
94
  const before = textSpans(source, beforeTags);
87
95
  const after = textSpans(edited, afterTags);
88
- if (before.length !== after.length || beforeTags.join('\0') !== afterTags.join('\0')) {
89
- throw new Error('断片の構造が変わったため、元ソースの文字だけを安全に保存できません');
96
+ const refusal = '断片の構造が変わったため、元ソースの文字だけを安全に保存できません';
97
+ const tagIndex = beforeTags.findIndex((tag, index) => tag !== afterTags[index]);
98
+ if (tagIndex >= 0 || beforeTags.length !== afterTags.length) {
99
+ const index = tagIndex >= 0 ? tagIndex : Math.min(beforeTags.length, afterTags.length);
100
+ const label = tag => tag === undefined ? 'なし' : `<${tag}>`;
101
+ throw new Error(`${refusal}(最初の違い: ${index + 1} 番目・元 ${label(beforeTags[index])} / 編集後 ${label(afterTags[index])})`);
102
+ }
103
+ if (before.length !== after.length) {
104
+ throw new Error(`${refusal}(文字の区切りの数: 元 ${before.length} / 編集後 ${after.length})`);
90
105
  }
91
106
  let result = source;
92
107
  for (let index = before.length - 1; index >= 0; index--) {
@@ -1722,50 +1722,143 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1722
1722
  Object.assign(selectionFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
1723
1723
  width: `${rect.width}px`, height: `${rect.height}px` });
1724
1724
  }
1725
+ let breadcrumbResizeObserver = null;
1726
+ let breadcrumbHoverActive = false;
1727
+ let breadcrumbHoverSuppressedAt = null;
1728
+ function clearBreadcrumbHover() {
1729
+ if (!breadcrumbHoverActive) return;
1730
+ hideHover();
1731
+ }
1732
+ function breadcrumbBounds(target) {
1733
+ if (target.kind === 'stage') return { rect: stage?.getBoundingClientRect() };
1734
+ if (target.kind === 'node') {
1735
+ const node = treeNode(target.id);
1736
+ const leaf = containerById(target.id);
1737
+ const lineRect = leaf && lineFrameGeometry(leaf);
1738
+ const rect = node?.kind !== 'leaf' ? unionBounds(visibleMembers(target.id))
1739
+ : lineRect ?? (leaf ? fragmentBounds(leaf) : null);
1740
+ return { rect, angle: lineRect?.angle ?? null };
1741
+ }
1742
+ if (target.kind === 'item') {
1743
+ const container = containerById(target.id);
1744
+ return { rect: container ? fragmentBounds(container) : null };
1745
+ }
1746
+ const element = target.element?.isConnected ? target.element
1747
+ : elementByAddress(fragmentRoot(containerById(target.overlayId)), target.ref);
1748
+ if (!element?.isConnected) return null;
1749
+ const geometry = elementScreenGeometry(element);
1750
+ const width = geometry.width * Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
1751
+ const height = geometry.height * Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
1752
+ return { rect: { left: geometry.center.x - width / 2, top: geometry.center.y - height / 2,
1753
+ width, height }, angle: Math.atan2(geometry.axes.x.y, geometry.axes.x.x) * 180 / Math.PI };
1754
+ }
1725
1755
  function renderScopeBreadcrumb() {
1726
1756
  const nav = document.querySelector('[data-akari-ui="preview-scope-breadcrumb"]');
1727
1757
  if (!nav) return;
1758
+ if (!breadcrumbResizeObserver && typeof ResizeObserver !== 'undefined' && nav.parentElement) {
1759
+ breadcrumbResizeObserver = new ResizeObserver(() => renderScopeBreadcrumb());
1760
+ breadcrumbResizeObserver.observe(nav.parentElement);
1761
+ }
1762
+ clearBreadcrumbHover();
1763
+ const entries = [];
1764
+ const append = (label, action, target) => entries.push({ label, action, target });
1728
1765
  if (selectedElementFocus() && selectedOverlay) {
1729
- nav.hidden = false;
1730
- nav.replaceChildren();
1731
- const append = (label, action) => {
1732
- if (nav.childNodes.length) nav.append(' › ');
1733
- const button = document.createElement('button');
1734
- button.type = 'button'; button.textContent = label;
1735
- button.addEventListener('click', action);
1736
- nav.appendChild(button);
1737
- };
1738
- append('全体', () => { clearSelection(); publishScopedSelection(); });
1766
+ append('全体', () => { clearSelection(); publishScopedSelection(); }, { kind: 'stage' });
1739
1767
  for (const id of lineage(selectionTree(), scopeId)) {
1740
- append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }));
1768
+ append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }),
1769
+ { kind: 'node', id });
1741
1770
  }
1742
1771
  const name = window.akari.state?.summary?.overlays?.find(overlay => overlay.id === selectedId)?.name ?? selectedId;
1743
- append(name, () => focusElement(null));
1772
+ append(name, () => focusElement(null), { kind: 'item', id: selectedId });
1744
1773
  const root = fragmentRoot(selectedOverlay);
1745
1774
  const path = [];
1746
1775
  for (let node = focusedElement(); node && node !== root; node = node.parentElement) {
1747
1776
  if (selectableElement(root, node, stage?.getBoundingClientRect())) path.unshift(node);
1748
1777
  }
1749
- for (const node of path) append(elementLabel(root, node), () => focusElement(node));
1750
- return;
1751
- }
1752
- nav.hidden = scopeId === floorScopeId || !selectionTree().length;
1753
- nav.replaceChildren();
1754
- if (nav.hidden) return;
1755
- const path = lineage(selectionTree(), scopeId);
1756
- const ids = floorScopeId === null ? [null, ...path] : path.slice(path.indexOf(floorScopeId));
1757
- ids.forEach((id, index) => {
1758
- if (index) nav.append(' › ');
1759
- const button = document.createElement('button');
1760
- button.type = 'button';
1761
- button.textContent = id === null ? '全体' : treeNode(id)?.label ?? id;
1762
- button.addEventListener('click', () => {
1778
+ for (const node of path) append(elementLabel(root, node), () => focusElement(node),
1779
+ { kind: 'element', element: node, overlayId: selectedId, ref: elementAddress(root, node) });
1780
+ } else if (scopeId !== floorScopeId && selectionTree().length) {
1781
+ const path = lineage(selectionTree(), scopeId);
1782
+ const ids = floorScopeId === null ? [null, ...path] : path.slice(path.indexOf(floorScopeId));
1783
+ ids.forEach(id => append(id === null ? '全体' : treeNode(id)?.label ?? id, () => {
1763
1784
  const hit = descendantLeafIds(selectionTree(), selectedId)[0] ?? selectedId;
1764
1785
  const next = resolveScopedSelection(selectionTree(), id, hit);
1765
1786
  applyScopedSelection({ scopeId: id, selectId: next.scopeId === id ? next.selectId : null });
1787
+ }, id === null ? { kind: 'stage' } : { kind: 'node', id }));
1788
+ }
1789
+ nav.replaceChildren();
1790
+ nav.hidden = entries.length === 0;
1791
+ if (nav.hidden) return;
1792
+ const separator = () => {
1793
+ const span = document.createElement('span');
1794
+ span.className = 'akari-breadcrumb-separator'; span.textContent = ' › ';
1795
+ return span;
1796
+ };
1797
+ const button = entry => {
1798
+ const node = document.createElement('button');
1799
+ node.type = 'button'; node.textContent = entry.label;
1800
+ const show = () => {
1801
+ let geometry;
1802
+ try { geometry = breadcrumbBounds(entry.target); } catch { return; }
1803
+ if (!geometry?.rect || geometry.rect.width <= 0 || geometry.rect.height <= 0) return;
1804
+ breadcrumbHoverActive = true;
1805
+ showHoverFrame(geometry.rect, geometry.angle ?? null, entry.target.id ?? '', true);
1806
+ };
1807
+ node.addEventListener('pointerenter', () => {
1808
+ if (!breadcrumbHoverSuppressedAt) show();
1766
1809
  });
1767
- nav.appendChild(button);
1768
- });
1810
+ node.addEventListener('pointermove', event => {
1811
+ const point = breadcrumbHoverSuppressedAt;
1812
+ if (!point || Math.hypot(event.clientX - point.x, event.clientY - point.y) < 1) return;
1813
+ breadcrumbHoverSuppressedAt = null;
1814
+ show();
1815
+ });
1816
+ node.addEventListener('focus', show);
1817
+ node.addEventListener('pointerleave', () => {
1818
+ if (node.isConnected) breadcrumbHoverSuppressedAt = null;
1819
+ clearBreadcrumbHover();
1820
+ });
1821
+ node.addEventListener('blur', clearBreadcrumbHover);
1822
+ node.addEventListener('click', event => {
1823
+ breadcrumbHoverSuppressedAt = { x: event.clientX, y: event.clientY };
1824
+ clearBreadcrumbHover(); entry.action();
1825
+ });
1826
+ return node;
1827
+ };
1828
+ const add = (entry, index) => {
1829
+ if (index) nav.appendChild(separator());
1830
+ nav.appendChild(button(entry));
1831
+ };
1832
+ entries.forEach(add);
1833
+ const buttons = [...nav.querySelectorAll('button')];
1834
+ const widths = buttons.map(node => node.getBoundingClientRect().width);
1835
+ const separatorWidth = nav.querySelector('.akari-breadcrumb-separator')?.getBoundingClientRect().width ?? 0;
1836
+ const available = Math.max(0, nav.parentElement.getBoundingClientRect().width - 16);
1837
+ if (widths.reduce((sum, width) => sum + width, 0) + separatorWidth * (entries.length - 1) <= available) return;
1838
+ if (entries.length < 4) {
1839
+ const each = Math.max(3 * 10, (available - separatorWidth * (entries.length - 1) - widths[0]) / Math.max(1, entries.length - 1));
1840
+ buttons.slice(1).forEach(node => { node.style.maxWidth = `${each}px`; });
1841
+ return;
1842
+ }
1843
+ const ellipsis = document.createElement('span');
1844
+ ellipsis.textContent = '…'; ellipsis.setAttribute('aria-hidden', 'true');
1845
+ nav.appendChild(ellipsis);
1846
+ const ellipsisWidth = ellipsis.getBoundingClientRect().width;
1847
+ ellipsis.remove();
1848
+ let start = entries.length - 2;
1849
+ let used = widths[0] + widths[start] + widths[start + 1] + ellipsisWidth + 3 * separatorWidth;
1850
+ while (start > 2 && used + widths[start - 1] + separatorWidth <= available) {
1851
+ start--; used += widths[start] + separatorWidth;
1852
+ }
1853
+ nav.replaceChildren();
1854
+ add(entries[0], 0);
1855
+ nav.appendChild(separator()); nav.appendChild(ellipsis);
1856
+ for (let index = start; index < entries.length; index++) add(entries[index], index);
1857
+ if (used > available) {
1858
+ const tails = [...nav.querySelectorAll('button')].slice(1);
1859
+ const tailWidth = Math.max(30, (available - widths[0] - ellipsisWidth - 3 * separatorWidth) / tails.length);
1860
+ tails.forEach(node => { node.style.maxWidth = `${tailWidth}px`; });
1861
+ }
1769
1862
  }
1770
1863
  function publishScopedSelection(notify = true) {
1771
1864
  syncLazyBag();
@@ -2117,6 +2210,13 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2117
2210
  const parts = String(value ?? '').match(/[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?px/giu) ?? [];
2118
2211
  return { x: Number.parseFloat(parts[0]) || 0, y: Number.parseFloat(parts[1]) || 0 };
2119
2212
  }
2213
+ const elementDecimal = new Intl.NumberFormat('en-US', { useGrouping: false, maximumFractionDigits: 2 });
2214
+ function formatElementNumber(value) {
2215
+ return elementDecimal.format(Math.round((value + Number.EPSILON) * 100) / 100 || 0);
2216
+ }
2217
+ function formatElementTranslate(x, y) {
2218
+ return `${formatElementNumber(x)}px ${formatElementNumber(y)}px`;
2219
+ }
2120
2220
  function elementScreenGeometry(element) {
2121
2221
  const rect = element.getBoundingClientRect();
2122
2222
  const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
@@ -2185,14 +2285,18 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2185
2285
  }
2186
2286
  function elementWriteStyle(gesture, dimensionNames = []) {
2187
2287
  const style = {};
2188
- for (const name of dimensionNames) style[name] = gesture.element.style.getPropertyValue(name);
2288
+ for (const name of dimensionNames) {
2289
+ style[name] = `${formatElementNumber(Number.parseFloat(gesture.element.style.getPropertyValue(name)))}px`;
2290
+ }
2189
2291
  for (const name of elementInlineProperties) {
2190
2292
  if (name === 'translate' || dimensionNames.includes(name)) continue;
2191
2293
  const now = gesture.element.style.getPropertyValue(name);
2192
- if (now && now !== gesture.originalInline[name][0]) style[name] = now;
2294
+ if (now && now !== gesture.originalInline[name][0]) {
2295
+ style[name] = name === 'rotate' ? `${formatElementNumber(Number.parseFloat(now))}deg` : now;
2296
+ }
2193
2297
  }
2194
- const translate = gesture.element.style.translate;
2195
- if (translate && translate !== gesture.originalInline.translate[0]) style.translate = translate;
2298
+ const translate = formatElementTranslate(gesture.x, gesture.y);
2299
+ if (translate !== formatElementTranslate(gesture.startX, gesture.startY)) style.translate = translate;
2196
2300
  return style;
2197
2301
  }
2198
2302
  function beginElementGesture(event, handleEl) {
@@ -2214,7 +2318,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2214
2318
  startGeometry: before, startClientX: event.clientX, startClientY: event.clientY,
2215
2319
  startWidth: Number.parseFloat(declared.width) || before.width,
2216
2320
  startHeight: Number.parseFloat(declared.height) || before.height,
2217
- x: translate.x, y: translate.y, moved: false, writeContext: captureWriteContext() };
2321
+ x: translate.x, y: translate.y, startX: translate.x, startY: translate.y,
2322
+ moved: false, writeContext: captureWriteContext() };
2218
2323
  if (startStyle.display === 'inline') {
2219
2324
  element.style.display = 'inline-block';
2220
2325
  const next = element.getBoundingClientRect();
@@ -2362,12 +2467,15 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2362
2467
  const gesture = elementNudge;
2363
2468
  elementNudge = null;
2364
2469
  if (!gesture) return;
2365
- if (Math.abs(gesture.x - gesture.startX) < 0.5 && Math.abs(gesture.y - gesture.startY) < 0.5) {
2470
+ const translate = formatElementTranslate(gesture.x, gesture.y);
2471
+ if (Math.abs(gesture.x - gesture.startX) < 0.5 && Math.abs(gesture.y - gesture.startY) < 0.5
2472
+ || translate === formatElementTranslate(gesture.startX, gesture.startY)) {
2366
2473
  gesture.element.style.translate = gesture.originalInline;
2367
2474
  if (gesture.originalDisplay !== undefined) gesture.element.style.display = gesture.originalDisplay;
2475
+ refreshSelectionFrame();
2368
2476
  return;
2369
2477
  }
2370
- const style = { translate: `${gesture.x}px ${gesture.y}px` };
2478
+ const style = { translate };
2371
2479
  if (gesture.originalDisplay !== undefined) style.display = 'inline-block';
2372
2480
  enqueueWrite(gesture.writeContext, gesture.overlayId,
2373
2481
  { element: { ref: gesture.ref, tag: gesture.tag,
@@ -2475,17 +2583,41 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2475
2583
  }
2476
2584
 
2477
2585
  function hideHover() {
2586
+ breadcrumbHoverActive = false;
2478
2587
  hoverEvent = null;
2479
2588
  if (hoverTick !== null) cancelAnimationFrame(hoverTick);
2480
2589
  hoverTick = null;
2481
- if (hoverFrame) hoverFrame.hidden = true;
2590
+ if (hoverFrame) {
2591
+ hoverFrame.hidden = true;
2592
+ hoverFrame.removeAttribute('data-breadcrumb-hover');
2593
+ }
2594
+ }
2595
+ function showHoverFrame(rect, angle = null, overlayId = '', breadcrumb = false, ariaHidden = true) {
2596
+ if (!hoverFrame) {
2597
+ hoverFrame = document.createElement('div');
2598
+ hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2599
+ if (ariaHidden) hoverFrame.setAttribute('aria-hidden', 'true');
2600
+ Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none',
2601
+ boxSizing: 'border-box', border: '1px solid var(--akari-accent, #4da3ff)',
2602
+ opacity: '0.45', zIndex: '90' });
2603
+ document.body.appendChild(hoverFrame);
2604
+ }
2605
+ if (breadcrumb) hoverFrame.setAttribute('data-breadcrumb-hover', 'true');
2606
+ else hoverFrame.removeAttribute('data-breadcrumb-hover');
2607
+ hoverFrame.dataset.overlayId = overlayId;
2608
+ hoverFrame.hidden = false;
2609
+ Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2610
+ width: `${rect.width}px`, height: `${rect.height}px`,
2611
+ transform: angle === null ? '' : `rotate(${angle}deg)` });
2482
2612
  }
2483
2613
  function scheduleHover(event) {
2614
+ if (breadcrumbHoverActive) return;
2484
2615
  if (!interactionEnabled || activeDrag || activeResize || activeEdit || event.buttons) { hideHover(); return; }
2485
2616
  hoverEvent = event;
2486
2617
  if (hoverTick !== null) return;
2487
2618
  hoverTick = requestAnimationFrame(() => {
2488
2619
  hoverTick = null;
2620
+ if (breadcrumbHoverActive) { hoverEvent = null; return; }
2489
2621
  const event = hoverEvent;
2490
2622
  if (!event || activeDrag || activeResize || activeEdit) { hideHover(); return; }
2491
2623
  const container = overlayForEvent(event);
@@ -2501,17 +2633,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2501
2633
  const rect = element?.getBoundingClientRect() ?? fragmentBounds(container);
2502
2634
  if (!rect || selectedOverlay === container
2503
2635
  && selectedElementFocus()?.ref === elementAddress(root, element)) { hideHover(); return; }
2504
- if (!hoverFrame) {
2505
- hoverFrame = document.createElement('div');
2506
- hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2507
- Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none', boxSizing: 'border-box',
2508
- border: '1px solid var(--akari-accent, #4da3ff)', opacity: '0.45', zIndex: '90' });
2509
- document.body.appendChild(hoverFrame);
2510
- }
2511
- hoverFrame.dataset.overlayId = container.dataset.overlayId;
2512
- hoverFrame.hidden = false;
2513
- Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2514
- width: `${rect.width}px`, height: `${rect.height}px`, transform: '' });
2636
+ showHoverFrame(rect, null, container.dataset.overlayId, false, false);
2515
2637
  return;
2516
2638
  }
2517
2639
  const next = isSelectable(container) && resolveScopedSelection(selectionTree(), scopeId,
@@ -2524,20 +2646,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2524
2646
  const rect = node && node.kind !== 'leaf' ? unionBounds(visibleMembers(next.selectId))
2525
2647
  : leaf?.dataset.role === 'shape-line' ? lineRect : leaf ? fragmentBounds(leaf) : null;
2526
2648
  if (!rect) { hideHover(); return; }
2527
- if (!hoverFrame) {
2528
- hoverFrame = document.createElement('div');
2529
- hoverFrame.setAttribute('data-akari-ui', 'preview-hover-frame');
2530
- hoverFrame.setAttribute('aria-hidden', 'true');
2531
- // Standalone hosts also get a non-interactive, viewport-based frame.
2532
- Object.assign(hoverFrame.style, { position: 'fixed', pointerEvents: 'none',
2533
- boxSizing: 'border-box', border: '1px solid var(--akari-accent, #4da3ff)', opacity: '0.45', zIndex: '90' });
2534
- document.body.appendChild(hoverFrame);
2535
- }
2536
- hoverFrame.dataset.overlayId = next.selectId;
2537
- hoverFrame.hidden = false;
2538
- Object.assign(hoverFrame.style, { left: `${rect.left}px`, top: `${rect.top}px`,
2539
- width: `${rect.width}px`, height: `${rect.height}px`,
2540
- transform: lineRect ? `rotate(${lineRect.angle}deg)` : '' });
2649
+ showHoverFrame(rect, lineRect?.angle ?? null, next.selectId);
2541
2650
  });
2542
2651
  }
2543
2652
 
@@ -2592,13 +2701,15 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2592
2701
  const displayScale = stage?.getBoundingClientRect().width / outputSize().width || 1;
2593
2702
  const outputDx = (rect.left + rect.width / 2 - drag.startCenterX) / displayScale;
2594
2703
  const outputDy = (rect.top + rect.height / 2 - drag.startCenterY) / displayScale;
2595
- if (!drag.moved || (Math.abs(outputDx) < 0.5 && Math.abs(outputDy) < 0.5)) {
2704
+ const translate = formatElementTranslate(drag.x, drag.y);
2705
+ if (!drag.moved || (Math.abs(outputDx) < 0.5 && Math.abs(outputDy) < 0.5)
2706
+ || translate === formatElementTranslate(drag.startX, drag.startY)) {
2596
2707
  drag.element.style.translate = drag.originalInline;
2597
2708
  if (drag.originalDisplay !== undefined) drag.element.style.display = drag.originalDisplay;
2598
2709
  refreshSelectionFrame();
2599
2710
  return null;
2600
2711
  }
2601
- const style = { translate: `${drag.x}px ${drag.y}px` };
2712
+ const style = { translate };
2602
2713
  if (drag.originalDisplay !== undefined) style.display = 'inline-block';
2603
2714
  const record = enqueueWrite(drag.writeContext, drag.overlayId,
2604
2715
  { element: { ref: drag.elementRef, tag: drag.elementTag,
@@ -3887,8 +3998,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
3887
3998
  hideHover();
3888
3999
  clickOrigin = { selectedId, scopeId, moved: false, hadMultiple: selectedIds.length > 1 };
3889
4000
 
4001
+ if (event.target instanceof Element && event.target.closest('[data-akari-ui="preview-scope-breadcrumb"]')) return;
3890
4002
  if (selectionTree().length) {
3891
- if (event.target instanceof Element && event.target.closest('[data-akari-ui="preview-scope-breadcrumb"]')) return;
3892
4003
  const handle = findHandleElement(event.target);
3893
4004
  if (handle) {
3894
4005
  if (selectedIds.length > 1) return;