arcane-os 0.2.2 → 0.3.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -33,6 +33,9 @@
33
33
  const host=this;
34
34
  const root=host.shadowRoot;
35
35
  const {default:DirectoryPicker}=await import('../modules/DirectoryPicker.js');
36
+ const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
37
+ const events=createArcaneEventSource(host,{source:'arcane.component.directory-picker',eventTypes:['directory-picker-cancel','directory-picker-change','directory-picker-error','directory-picker-ready']});
38
+ const lifecycleController=new AbortController();
36
39
  const field=root.querySelector('#field');
37
40
  const label=root.querySelector('#label');
38
41
  const pathDisplay=root.querySelector('#path');
@@ -45,18 +48,37 @@
45
48
  let disabled=false;
46
49
  let pending=false;
47
50
  let picker=new DirectoryPicker();
51
+ let operationGeneration=0;
52
+ let selectionRevision=0;
53
+ let operationSequence=0;
54
+ let destroyed=false;
48
55
 
49
56
  host.configure=configure;
57
+ host.destroy=destroy;
50
58
  host.focus=focus;
51
59
  host.select=select;
52
60
 
53
61
  Object.defineProperties(host,{
54
- disabled:{get:()=>disabled,set:setDisabled},
55
- options:{get:getOptions,set:configure},
56
- value:{get:()=>currentValue,set:setValue}
62
+ disabled:{configurable:true,get:()=>disabled,set:setDisabled},
63
+ options:{configurable:true,get:getOptions,set:configure},
64
+ value:{configurable:true,get:()=>currentValue,set:setValue}
57
65
  });
58
66
 
59
- choose.addEventListener('click',()=>select().catch(()=>{}));
67
+ choose.addEventListener(
68
+ 'click',
69
+ ()=>select().catch(
70
+ error=>console.error('Unable to select a directory:',error)
71
+ ),
72
+ {signal:lifecycleController.signal}
73
+ );
74
+
75
+ function emit(type,detail={},publicDetail={},operationId=null){
76
+ if(destroyed){
77
+ return false;
78
+ }
79
+ const publication=events.dispatch(type,detail,{publicDetail,operationId});
80
+ return projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true});
81
+ }
60
82
 
