@rig-c/review 0.3.0 → 0.5.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.
@@ -1,76 +1,96 @@
1
1
  /** Browser workspace presentation. Source selection, player clocks and review decisions stay with the page. */
2
2
  import {WorkspacePreferenceStore,panelBounds,type WorkspacePanels,type WorkspacePreferences,type WorkspaceTheme} from './workspace-preferences.ts';
3
- import {allocateWorkspaceLayout,bindWorkspaceSeparator,bindWorkspaceDrawer,InspectorScrollMemory,inspectorTabs,type InspectorTab,type WorkspacePanel,type SeparatorRange} from './workspace-layout.ts';
3
+ import {allocateWorkspaceLayout,bindWorkspaceSeparator,bindWorkspaceDrawer,defaultWorkspaceColumns,InspectorScrollMemory,inspectorTabs,workspaceMinimums,type InspectorTab,type WorkspaceColumns,type WorkspacePanel,type SeparatorRange} from './workspace-layout.ts';
4
4
 
5
5
  /** Reachable effective bounds, accounting for the other panel's saved desired size. */
6
- export function workspaceResizeRange(available:{width:number;height:number},desired:WorkspacePanels,panel:WorkspacePanel):SeparatorRange {
7
- const base=allocateWorkspaceLayout(available,desired),range=base.separators[panel];if(!range.enabled)return range;
8
- const low=allocateWorkspaceLayout(available,{...desired,[panel]:panelBounds[panel].min})[panel],high=allocateWorkspaceLayout(available,{...desired,[panel]:panelBounds[panel].max})[panel];
6
+ export function workspaceResizeRange(available:{width:number;height:number},desired:WorkspacePanels,panel:WorkspacePanel,columns:WorkspaceColumns=defaultWorkspaceColumns):SeparatorRange {
7
+ const base=allocateWorkspaceLayout(available,desired,columns),range=base.separators[panel];if(!range.enabled)return range;
8
+ const low=allocateWorkspaceLayout(available,{...desired,[panel]:panelBounds[panel].min},columns)[panel],high=allocateWorkspaceLayout(available,{...desired,[panel]:panelBounds[panel].max},columns)[panel];
9
9
  return {min:low,max:high,value:base[panel],enabled:high>low};
10
10
  }
11
11
  /** Invert the allocator instead of storing an already-clamped pixel value as a desktop desire. */
12
- export function workspaceResizeTarget(available:{width:number;height:number},desired:WorkspacePanels,panel:WorkspacePanel,target:number):number {
13
- const range=workspaceResizeRange(available,desired,panel);if(!range.enabled)return desired[panel];
12
+ export function workspaceResizeTarget(available:{width:number;height:number},desired:WorkspacePanels,panel:WorkspacePanel,target:number,columns:WorkspaceColumns=defaultWorkspaceColumns):number {
13
+ const range=workspaceResizeRange(available,desired,panel,columns);if(!range.enabled)return desired[panel];
14
14
  if(target<=range.min)return panelBounds[panel].min;if(target>=range.max)return panelBounds[panel].max;
15
15
  let low:number=panelBounds[panel].min,high:number=panelBounds[panel].max;
16
- for(let i=0;i<48;i++){const mid=(low+high)/2,value=allocateWorkspaceLayout(available,{...desired,[panel]:mid})[panel];if(value<target)low=mid;else high=mid;}
16
+ for(let i=0;i<48;i++){const mid=(low+high)/2,value=allocateWorkspaceLayout(available,{...desired,[panel]:mid},columns)[panel];if(value<target)low=mid;else high=mid;}
17
17
  return (low+high)/2;
18
18
  }
19
+ /** The selected Inspector section name, from either a selected tab or a pressed header selector. */
20
+ export function selectedInspector(root:Pick<Document,'querySelector'>=document):string|undefined {
21
+ return root.querySelector<HTMLElement>('[data-inspector][aria-selected=true],[data-inspector][aria-pressed=true]')?.dataset.inspector;
22
+ }
19
23
  export interface WorkspaceUI {
20
24
  defaults:()=>WorkspacePreferences;beforeTab:()=>void;afterTab:()=>void;scroll:()=>string;restoreScroll:(value:unknown)=>void;
21
25
  revealInspector:()=>void;refresh:()=>void;dispose:()=>void;
22
26
  }
23
- export function bindWorkspaceUI(options:{changed:()=>void;defaultTheme:(theme:WorkspaceTheme)=>void}):WorkspaceUI {
27
+ /** `currentTheme`, when given, is the appearance the Settings theme select shows; otherwise it shows the saved default. */
28
+ export function bindWorkspaceUI(options:{changed:()=>void;defaultTheme:(theme:WorkspaceTheme)=>void;currentTheme?:()=>WorkspaceTheme|undefined}):WorkspaceUI {
24
29
  const require=<T extends HTMLElement=HTMLElement>(id:string)=>{const node=document.getElementById(id);if(!node)throw new Error('Missing workspace element: '+id);return node as T;};
25
- const layout=document.querySelector<HTMLElement>('.layout')!,main=layout.querySelector<HTMLElement>('main')!,stage=require('stage-layout'),inspector=document.querySelector<HTMLElement>('.inspector')!,right=require('parts-explorer');
30
+ const layout=document.querySelector<HTMLElement>('.layout')!,main=layout.querySelector<HTMLElement>('main')!,stage=require('stage-layout'),inspector=document.querySelector<HTMLElement>('.inspector')!,explorer=require('parts-explorer');
26
31
  layout.classList.add('workspace-layout');
27
32
  let left=layout.querySelector<HTMLElement>('.left-panel');if(!left){left=document.createElement('aside');left.className='left-panel';left.innerHTML='<h2>INPUTS</h2><p class="hint">A and B retain their fixed source bindings. The reference video and shared animation are named above the stage.</p>';layout.prepend(left);}left.id='workspace-left';inspector.id='workspace-inspector';
28
- const sourceList=document.createElement('div');sourceList.className='workspace-sources';sourceList.append(...left.childNodes);left.append(sourceList);
33
+ // A parts explorer placed inside the left column makes that column the explorer: it keeps the explorer's
34
+ // saved width and the page has no right column. Otherwise sources stay left and the explorer stays right.
35
+ const explorerLeft=left.contains(explorer),columns:WorkspaceColumns=explorerLeft?{left:'rightWidth',right:null}:defaultWorkspaceColumns;
36
+ // A section is an Inspector panel only while it carries data-inspector-panel; the review page's Feedback
37
+ // section lives in the review dialog instead and keeps its id without being a panel.
38
+ const tabs=inspectorTabs.filter(tab=>document.getElementById('inspector-'+tab)?.dataset.inspectorPanel===tab);if(!tabs.length)throw new Error('Missing workspace element: inspector-'+inspectorTabs.join('|inspector-'));
39
+ // The left column is a fixed header row (a leading `.left-header`, when the page has one) and one scrolling
40
+ // body holding everything else, Settings included: the body is the column's only scroll container.
41
+ const header=[...left.children].find(node=>node.className.split(' ').includes('left-header'))??null;
42
+ const sourceList=document.createElement('div');sourceList.className='workspace-sources';sourceList.append(...[...left.childNodes].filter(node=>node!==header));left.append(sourceList);
29
43
  const settings=document.createElement('section');settings.className='workspace-settings';settings.setAttribute('aria-label','Settings');
30
- settings.innerHTML='<h2>Settings</h2><label>Theme <select id="settings-theme" data-app-preference><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="settings-sync" data-app-preference type="checkbox">Sync cameras by default</label><label><input id="settings-idle" data-app-preference type="checkbox">Repeat idle by default</label><button id="settings-reset" type="button">Reset layout</button><p class="hint">Defaults apply to new inspections. Links and source drafts retain their explicit view.</p><p id="settings-state" class="hint" role="status"></p>';
31
- left.append(settings);
44
+ settings.innerHTML='<h2 class="sticky-header">Settings</h2><label>Theme <select id="settings-theme" data-app-preference><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="settings-sync" data-app-preference type="checkbox">Sync cameras by default</label><label><input id="settings-idle" data-app-preference type="checkbox">Repeat idle by default</label><button id="settings-reset" type="button">Reset layout</button><p class="hint">Defaults apply to new inspections. Links and source drafts retain their explicit view.</p><p id="settings-state" class="hint" role="status"></p>';
45
+ sourceList.append(settings);
32
46
  const theme=require<HTMLSelectElement>('settings-theme'),sync=require<HTMLInputElement>('settings-sync'),idle=require<HTMLInputElement>('settings-idle'),status=require('settings-state');
33
47
  const store=new WorkspacePreferenceStore({onStatus:value=>{status.textContent=value.detail;}});let desired={...store.get().panels};
34
48
  const stack=document.createElement('div');stack.className='workspace-stage-stack';for(const child of [...main.children])if(child!==inspector)stack.append(child);main.prepend(stack);
35
49
  const triggers=document.createElement('div');triggers.className='workspace-drawer-triggers';triggers.setAttribute('aria-label','Workspace panels');stack.prepend(triggers);
36
- const panels:Record<WorkspacePanel,HTMLElement>={leftWidth:left,rightWidth:right,inspectorHeight:inspector};
37
- const labels:Record<WorkspacePanel,string>={leftWidth:'Sources and settings',rightWidth:'Parts explorer',inspectorHeight:'Inspector'};
50
+ const panels:Partial<Record<WorkspacePanel,HTMLElement>>=explorerLeft?{rightWidth:left,inspectorHeight:inspector}:{leftWidth:left,rightWidth:explorer,inspectorHeight:inspector};
51
+ const sides:Partial<Record<WorkspacePanel,'left'|'right'>>=explorerLeft?{rightWidth:'left'}:{leftWidth:'left',rightWidth:'right'};
52
+ const labels:Record<WorkspacePanel,string>=explorerLeft?{leftWidth:'Sources and settings',rightWidth:'Parts and settings',inspectorHeight:'Inspector'}:{leftWidth:'Sources and settings',rightWidth:'Parts explorer',inspectorHeight:'Inspector'};
38
53
  const handles={} as Record<WorkspacePanel,HTMLElement>,dialogs={} as Record<WorkspacePanel,HTMLDialogElement>,buttons={} as Record<WorkspacePanel,HTMLButtonElement>,disposers:(()=>void)[]=[];
39
- for(const panel of Object.keys(panels) as WorkspacePanel[]){
40
- const handle=document.createElement('div');handle.className='workspace-separator workspace-'+panel;handles[panel]=handle;
41
- if(panel==='leftWidth')left.after(handle);else if(panel==='rightWidth')right.before(handle);else inspector.before(handle);
54
+ const present=Object.keys(panels) as WorkspacePanel[],node=(panel:WorkspacePanel)=>panels[panel]!;
55
+ for(const panel of present){
56
+ const handle=document.createElement('div');handle.className='workspace-separator workspace-'+panel+(sides[panel]?' workspace-side-'+sides[panel]:'');handles[panel]=handle;
57
+ if(sides[panel]==='left')node(panel).after(handle);else if(sides[panel]==='right')node(panel).before(handle);else inspector.before(handle);
42
58
  const trigger=document.createElement('button');trigger.type='button';trigger.textContent=labels[panel];trigger.hidden=true;triggers.append(trigger);buttons[panel]=trigger;
43
- const dialog=document.createElement('dialog');dialog.className='workspace-drawer';dialog.id='workspace-drawer-'+panel;dialog.dataset.panel=panel;const close=document.createElement('button');close.type='button';close.textContent='Close '+labels[panel];close.className='workspace-drawer-close';dialog.append(close);document.body.append(dialog);dialogs[panel]=dialog;
59
+ const dialog=document.createElement('dialog');dialog.className='workspace-drawer';dialog.id='workspace-drawer-'+panel;dialog.dataset.panel=panel;const close=document.createElement('button');close.type='button';close.textContent='Close '+labels[panel];close.className='workspace-drawer-close';
60
+ // The drawer's header stays above its scrolling panel: title and the close action.
61
+ const header=document.createElement('div');header.className='sticky-header workspace-drawer-header';const title=document.createElement('span');title.textContent=labels[panel];header.append(title,close);dialog.append(header);document.body.append(dialog);dialogs[panel]=dialog;
44
62
  disposers.push(bindWorkspaceDrawer({trigger,drawer:dialog,label:labels[panel],close}));
45
63
  }
46
- const memory=new InspectorScrollMemory();const active=():InspectorTab=>(document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector as InspectorTab)??'view';
47
- let disposed=false,frame=0,applying=false,available={width:0,height:0},effective=allocateWorkspaceLayout(available,desired);
64
+ const memory=new InspectorScrollMemory();const active=():InspectorTab=>{const tab=selectedInspector() as InspectorTab|undefined;return tab&&tabs.includes(tab)?tab:tabs[0]!;};
65
+ let disposed=false,frame=0,applying=false,available={width:0,height:0},effective=allocateWorkspaceLayout(available,desired,columns);
48
66
  const panelFor=(tab:InspectorTab)=>require('inspector-'+tab);
49
67
  const capture=()=>{const tab=active(),panel=panelFor(tab),saved=memory.snapshot()[tab];if(!panel.hidden&&(!saved||saved.x<=Math.max(0,panel.scrollWidth-panel.clientWidth)&&saved.y<=Math.max(0,panel.scrollHeight-panel.clientHeight)))memory.capture(tab,panel);};
50
68
  const restore=()=>{if(!disposed)memory.restore(active(),panelFor(active()));};
51
69
  const schedule=()=>{if(!disposed&&!frame)frame=requestAnimationFrame(()=>{frame=0;apply();});};
52
70
  const dock=(panel:WorkspacePanel,drawer:boolean)=>{
53
- const node=panels[panel],dialog=dialogs[panel],trigger=buttons[panel];trigger.hidden=!drawer;handles[panel].hidden=drawer;
54
- if(drawer&&node.parentElement!==dialog){const focus=node.contains(document.activeElement);dialog.append(node);if(focus)trigger.focus({preventScroll:true});}
55
- if(!drawer&&node.parentElement===dialog){const focused=node.contains(document.activeElement)||document.activeElement===trigger;if(dialog.open)dialog.close();trigger.setAttribute('aria-expanded','false');if(panel==='leftWidth')layout.prepend(node);else if(panel==='rightWidth')layout.append(node);else main.append(node);if(focused){node.tabIndex=-1;node.focus({preventScroll:true});}}
71
+ const element=node(panel),dialog=dialogs[panel],trigger=buttons[panel];trigger.hidden=!drawer;handles[panel].hidden=drawer;
72
+ if(drawer&&element.parentElement!==dialog){const focus=element.contains(document.activeElement);dialog.append(element);if(focus)trigger.focus({preventScroll:true});}
73
+ if(!drawer&&element.parentElement===dialog){const focused=element.contains(document.activeElement)||document.activeElement===trigger;if(dialog.open)dialog.close();trigger.setAttribute('aria-expanded','false');if(sides[panel]==='left')layout.prepend(element);else if(sides[panel]==='right')layout.append(element);else main.append(element);if(focused){element.tabIndex=-1;element.focus({preventScroll:true});}}
56
74
  };
57
- const bindings=(Object.keys(panels) as WorkspacePanel[]).map(panel=>bindWorkspaceSeparator({element:handles[panel],panel,controls:panels[panel].id,label:'Resize '+labels[panel],range:()=>workspaceResizeRange(available,desired,panel),resolveDesired:value=>workspaceResizeTarget(available,desired,panel,value),desired:()=>desired[panel],resize:value=>{desired={...desired,[panel]:value};apply();},commit:value=>store.update({panels:{[panel]:value}})}));
75
+ const bindings=present.map(panel=>bindWorkspaceSeparator({element:handles[panel],panel,...(sides[panel]?{side:sides[panel]}:{}),controls:node(panel).id,label:'Resize '+labels[panel],range:()=>workspaceResizeRange(available,desired,panel,columns),resolveDesired:value=>workspaceResizeTarget(available,desired,panel,value,columns),desired:()=>desired[panel],resize:value=>{desired={...desired,[panel]:value};apply();},commit:value=>store.update({panels:{[panel]:value}})}));
58
76
  function apply():void {
59
77
  if(disposed||applying)return;applying=true;
60
78
  try{
61
79
  // Chrome shares the center width. It is not charged against the actual stage minimum.
62
80
  const chrome=[...stack.children].filter(node=>node!==stage&&!((node as HTMLElement).hidden)).reduce((sum,node)=>sum+(node as HTMLElement).getBoundingClientRect().height,0);
63
- available={width:Math.max(0,layout.clientWidth),height:Math.max(0,layout.clientHeight-chrome)};effective=allocateWorkspaceLayout(available,desired);
64
- for(const panel of Object.keys(panels) as WorkspacePanel[])dock(panel,effective[panel==='leftWidth'?'leftMode':panel==='rightWidth'?'rightMode':'inspectorMode']==='drawer');
65
- layout.style.setProperty('--workspace-columns',`${effective.leftWidth}px ${effective.leftMode==='panel'?8:0}px minmax(0,1fr) ${effective.rightMode==='panel'?8:0}px ${effective.rightWidth}px`);
66
- main.style.gridTemplateRows=`minmax(0,1fr) ${effective.inspectorMode==='panel'?8:0}px ${effective.inspectorHeight}px`;
81
+ available={width:Math.max(0,layout.clientWidth),height:Math.max(0,layout.clientHeight-chrome)};effective=allocateWorkspaceLayout(available,desired,columns);
82
+ const mode=(panel:WorkspacePanel)=>effective[panel==='leftWidth'?'leftMode':panel==='rightWidth'?'rightMode':'inspectorMode'];
83
+ for(const panel of present)dock(panel,mode(panel)==='drawer');
84
+ const gap=workspaceMinimums.separator,side=(key:WorkspaceColumns['left'])=>key&&mode(key)==='panel'?{width:effective[key],separator:gap}:{width:0,separator:0},l=side(columns.left),r=side(columns.right);
85
+ layout.style.setProperty('--workspace-columns',`${l.width}px ${l.separator}px minmax(0,1fr) ${r.separator}px ${r.width}px`);
86
+ main.style.gridTemplateRows=`minmax(0,1fr) ${effective.inspectorMode==='panel'?gap:0}px ${effective.inspectorHeight}px`;
67
87
  layout.dataset.stageMinimum=String(effective.stageMinimumSatisfied);for(const binding of bindings)binding.refresh();options.changed();restore();
68
88
  }finally{applying=false;}
69
89
  }
70
- const renderSettings=()=>{const value=store.get();theme.value=value.theme;sync.checked=value.defaultCameraSync;idle.checked=value.defaultIdleRepeat;status.textContent=store.status().detail;};
90
+ const renderSettings=()=>{const value=store.get();theme.value=options.currentTheme?.()??value.theme;sync.checked=value.defaultCameraSync;idle.checked=value.defaultIdleRepeat;status.textContent=store.status().detail;};
71
91
  theme.onchange=()=>{store.update({theme:theme.value as WorkspaceTheme});options.defaultTheme(store.get().theme);};sync.onchange=()=>{store.update({defaultCameraSync:sync.checked});};idle.onchange=()=>{store.update({defaultIdleRepeat:idle.checked});};require<HTMLButtonElement>('settings-reset').onclick=()=>store.resetLayout();
72
92
  const unsubscribe=store.subscribe(value=>{desired={...value.panels};renderSettings();schedule();});renderSettings();
73
- for(const tab of inspectorTabs){
93
+ for(const tab of tabs){
74
94
  const panel=panelFor(tab);let user=false,timer:ReturnType<typeof setTimeout>|undefined;
75
95
  const intent=()=>{user=true;if(timer)clearTimeout(timer);timer=setTimeout(()=>{user=false;},600);};const scrolled=()=>{
76
96
  if(!user)return;const saved=memory.snapshot()[tab]??{x:0,y:0};
@@ -83,7 +103,7 @@ export function bindWorkspaceUI(options:{changed:()=>void;defaultTheme:(theme:Wo
83
103
  let extent='';const content=new MutationObserver(()=>{const next=`${panel.scrollWidth}:${panel.scrollHeight}:${panel.clientWidth}:${panel.clientHeight}`;if(next!==extent){extent=next;if(tab===active())restore();}});content.observe(panel,{childList:true,subtree:true});
84
104
  disposers.push(()=>{if(timer)clearTimeout(timer);for(const type of ['wheel','pointerdown','touchstart','keydown'])panel.removeEventListener(type,intent);panel.removeEventListener('scroll',scrolled);content.disconnect();});
85
105
  }
86
- const observer=new ResizeObserver(schedule);observer.observe(layout);for(const node of [...stack.children])if(node!==stage)observer.observe(node);for(const tab of inspectorTabs)observer.observe(panelFor(tab));
106
+ const observer=new ResizeObserver(schedule);observer.observe(layout);for(const node of [...stack.children])if(node!==stage)observer.observe(node);for(const tab of tabs)observer.observe(panelFor(tab));
87
107
  const resumed=()=>{if(!document.hidden)store.refresh();};document.addEventListener('visibilitychange',resumed);schedule();
88
108
  return {defaults:()=>store.get(),beforeTab:capture,afterTab:restore,scroll:()=>{capture();return JSON.stringify(memory.snapshot());},restoreScroll(value){if(typeof value!=='string')return;memory.replace(JSON.parse(value));restore();},revealInspector(){if(effective.inspectorMode==='drawer'&&!dialogs.inspectorHeight.open){for(const dialog of Object.values(dialogs))if(dialog.open)dialog.close();buttons.inspectorHeight.click();}},refresh:schedule,
89
109
  dispose(){if(disposed)return;capture();disposed=true;if(frame)cancelAnimationFrame(frame);observer.disconnect();document.removeEventListener('visibilitychange',resumed);unsubscribe();for(const binding of bindings)binding.dispose();for(const dispose of disposers)dispose();memory.dispose();store.dispose();}