61
83
  function datasetOptions(){
62
84
  return {
@@ -71,32 +93,47 @@
71
93
  }
72
94
 
73
95
  function configure(input={}){
96
+ if(destroyed){
97
+ return false;
98
+ }
74
99
  if(!input||typeof input!=='object'||Array.isArray(input)){
75
100
  throw new TypeError('Directory picker configuration must be an object.');
76
101
  }
102
+ const replacesSelection=Object.prototype.hasOwnProperty.call(input,'picker')
103
+ ||Object.prototype.hasOwnProperty.call(input,'value');
77
104
  const next={...datasetOptions(),...options,...input};
78
105
  const nextDisabled=Object.prototype.hasOwnProperty.call(input,'disabled')||!host.ready
79
106
  ?Boolean(next.disabled)
80
107
  :disabled;
108
+ const nextPicker=input.picker===undefined?picker:input.picker;
81
109
  if(input.picker!==undefined){
82
- if(!input.picker||typeof input.picker.select!=='function'){
110
+ if(!nextPicker||typeof nextPicker.select!=='function'){
83
111
  throw new TypeError('picker must expose a select(options) function.');
84
112
  }
85
- picker=input.picker;
86
113
  }
87
- options={
114
+ const nextOptions={
88
115
  buttonLabel:text(next.buttonLabel,'Choose folder'),
89
116
  help:text(next.help,'Choose one folder from this device.'),
90
117
  label:text(next.label,'Folder'),
91
118
  placeholder:text(next.placeholder,'No folder selected'),
92
119
  title:text(next.title,next.label||'Choose folder')
93
120
  };
121
+ const replacesValue=Object.prototype.hasOwnProperty.call(input,'value')
122
+ ||!host.ready;
123
+ const nextValue=replacesValue
124
+ ?normalizeValue(next.value??'')
125
+ :currentValue;
126
+ if(replacesSelection){
127
+ reviseSelection();
128
+ }
129
+ picker=nextPicker;
130
+ options=nextOptions;
94
131
  label.innerText=options.label;
95
132
  help.innerText=options.help;
96
133
  choose.innerText=options.buttonLabel;
97
134
  pathDisplay.placeholder=options.placeholder;
98
- if(Object.prototype.hasOwnProperty.call(input,'value')||!host.ready){
99
- setValue(next.value??'');
135
+ if(replacesValue){
136
+ applyValue(nextValue);
100
137
  }
101
138
  setDisabled(nextDisabled);
102
139
  return getOptions();
@@ -112,17 +149,33 @@
112
149
  }
113
150
 
114
151
  function setValue(value=''){
115
- const next=`${value??''}`.trim();
116
- if(next.length>4096||/[\u0000-\u001f\u007f]/.test(next)){
117
- throw new TypeError('The directory path must be bounded plain text.');
152
+ if(destroyed){
153
+ return currentValue;
118
154
  }
119
- currentValue=next;
155
+ const next=normalizeValue(value);
156
+ reviseSelection();
157
+ return applyValue(next);
158
+ }
159
+
160
+ function applyValue(value=''){
161
+ currentValue=normalizeValue(value);
120
162
  pathDisplay.value=currentValue;
121
163
  host.dataset.hasValue=`${Boolean(currentValue)}`;
122
164
  return currentValue;
123
165
  }
124
166
 
167
+ function normalizeValue(value=''){
168
+ const next=`${value??''}`.trim();
169
+ if(next.length>4096||/[\u0000-\u001f\u007f]/.test(next)){
170
+ throw new TypeError('The directory path must be bounded plain text.');
171
+ }
172
+ return next;
173
+ }
174
+
125
175
  function setDisabled(value=false){
176
+ if(destroyed){
177
+ return disabled;
178
+ }
126
179
  disabled=Boolean(value);
127
180
  pathDisplay.disabled=disabled;
128
181
  choose.disabled=disabled||pending;
@@ -132,12 +185,18 @@
132
185
  }
133
186
 
134
187
  function focus(options){
188
+ if(destroyed){
189
+ return false;
190
+ }
135
191
  choose.focus(options);
136
192
  return document.activeElement===host||root.activeElement===choose;
137
193
  }
138
194
 
139
195
  async function select(){
140
- if(disabled||pending) return null;
196
+ if(destroyed||disabled||pending) return null;
197
+ const generation=++operationGeneration;
198
+ const revision=selectionRevision;
199
+ const operationId=`${events.descriptor.instanceId}:select:${++operationSequence}`;
141
200
  const previousPath=currentValue;
142
201
  setPending(true);
143
202
  setStatus('Opening the folder selector.');
@@ -146,34 +205,39 @@
146
205
  ...(currentValue?{initialPath:currentValue}:{}),
147
206
  title:options.title||options.label||'Choose folder'
148
207
  });
208
+ if(destroyed
209
+ ||generation!==operationGeneration
210
+ ||revision!==selectionRevision){
211
+ return null;
212
+ }
149
213
  if(result.cancelled){
150
214
  setStatus('Folder selection canceled.');
151
- host.dispatchEvent(new CustomEvent('directory-picker-cancel',{
152
- bubbles:true,
153
- composed:true,
154
- detail:{path:currentValue}
155
- }));
215
+ emit('directory-picker-cancel',{path:currentValue,reason:'directory-selection-cancelled'},{hasPath:Boolean(currentValue),reason:'directory-selection-cancelled'},operationId);
156
216
  return result;
157
217
  }
158
- setValue(result.path);
218
+ applyValue(result.path);
159
219
  setStatus('Folder selected.');
160
- host.dispatchEvent(new CustomEvent('directory-picker-change',{
161
- bubbles:true,
162
- composed:true,
163
- detail:{path:currentValue,previousPath,source:'picker'}
164
- }));
220
+ emit('directory-picker-change',{path:currentValue,previousPath,source:'picker'},{hasPath:Boolean(currentValue),changed:currentValue!==previousPath},operationId);
165
221
  return result;
166
222
  }catch(error){
223
+ if(destroyed
224
+ ||generation!==operationGeneration
225
+ ||revision!==selectionRevision){
226
+ return null;
227
+ }
167
228
  const message=error?.message||'The folder selector could not open.';
168
229
  setStatus(message,'error');
169
- host.dispatchEvent(new CustomEvent('directory-picker-error',{
170
- bubbles:true,
171
- composed:true,
172
- detail:{error,message}
173
- }));
230
+ const code=String(error?.code||'ARCANE_DIRECTORY_PICKER_SELECTION_FAILED');
231
+ const reason='directory-selection-failed';
232
+ emit('directory-picker-error',{error,message,code,reason},{code,reason},operationId);
174
233
  throw error;
175
234
  }finally{
176
- setPending(false);
235
+ if(!destroyed&&generation===operationGeneration){
236
+ setPending(false);
237
+ if(revision!==selectionRevision){
238
+ setStatus('Folder selection superseded.');
239
+ }
240
+ }
177
241
  }
178
242
  }
179
243
 
@@ -185,13 +249,35 @@
185
249
  return pending;
186
250
  }
187
251
 
252
+ function reviseSelection(){
253
+ selectionRevision+=1;
254
+ if(pending){
255
+ setStatus('Folder selection superseded; waiting for the selector to close.');
256
+ }
257
+ }
258
+
188
259
  function setStatus(message='',tone=''){
189
260
  status.innerText=`${message||''}`;
190
261
  status.dataset.tone=tone;
191
262
  return status.innerText;
192
263
  }
193
264
 
265
+ function destroy(){
266
+ if(destroyed){
267
+ return false;
268
+ }
269
+ destroyed=true;
270
+ operationGeneration+=1;
271
+ selectionRevision+=1;
272
+ lifecycleController.abort('directory-picker-destroyed');
273
+ pending=false;
274
+ picker?.destroy?.();
275
+ events.dispose();
276
+ host.ready=false;
277
+ return true;
278
+ }
279
+
194
280
  configure(datasetOptions());
195
281
  host.ready=true;
196
- host.dispatchEvent(new CustomEvent('directory-picker-ready',{bubbles:true,composed:true}));
282
+ emit('directory-picker-ready',{}, {ready:true});
197
283
  </script>
@@ -70,17 +70,24 @@
70
70
  const notesLabel=shadow.querySelector('[data-notes-label]');
71
71
  const saveState=shadow.querySelector('.save-state');
72
72
  const {default:MD}=await import('../modules/MD.js');
73
+ const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
74
+ const events=createArcaneEventSource(host,{source:'arcane.component.document-inspector',eventTypes:['document-review-change','document-inspector-ready']});
75
+ const lifecycleController=new AbortController();
73
76
  let rawMarkdown='';
74
77
  let renderedMarkdown='';
75
78
  let markdownMessage=null;
76
79
  let activeRecordId='';
77
80
  let loadSequence=0;
81
+ let loadController=null;
82
+ let operationSequence=0;
83
+ let destroyed=false;
78
84
 
79
85
  host.loadDocument=loadDocument;
80
86
  host.selectView=selectView;
87
+ host.destroy=destroy;
81
88
  host.ready=true;
82
89
 
83
- tabs.forEach(tab=>tab.addEventListener('click',()=>selectView(tab.dataset.view)));
90
+ tabs.forEach(tab=>tab.addEventListener('click',()=>selectView(tab.dataset.view),{signal:lifecycleController.signal}));
84
91
  tabs.forEach(tab=>tab.addEventListener('keydown',event=>{
85
92
  if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return;
86
93
  event.preventDefault();
@@ -88,16 +95,18 @@
88
95
  const index=available.indexOf(tab);
89
96
  const next=event.key==='Home'?0:event.key==='End'?available.length-1:(index+(event.key==='ArrowRight'?1:-1)+available.length)%available.length;
90
97
  selectView(available[next].dataset.view); available[next].focus();
91
- }));
92
- searchInput.addEventListener('input',()=>highlightSearch(searchInput.value));
98
+ },{signal:lifecycleController.signal}));
99
+ searchInput.addEventListener('input',()=>highlightSearch(searchInput.value),{signal:lifecycleController.signal});
93
100
  reviewForm.addEventListener('submit',event=>{
94
101
  event.preventDefault();
95
102
  const attributes={};
96
103
  reviewFields.querySelectorAll('[data-review-field]').forEach(field=>{attributes[field.dataset.reviewField]=field.value;});
97
104
  const detail={recordId:activeRecordId,status:reviewStatus.value,classification:reviewClassification.value||'unassigned',attributes,notes:reviewNotes.value};
98
105
  saveState.textContent='Saving…';
99
- host.dispatchEvent(new CustomEvent('document-review-change',{detail,bubbles:true,composed:true}));
100
- });
106
+ const operationId=`${events.descriptor.instanceId}:review-change:${++operationSequence}`;
107
+ const publication=events.dispatch('document-review-change',detail,{operationId,publicDetail:{hasRecord:Boolean(activeRecordId),attributeCount:Object.keys(attributes).length,hasNotes:Boolean(reviewNotes.value)}});
108
+ projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true});
109
+ },{signal:lifecycleController.signal});
101
110
 
102
111
  function sameOriginUrl(value){
103
112
  if(!String(value||'').trim()) throw new TypeError('A document source is required.');
@@ -117,6 +126,9 @@
117
126
  }
118
127
 
119
128
  function selectView(view='pdf'){
129
+ if(destroyed){
130
+ return false;
131
+ }
120
132
  const available=tabs.filter(tab=>!tab.hidden);
121
133
  const selected=available.some(tab=>tab.dataset.view===view)?view:available[0]?.dataset.view;
122
134
  tabs.forEach(tab=>{const active=tab.dataset.view===selected;tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;});
@@ -172,6 +184,12 @@
172
184
  }
173
185
 
174
186
  async function loadDocument(config={}){
187
+ if(destroyed){
188
+ return false;
189
+ }
190
+ loadController?.abort('document-replaced');
191
+ const controller=new AbortController();
192
+ loadController=controller;
175
193
  const sequence=++loadSequence;
176
194
  activeRecordId=String(config.recordId||'');
177
195
  saveState.textContent=''; searchInput.value=''; matchStatus.textContent='';
@@ -231,22 +249,41 @@
231
249
  if(hasText){
232
250
  showSurfaceMessage(markdownSurface,'Loading document text…');
233
251
  try{
234
- const response=await fetch(sameOriginUrl(config.markdownUrl),{cache:'no-store',credentials:'same-origin'});
252
+ const response=await fetch(sameOriginUrl(config.markdownUrl),{cache:'no-store',credentials:'same-origin',signal:controller.signal});
235
253
  if(!response.ok) throw new Error(`Document text unavailable (${response.status}).`);
236
254
  const markdown=await response.text();
237
- if(sequence!==loadSequence) return false;
255
+ if(destroyed||sequence!==loadSequence) return false;
238
256
  rawMarkdown=markdown; renderedMarkdown=safeMarkdown(rawMarkdown); showMarkdown();
239
257
  }catch(error){
240
- if(sequence!==loadSequence) return false;
258
+ if(destroyed||sequence!==loadSequence||controller.signal.aborted) return false;
241
259
  markdownMessage={message:error.message,className:'error'}; showMarkdown();
242
260
  }
243
261
  }else{
244
262
  markdownMessage={message:'No document text is available.',className:'loading'};
245
263
  showMarkdown();
246
264
  }
265
+ if(loadController===controller){
266
+ loadController=null;
267
+ }
268
+ return !destroyed&&sequence===loadSequence;
269
+ }
270
+
271
+ function destroy(){
272
+ if(destroyed){
273
+ return false;
274
+ }
275
+ destroyed=true;
276
+ loadSequence+=1;
277
+ loadController?.abort('document-inspector-destroyed');
278
+ loadController=null;
279
+ lifecycleController.abort('document-inspector-destroyed');
280
+ pdfPanel.replaceChildren();
281
+ events.dispose();
282
+ host.ready=false;
247
283
  return true;
248
284
  }
249
285
 
250
- host.markSaved=message=>{saveState.textContent=String(message||'Saved');};
251
- host.dispatchEvent(new CustomEvent('document-inspector-ready',{bubbles:true,composed:true}));
286
+ host.markSaved=message=>{if(destroyed)return false;saveState.textContent=String(message||'Saved');return true;};
287
+ const readyPublication=events.dispatch('document-inspector-ready',{}, {publicDetail:{ready:true}});
288
+ projectArcaneDOMEvent(host,readyPublication.occurrence,{bubbles:true,composed:true});
252
289
  </script>
@@ -46,6 +46,9 @@
46
46
  <script type="module">
47
47
  const host=this;
48
48
  const root=host.shadowRoot;
49
+ const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
50
+ const events=createArcaneEventSource(host,{source:'arcane.component.file-drop',eventTypes:['file-drop-selected','file-drop-state','file-drop-progress','file-drop-error','file-drop-ready']});
51
+ const lifecycleController=new AbortController();
49
52
  const dropZone=root.querySelector('#dropZone');
50
53
  const title=root.querySelector('#fileDropTitle');
51
54
  const hint=root.querySelector('#fileDropHint');
@@ -62,27 +65,38 @@
62
65
  let currentFiles=[];
63
66
  let progress=null;
64
67
  let options={};
68
+ let operationSequence=0;
69
+ let destroyed=false;
65
70
 
66
71
  host.clear=clear;
67
72
  host.configure=configure;
73
+ host.destroy=destroy;
68
74
  host.openPicker=openPicker;
69
75
  host.setBusy=setBusy;
70
76
  host.setError=setError;
71
77
  host.setProgress=setProgress;
72
78
 
73
79
  Object.defineProperties(host,{
74
- busy:{get:()=>busy,set:setBusy},
75
- files:{get:()=>currentFiles.slice()},
76
- options:{get:getOptions,set:configure},
77
- progress:{get:()=>progress,set:setProgress}
80
+ busy:{configurable:true,get:()=>busy,set:setBusy},
81
+ files:{configurable:true,get:()=>currentFiles.slice()},
82
+ options:{configurable:true,get:getOptions,set:configure},
83
+ progress:{configurable:true,get:()=>progress,set:setProgress}
78
84
  });
79
85
 
80
- choose.addEventListener('click',openPicker);
81
- picker.addEventListener('change',()=>acceptFiles(picker.files,'picker'));
82
- dropZone.addEventListener('dragenter',dragEnter);
83
- dropZone.addEventListener('dragover',dragOver);
84
- dropZone.addEventListener('dragleave',dragLeave);
85
- dropZone.addEventListener('drop',dropFiles);
86
+ choose.addEventListener('click',openPicker,{signal:lifecycleController.signal});
87
+ picker.addEventListener('change',()=>acceptFiles(picker.files,'picker'),{signal:lifecycleController.signal});
88
+ dropZone.addEventListener('dragenter',dragEnter,{signal:lifecycleController.signal});
89
+ dropZone.addEventListener('dragover',dragOver,{signal:lifecycleController.signal});
90
+ dropZone.addEventListener('dragleave',dragLeave,{signal:lifecycleController.signal});
91
+ dropZone.addEventListener('drop',dropFiles,{signal:lifecycleController.signal});
92
+
93
+ function emit(type,detail={},publicDetail={},operationId=null){
94
+ if(destroyed){
95
+ return false;
96
+ }
97
+ const publication=events.dispatch(type,detail,{publicDetail,operationId});
98
+ return projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true});
99
+ }
86
100
 
87
101
  function datasetOptions(){
88
102
  return {
@@ -96,6 +110,9 @@
96
110
  }
97
111
 
98
112
  function configure(input={}){
113
+ if(destroyed){
114
+ return false;
115
+ }
99
116
  const previous={...datasetOptions(),...options};
100
117
  options={
101
118
  ...previous,
@@ -121,7 +138,7 @@
121
138
  }
122
139
 
123
140
  function openPicker(){
124
- if(busy){
141
+ if(destroyed||busy){
125
142
  return false;
126
143
  }
127
144
  picker.click();
@@ -163,12 +180,23 @@
163
180
  }
164
181
 
165
182
  function acceptFiles(fileList=[],source='api'){
183
+ if(destroyed){
184
+ return false;
185
+ }
186
+ const operationId=`${events.descriptor.instanceId}:select:${++operationSequence}`;
166
187
  const files=Array.from(fileList||[]).filter(Boolean);
167
188
  if(!files.length){
168
189
  return false;
169
190
  }
170
191
  if(options.maxFiles>0&&files.length>options.maxFiles){
171
- setError(`Select no more than ${options.maxFiles} file${options.maxFiles===1?'':'s'}.`);
192
+ setError(
193
+ `Select no more than ${options.maxFiles} file${options.maxFiles===1?'':'s'}.`,
194
+ {
195
+ code:'ARCANE_FILE_DROP_FILE_COUNT_LIMIT_EXCEEDED',
196
+ reason:'file-count-limit-exceeded'
197
+ },
198
+ operationId
199
+ );
172
200
  return false;
173
201
  }
174
202
  currentFiles=options.multiple?files:files.slice(0,1);
@@ -176,16 +204,14 @@
176
204
  feedback.hidden=false;
177
205
  statusElement.className='status';
178
206
  statusElement.innerText=`${currentFiles.length} file${currentFiles.length===1?'':'s'} selected.`;
179
- host.dispatchEvent(new CustomEvent('file-drop-selected',{
180
- bubbles:true,
181
- cancelable:false,
182
- composed:true,
183
- detail:{files:currentFiles.slice(),source}
184
- }));
207
+ emit('file-drop-selected',{files:currentFiles.slice(),source},{fileCount:currentFiles.length,source:String(source)},operationId);
185
208
  return currentFiles.slice();
186
209
  }
187
210
 
188
211
  function setBusy(value=true,message=''){
212
+ if(destroyed){
213
+ return busy;
214
+ }
189
215
  busy=Boolean(value);
190
216
  choose.disabled=busy;
191
217
  picker.disabled=busy;
@@ -196,15 +222,14 @@
196
222
  statusElement.className='status';
197
223
  statusElement.innerText=`${message}`;
198
224
  }
199
- host.dispatchEvent(new CustomEvent('file-drop-state',{
200
- bubbles:true,
201
- composed:true,
202
- detail:{busy,progress}
203
- }));
225
+ emit('file-drop-state',{busy,progress},{busy,hasProgress:progress!==null});
204
226
  return busy;
205
227
  }
206
228
 
207
229
  function setProgress(value=null,label=''){
230
+ if(destroyed){
231
+ return progress;
232
+ }
208
233
  const numeric=Number(value);
209
234
  progress=value===null||value===undefined||!Number.isFinite(numeric)
210
235
  ?null
@@ -218,30 +243,35 @@
218
243
  progressElement.removeAttribute('value');
219
244
  progressLabel.innerText='';
220
245
  }
221
- host.dispatchEvent(new CustomEvent('file-drop-progress',{
222
- bubbles:true,
223
- composed:true,
224
- detail:{progress,label:progressLabel.innerText}
225
- }));
246
+ emit('file-drop-progress',{progress,label:progressLabel.innerText},{progress});
226
247
  return progress;
227
248
  }
228
249
 
229
- function setError(message=''){
250
+ function setError(message='',errorDetail={},operationId=null){
251
+ if(destroyed){
252
+ return '';
253
+ }
230
254
  const text=`${message||''}`;
231
255
  feedback.hidden=!text&&progress===null;
232
256
  statusElement.innerText=text;
233
257
  statusElement.className=text?'status arcane-error':'status';
234
258
  if(text){
235
- host.dispatchEvent(new CustomEvent('file-drop-error',{
236
- bubbles:true,
237
- composed:true,
238
- detail:{message:text}
239
- }));
259
+ const code=String(errorDetail?.code||'ARCANE_FILE_DROP_INPUT_REJECTED');
260
+ const reason=String(errorDetail?.reason||'file-input-rejected');
261
+ emit(
262
+ 'file-drop-error',
263
+ {message:text,code,reason},
264
+ {code,reason},
265
+ operationId||`${events.descriptor.instanceId}:error:${++operationSequence}`
266
+ );
240
267
  }
241
268
  return text;
242
269
  }
243
270
 
244
271
  function clear(){
272
+ if(destroyed){
273
+ return false;
274
+ }
245
275
  currentFiles=[];
246
276
  picker.value='';
247
277
  setBusy(false);
@@ -256,9 +286,25 @@
256
286
  return Number.isFinite(numeric)&&numeric>=0?numeric:fallback;
257
287
  }
258
288
 
289
+ function destroy(){
290
+ if(destroyed){
291
+ return false;
292
+ }
293
+ destroyed=true;
294
+ lifecycleController.abort('file-drop-destroyed');
295
+ dragDepth=0;
296
+ busy=false;
297
+ currentFiles=[];
298
+ progress=null;
299
+ picker.value='';
300
+ events.dispose();
301
+ host.ready=false;
302
+ return true;
303
+ }
304
+
259
305
  configure(datasetOptions());
260
306
  dropZone.dataset.dragActive='false';
261
307
  setBusy(false);
262
308
  host.ready=true;
263
- host.dispatchEvent(new CustomEvent('file-drop-ready',{bubbles:true,composed:true}));
309
+ emit('file-drop-ready',{}, {ready:true});
264
310
  </script>