@signalk/app-dock 0.2.1 → 0.3.0-beta.2
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/README.md +32 -23
- package/eslint.config.js +4 -1
- package/package.json +1 -1
- package/plugin/index.js +0 -8
- package/public/805.js +1 -1
- package/public/dock.css +0 -13
- package/public/dock.js +171 -80
- package/public/index.html +1 -5
- package/src/configpanel/PluginConfigurationPanel.js +4 -16
- package/test/plugin.test.js +2 -6
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
|
|
|
9
9
|
- **macOS dock magnification** -- icons scale up with parabolic falloff as your finger moves along the dock
|
|
10
10
|
- **Frosted glass pill** with spring bounce animation, positioned on any screen edge
|
|
11
11
|
- **Lazy loading** -- apps load only when first tapped (important for Raspberry Pi and low-power devices)
|
|
12
|
-
- **Double-tap
|
|
13
|
-
- **Mouse edge hover** to reveal on desktop
|
|
12
|
+
- **Double-tap anywhere** to reveal the dock -- works on touch and mouse. Single taps and clicks pass through to the underlying webapp unchanged, so interactive elements (e.g. KIP's corner widgets) keep working. The pass-through recurses into nested iframes too, so double-tap still opens the dock when one webapp embeds another (e.g. KIP's split view with Freeboard-SK).
|
|
14
13
|
- **Autostart** -- optionally load a default app immediately on open
|
|
15
14
|
- **keep-alive or destroy** iframe lifecycle
|
|
15
|
+
- **Night/day mode toggle** -- optional sun/moon button that flips `environment.mode` via a PUT handler
|
|
16
16
|
- **Active dot indicator**, label tooltip, haptic feedback
|
|
17
17
|
- **Embedded config panel** in the admin UI with webapp discovery, drag-to-reorder, and live preview
|
|
18
18
|
|
|
@@ -29,9 +29,11 @@ Open: `http://your-sk-server:3000/@signalk/app-dock/`
|
|
|
29
29
|
|
|
30
30
|
## Usage
|
|
31
31
|
|
|
32
|
-
**Double-tap** the
|
|
32
|
+
**Double-tap anywhere** on the screen to open the dock. Tap an app icon to switch. The dock auto-dismisses after selection, or tap the backdrop to close it. Double-click works the same way for mouse users.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Tip: double-tapping a clickable element in the underlying webapp will activate that element once before the dock opens -- aim double-taps at non-interactive regions (maps, gauge backgrounds, empty space).
|
|
35
|
+
|
|
36
|
+
Config changes made in **Plugin Config** apply automatically within ~5 seconds: structural changes (position, icon size, etc.) reload the dock page; changes to the app list hot-update in place.
|
|
35
37
|
|
|
36
38
|
## Configuration
|
|
37
39
|
|
|
@@ -45,38 +47,45 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
|
|
|
45
47
|
|
|
46
48
|
### Settings
|
|
47
49
|
|
|
48
|
-
| Setting
|
|
49
|
-
|
|
|
50
|
-
| `position`
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
50
|
+
| Setting | Default | Description |
|
|
51
|
+
| --------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
|
|
52
|
+
| `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
|
|
53
|
+
| `iframeMode` | `keep-alive` | `keep-alive` loads each app once and hides/shows (faster, more RAM); `destroy` reloads each time |
|
|
54
|
+
| `iconSize` | `56` | Base icon size in px |
|
|
55
|
+
| `magnification` | `true` | Enable macOS-style magnification effect |
|
|
56
|
+
| `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
|
|
57
|
+
| `showNightModeButton` | `true` | Show sun/moon button in dock; PUT-writes `environment.mode` |
|
|
58
|
+
| `showExitButton` | `false` | Show X button that returns to the Signal K admin UI |
|
|
56
59
|
|
|
57
60
|
## Development
|
|
58
61
|
|
|
59
62
|
```bash
|
|
60
|
-
cd ~/.signalk
|
|
63
|
+
cd ~/.signalk # or a throwaway SK config dir for dev, e.g. ~/.signalk-app-dock
|
|
61
64
|
npm link /path/to/app-dock
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
To rebuild the config panel (after changing `src/configpanel/`):
|
|
67
|
+
For side-by-side development without touching your real SK setup, point the server at a dedicated config dir:
|
|
67
68
|
|
|
68
69
|
```bash
|
|
69
|
-
npm
|
|
70
|
+
PORT=4000 npm start -- -c ~/.signalk-app-dock
|
|
70
71
|
```
|
|
71
72
|
|
|
73
|
+
### Architecture
|
|
74
|
+
|
|
75
|
+
The repo ships three cooperating surfaces:
|
|
76
|
+
|
|
77
|
+
- **`plugin/index.js`** -- CommonJS SK-server plugin. Registers the schema, seeds default apps on first run, exposes HTTP endpoints for the dock and config panel, and owns the `environment.mode` PUT handler.
|
|
78
|
+
- **`public/dock.js` + `dock.css` + `index.html`** -- the dock UI itself. Plain JS, served as-is. **No build step**: edit and reload the browser.
|
|
79
|
+
- **`src/configpanel/`** -- React 19 component exposed to the admin UI via Webpack Module Federation. Rebuild with `npm run build:config` after changes here; output lands in `public/remoteEntry.js` and companion chunks.
|
|
80
|
+
|
|
72
81
|
### Scripts
|
|
73
82
|
|
|
74
|
-
| Command | Description
|
|
75
|
-
| ---------------------- |
|
|
76
|
-
| `npm test` | Run tests
|
|
77
|
-
| `npm run format` | Prettier + ESLint fix
|
|
78
|
-
| `npm run lint` | ESLint check
|
|
79
|
-
| `npm run build:config` | Rebuild the admin UI config panel |
|
|
83
|
+
| Command | Description |
|
|
84
|
+
| ---------------------- | ----------------------------------------------------------------- |
|
|
85
|
+
| `npm test` | Run plugin tests (`node --test`) |
|
|
86
|
+
| `npm run format` | Prettier + ESLint fix |
|
|
87
|
+
| `npm run lint` | ESLint check |
|
|
88
|
+
| `npm run build:config` | Rebuild the admin UI config panel (required after `src/` changes) |
|
|
80
89
|
|
|
81
90
|
## License
|
|
82
91
|
|
package/eslint.config.js
CHANGED
|
@@ -44,7 +44,10 @@ module.exports = [
|
|
|
44
44
|
requestAnimationFrame: 'readonly',
|
|
45
45
|
Promise: 'readonly',
|
|
46
46
|
Set: 'readonly',
|
|
47
|
-
Infinity: 'readonly'
|
|
47
|
+
Infinity: 'readonly',
|
|
48
|
+
MutationObserver: 'readonly',
|
|
49
|
+
URLSearchParams: 'readonly',
|
|
50
|
+
location: 'readonly'
|
|
48
51
|
}
|
|
49
52
|
},
|
|
50
53
|
rules: {
|
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -141,14 +141,6 @@ module.exports = (app) => {
|
|
|
141
141
|
default: 'bottom'
|
|
142
142
|
},
|
|
143
143
|
|
|
144
|
-
triggerCorner: {
|
|
145
|
-
type: 'string',
|
|
146
|
-
title: 'Double-tap corner',
|
|
147
|
-
description: 'Screen corner that activates the dock on double-tap',
|
|
148
|
-
enum: ['bottom-right', 'bottom-left', 'top-right', 'top-left'],
|
|
149
|
-
default: 'bottom-right'
|
|
150
|
-
},
|
|
151
|
-
|
|
152
144
|
iframeMode: {
|
|
153
145
|
type: 'string',
|
|
154
146
|
title: 'iFrame lifecycle',
|
package/public/805.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[u,g]=(0,a.useState)(n.position||"bottom"),[p,m]=(0,a.useState)(n.triggerCorner||"bottom-right"),[b,f]=(0,a.useState)(n.iframeMode||"keep-alive"),[h,v]=(0,a.useState)(n.iconSize||56),[y,k]=(0,a.useState)(!1!==n.magnification),[E,S]=(0,a.useState)(n.magnificationScale||1.7),[x,w]=(0,a.useState)(n.showNightModeButton||!1),[C,z]=(0,a.useState)(n.showExitButton||!1),[D,R]=(0,a.useState)(""),[B,T]=(0,a.useState)(!1),[I,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(!1),[N,W]=(0,a.useState)(null),[$,_]=(0,a.useState)(null),O=(0,a.useRef)(s);O.current=s;const U=(0,a.useRef)(null);(0,a.useEffect)(()=>()=>{U.current&&clearTimeout(U.current)},[]);const j=(0,a.useCallback)(e=>({position:u,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[u,p,b,h,y,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),T(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),T(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),T(!0)}finally{M(!1)}},[s,j,t]),P=()=>_(null),K=()=>{W(null),_(null)},H=s.filter(e=>!1!==e.enabled);return a.createElement("div",{style:l.root},a.createElement("div",{style:l.sectionTitle},"Dock Settings"),a.createElement(i,{label:"Dock position",value:u,onChange:g,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),a.createElement(i,{label:"Double-tap corner",value:p,onChange:m,options:[{value:"bottom-right",label:"Bottom-right"},{value:"bottom-left",label:"Bottom-left"},{value:"top-right",label:"Top-right"},{value:"top-left",label:"Top-left"}]}),a.createElement(i,{label:"iFrame lifecycle",value:b,onChange:f,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),a.createElement(r,{label:"Icon size",value:h,onChange:v,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:y,onChange:k}),y&&a.createElement(r,{label:"Magnification scale",value:E,onChange:S,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:x,onChange:w,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:C,onChange:z,hint:"X button to return to Signal K admin UI"}),a.createElement("div",{style:l.sectionTitle},"Webapps"),a.createElement("button",{style:{...l.btn,...l.btnPrimary,...I?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),T(!1);try{const e=await fetch("/skServer/webapps"),t=(await e.json()).filter(e=>"@signalk/app-dock"!==e.name).map(e=>"@signalk/server-admin-ui"===e.name?{name:e.name,label:"Settings",url:"/admin/",icon:"/@signalk/app-dock/icon-settings.svg",isAdmin:!0}:{name:e.name,label:e.signalk?.displayName||e.name,url:`/${e.name}/`,icon:e.signalk?.appIcon?`/${e.name}/${e.signalk.appIcon}`:null});if(0===t.length)return R("No webapps found. Is the server fully started?"),T(!0),void F(!1);const n=O.current,a=new Set(n.map(e=>e.url));let l=0;const o=[...n];for(const e of t)a.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),l++);d(o),R(l>0?`Found ${t.length} webapps, added ${l} new.`:`All ${t.length} webapps already in list.`),T(!1)}catch(e){R("Discovery failed: "+e.message),T(!0)}F(!1)},disabled:I},I?"Discovering…":"Discover Installed Webapps"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D),a.createElement("div",{style:l.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?a.createElement("div",{style:l.empty},"No apps yet. Click Discover above."):a.createElement("div",null,s.map((e,t)=>a.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>W(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),_(t)})(e,t),onDragLeave:P,onDrop:e=>((e,t)=>{if(e.preventDefault(),_(null),null!==N&&N!==t){const e=[...s],[n]=e.splice(N,1);e.splice(t,0,n),d(e)}W(null)})(e,t),onDragEnd:K,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...$===t?l.dragOver:{},...N===t?{opacity:.4}:{}}},a.createElement("span",{style:l.handle},"≡"),a.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),a.createElement("div",{style:l.info},a.createElement("div",{style:l.name},e.label||e.url),a.createElement("div",{style:l.url},e.url)),a.createElement("div",{style:l.toggle,title:"Enabled"},a.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,n)=>n===e?{...t,enabled:!t.enabled}:t)))(t),style:l.checkbox})),a.createElement("div",{style:{...l.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,n)=>n===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),a.createElement("button",{style:{...l.btn,...l.btnDanger},onClick:()=>(e=>d(s.filter((t,n)=>n!==e)))(t)},"Remove")))),(H.length>0||x||C)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},x&&a.createElement(a.Fragment,null,a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:20,borderRadius:10}},"☀️"),a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}})),H.map((e,t)=>a.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})),C&&a.createElement(a.Fragment,null,a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}}),a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:16,borderRadius:10,color:"#fff"}},"✕")))),a.createElement("div",{style:l.actions},a.createElement("button",{style:{...l.btn,...l.btnSave,...A?{opacity:.6}:{}},onClick:L,disabled:A},A?"Saving…":"Save Configuration"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D)))}}}]);
|
|
1
|
+
"use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[u,g]=(0,a.useState)(n.position||"bottom"),[p,m]=(0,a.useState)(n.iframeMode||"keep-alive"),[b,f]=(0,a.useState)(n.iconSize||56),[h,y]=(0,a.useState)(!1!==n.magnification),[v,k]=(0,a.useState)(n.magnificationScale||1.7),[E,S]=(0,a.useState)(n.showNightModeButton||!1),[x,w]=(0,a.useState)(n.showExitButton||!1),[C,D]=(0,a.useState)(n.tourDismissed||!1),[z,R]=(0,a.useState)(""),[I,B]=(0,a.useState)(!1),[T,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(!1),[N,W]=(0,a.useState)(null),[$,_]=(0,a.useState)(null),O=(0,a.useRef)(s);O.current=s;const U=(0,a.useRef)(null);(0,a.useEffect)(()=>()=>{U.current&&clearTimeout(U.current)},[]);const j=(0,a.useCallback)(e=>({position:u,iframeMode:p,iconSize:b,magnification:h,magnificationScale:v,showNightModeButton:E,showExitButton:x,tourDismissed:C,apps:e}),[u,p,b,h,v,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),B(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),B(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),B(!0)}finally{M(!1)}},[s,j,t]),P=()=>_(null),K=()=>{W(null),_(null)},H=s.filter(e=>!1!==e.enabled);return a.createElement("div",{style:l.root},a.createElement("div",{style:l.sectionTitle},"Dock Settings"),a.createElement(i,{label:"Dock position",value:u,onChange:g,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),a.createElement(i,{label:"iFrame lifecycle",value:p,onChange:m,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),a.createElement(r,{label:"Icon size",value:b,onChange:f,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:h,onChange:y}),h&&a.createElement(r,{label:"Magnification scale",value:v,onChange:k,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:E,onChange:S,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:x,onChange:w,hint:"X button to return to Signal K admin UI"}),a.createElement("div",{style:l.sectionTitle},"Webapps"),a.createElement("button",{style:{...l.btn,...l.btnPrimary,...T?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),B(!1);try{const e=await fetch("/skServer/webapps"),t=(await e.json()).filter(e=>"@signalk/app-dock"!==e.name).map(e=>"@signalk/server-admin-ui"===e.name?{name:e.name,label:"Settings",url:"/admin/",icon:"/@signalk/app-dock/icon-settings.svg",isAdmin:!0}:{name:e.name,label:e.signalk?.displayName||e.name,url:`/${e.name}/`,icon:e.signalk?.appIcon?`/${e.name}/${e.signalk.appIcon}`:null});if(0===t.length)return R("No webapps found. Is the server fully started?"),B(!0),void F(!1);const n=O.current,a=new Set(n.map(e=>e.url));let l=0;const o=[...n];for(const e of t)a.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),l++);d(o),R(l>0?`Found ${t.length} webapps, added ${l} new.`:`All ${t.length} webapps already in list.`),B(!1)}catch(e){R("Discovery failed: "+e.message),B(!0)}F(!1)},disabled:T},T?"Discovering…":"Discover Installed Webapps"),z&&a.createElement("div",{style:{...l.status,color:I?"#ef4444":"#10b981"}},z),a.createElement("div",{style:l.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?a.createElement("div",{style:l.empty},"No apps yet. Click Discover above."):a.createElement("div",null,s.map((e,t)=>a.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>W(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),_(t)})(e,t),onDragLeave:P,onDrop:e=>((e,t)=>{if(e.preventDefault(),_(null),null!==N&&N!==t){const e=[...s],[n]=e.splice(N,1);e.splice(t,0,n),d(e)}W(null)})(e,t),onDragEnd:K,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...$===t?l.dragOver:{},...N===t?{opacity:.4}:{}}},a.createElement("span",{style:l.handle},"≡"),a.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),a.createElement("div",{style:l.info},a.createElement("div",{style:l.name},e.label||e.url),a.createElement("div",{style:l.url},e.url)),a.createElement("div",{style:l.toggle,title:"Enabled"},a.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,n)=>n===e?{...t,enabled:!t.enabled}:t)))(t),style:l.checkbox})),a.createElement("div",{style:{...l.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,n)=>n===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),a.createElement("button",{style:{...l.btn,...l.btnDanger},onClick:()=>(e=>d(s.filter((t,n)=>n!==e)))(t)},"Remove")))),(H.length>0||E||x)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},E&&a.createElement(a.Fragment,null,a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:20,borderRadius:10}},"☀️"),a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}})),H.map((e,t)=>a.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})),x&&a.createElement(a.Fragment,null,a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}}),a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:16,borderRadius:10,color:"#fff"}},"✕")))),a.createElement("div",{style:l.actions},a.createElement("button",{style:{...l.btn,...l.btnSave,...A?{opacity:.6}:{}},onClick:L,disabled:A},A?"Saving…":"Save Configuration"),z&&a.createElement("div",{style:{...l.status,color:I?"#ef4444":"#10b981"}},z)))}}}]);
|
package/public/dock.css
CHANGED
|
@@ -396,19 +396,6 @@ body {
|
|
|
396
396
|
background: rgba(239, 68, 68, 0.25);
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
-
/* ─── Invisible corner trigger zone ────────────────────────────────────────── */
|
|
400
|
-
#trigger-corner {
|
|
401
|
-
position: fixed;
|
|
402
|
-
z-index: 9;
|
|
403
|
-
width: 120px;
|
|
404
|
-
height: 120px;
|
|
405
|
-
pointer-events: auto;
|
|
406
|
-
touch-action: none;
|
|
407
|
-
-webkit-touch-callout: none;
|
|
408
|
-
-webkit-user-select: none;
|
|
409
|
-
user-select: none;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
399
|
/* ─── Loading overlay ───────────────────────────────────────────────────────── */
|
|
413
400
|
#loading-overlay {
|
|
414
401
|
position: fixed;
|
package/public/dock.js
CHANGED
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
const DEFAULTS = {
|
|
5
5
|
position: 'bottom',
|
|
6
|
-
triggerCorner: 'bottom-right',
|
|
7
6
|
iframeMode: 'keep-alive',
|
|
8
7
|
iconSize: 56,
|
|
9
8
|
magnification: true,
|
|
10
9
|
magnificationScale: 1.7,
|
|
11
|
-
showNightModeButton:
|
|
10
|
+
showNightModeButton: true,
|
|
12
11
|
showExitButton: false,
|
|
13
12
|
apps: []
|
|
14
13
|
}
|
|
@@ -44,7 +43,6 @@
|
|
|
44
43
|
const $backdrop = document.getElementById('dock-backdrop')
|
|
45
44
|
const $dock = document.getElementById('dock')
|
|
46
45
|
const $dockInner = document.getElementById('dock-inner')
|
|
47
|
-
const $triggerCorner = document.getElementById('trigger-corner')
|
|
48
46
|
const $loadingOverlay = document.getElementById('loading-overlay')
|
|
49
47
|
const $loadingLabel = document.getElementById('loading-label')
|
|
50
48
|
const $idleHint = document.getElementById('idle-hint')
|
|
@@ -130,18 +128,8 @@
|
|
|
130
128
|
return
|
|
131
129
|
}
|
|
132
130
|
|
|
133
|
-
const cornerNames = {
|
|
134
|
-
'bottom-right': 'bottom-right',
|
|
135
|
-
'bottom-left': 'bottom-left',
|
|
136
|
-
'top-right': 'top-right',
|
|
137
|
-
'top-left': 'top-left'
|
|
138
|
-
}
|
|
139
|
-
const corner = cornerNames[cfg.triggerCorner] || 'bottom-right'
|
|
140
|
-
|
|
141
131
|
$idleHintText.innerHTML =
|
|
142
|
-
'Double-tap
|
|
143
|
-
corner +
|
|
144
|
-
' corner</strong> to open the dock' +
|
|
132
|
+
'Double-tap <strong>anywhere</strong> to open the dock' +
|
|
145
133
|
'<br><br><span style="font-size:12px;opacity:0.6">Configure in Admin UI \u2192 Plugin Config \u2192 App Dock</span>'
|
|
146
134
|
}
|
|
147
135
|
|
|
@@ -411,9 +399,13 @@
|
|
|
411
399
|
}
|
|
412
400
|
|
|
413
401
|
// ─── Show / hide dock ────────────────────────────────────────────────────────
|
|
402
|
+
let dockShownAt = 0
|
|
403
|
+
const DOCK_CLICK_IGNORE_MS = 500
|
|
404
|
+
|
|
414
405
|
function showDock() {
|
|
415
406
|
if (dockVisible) return
|
|
416
407
|
dockVisible = true
|
|
408
|
+
dockShownAt = Date.now()
|
|
417
409
|
$dock.classList.add('visible')
|
|
418
410
|
$backdrop.classList.add('visible')
|
|
419
411
|
}
|
|
@@ -427,7 +419,17 @@
|
|
|
427
419
|
resetMagnification()
|
|
428
420
|
}
|
|
429
421
|
|
|
430
|
-
$backdrop.addEventListener('click',
|
|
422
|
+
$backdrop.addEventListener('click', (e) => {
|
|
423
|
+
// Windows touch synthesizes a click on whatever's under the finger after
|
|
424
|
+
// pointerdown. If that click lands on the just-shown backdrop, it would
|
|
425
|
+
// immediately close the dock. Ignore clicks within a short window after
|
|
426
|
+
// the dock opens.
|
|
427
|
+
if (Date.now() - dockShownAt < DOCK_CLICK_IGNORE_MS) {
|
|
428
|
+
e.stopPropagation()
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
hideDock()
|
|
432
|
+
})
|
|
431
433
|
|
|
432
434
|
// ─── Loading overlay helpers ──────────────────────────────────────────────────
|
|
433
435
|
function showLoading(label) {
|
|
@@ -479,82 +481,150 @@
|
|
|
479
481
|
hideDock()
|
|
480
482
|
}
|
|
481
483
|
|
|
482
|
-
// ───
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
484
|
+
// ─── Gesture: full-screen double-tap ────────────────────────────────────────
|
|
485
|
+
// No overlay element. We attach passive, non-preventDefault listeners to the
|
|
486
|
+
// parent document AND each same-origin iframe's contentDocument (recursively,
|
|
487
|
+
// to handle webapps like KIP that embed other apps), so webapps receive every
|
|
488
|
+
// tap/click normally. A double-tap anywhere opens the dock. Side effect:
|
|
489
|
+
// double-tapping a button fires that button once before the dock opens —
|
|
490
|
+
// users are expected to aim double-taps at non-interactive regions.
|
|
491
|
+
const DOUBLE_TAP_MS = 450
|
|
492
|
+
const DOUBLE_TAP_SLOP = 40
|
|
493
|
+
|
|
494
|
+
let lastTapTime = 0
|
|
495
|
+
let lastTapX = 0
|
|
496
|
+
let lastTapY = 0
|
|
497
|
+
|
|
498
|
+
function triggerDock() {
|
|
499
|
+
if (DEBUG) console.log('[Dock] triggerDock called, dockVisible=', dockVisible)
|
|
500
|
+
if (dockVisible) return
|
|
501
|
+
showDock()
|
|
502
|
+
if (DEBUG) {
|
|
503
|
+
const r = $dock.getBoundingClientRect()
|
|
504
|
+
console.log('[Dock] after showDock: dock rect', r, 'classes', $dock.className)
|
|
505
|
+
}
|
|
506
|
+
if (navigator.vibrate) navigator.vibrate(8)
|
|
507
|
+
}
|
|
489
508
|
|
|
490
|
-
|
|
491
|
-
$triggerCorner.style.bottom = ''
|
|
492
|
-
$triggerCorner.style.left = ''
|
|
493
|
-
$triggerCorner.style.right = ''
|
|
509
|
+
const DEBUG = new URLSearchParams(location.search).has('debugDock')
|
|
494
510
|
|
|
495
|
-
|
|
496
|
-
|
|
511
|
+
function handleTap(clientX, clientY) {
|
|
512
|
+
if (dockVisible) return
|
|
513
|
+
const now = Date.now()
|
|
514
|
+
const dx = clientX - lastTapX
|
|
515
|
+
const dy = clientY - lastTapY
|
|
516
|
+
const dist = Math.round(Math.sqrt(dx * dx + dy * dy))
|
|
517
|
+
const close = dist <= DOUBLE_TAP_SLOP
|
|
518
|
+
const gap = now - lastTapTime
|
|
519
|
+
const match = gap < DOUBLE_TAP_MS && close
|
|
520
|
+
if (DEBUG) {
|
|
521
|
+
console.log('[Dock] tap', { x: Math.round(clientX), y: Math.round(clientY), gap, dist, close, match })
|
|
522
|
+
}
|
|
523
|
+
if (match) {
|
|
524
|
+
lastTapTime = 0
|
|
525
|
+
triggerDock()
|
|
526
|
+
} else {
|
|
527
|
+
lastTapTime = now
|
|
528
|
+
lastTapX = clientX
|
|
529
|
+
lastTapY = clientY
|
|
530
|
+
}
|
|
497
531
|
}
|
|
498
532
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const DOUBLE_TAP_MS = 400
|
|
533
|
+
function attachTapListener(doc, frameEl) {
|
|
534
|
+
if (!doc || doc.__dockListenersAttached) return
|
|
535
|
+
doc.__dockListenersAttached = true
|
|
503
536
|
|
|
504
|
-
|
|
505
|
-
|
|
537
|
+
const offset = () => {
|
|
538
|
+
if (!frameEl) return { x: 0, y: 0 }
|
|
539
|
+
const r = frameEl.getBoundingClientRect()
|
|
540
|
+
return { x: r.left, y: r.top }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Dedupe: touchstart and pointerdown often both fire for the same gesture.
|
|
544
|
+
// We accept whichever arrives first and ignore the other if it's within
|
|
545
|
+
// 50 ms (the browser's synthesized-event coalescence window).
|
|
546
|
+
let lastEventAt = 0
|
|
547
|
+
const COALESCE_MS = 50
|
|
548
|
+
|
|
549
|
+
const onDown = (x, y) => {
|
|
550
|
+
const now = Date.now()
|
|
551
|
+
if (now - lastEventAt < COALESCE_MS) return
|
|
552
|
+
lastEventAt = now
|
|
553
|
+
handleTap(x, y)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
doc.addEventListener(
|
|
557
|
+
'pointerdown',
|
|
506
558
|
(e) => {
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
e.preventDefault()
|
|
510
|
-
lastTapTime = 0
|
|
511
|
-
showDock()
|
|
512
|
-
if (navigator.vibrate) navigator.vibrate(8)
|
|
513
|
-
} else {
|
|
514
|
-
lastTapTime = now
|
|
515
|
-
}
|
|
559
|
+
const o = offset()
|
|
560
|
+
onDown(e.clientX + o.x, e.clientY + o.y)
|
|
516
561
|
},
|
|
517
|
-
{ passive:
|
|
562
|
+
{ passive: true, capture: true }
|
|
518
563
|
)
|
|
519
564
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
565
|
+
// Fallback for Windows touchscreens + some browsers where the root
|
|
566
|
+
// document does not receive pointerdown for finger input (observed on
|
|
567
|
+
// W11 with touch-action: none). touchstart is the universally-supported
|
|
568
|
+
// event for direct touch.
|
|
569
|
+
doc.addEventListener(
|
|
570
|
+
'touchstart',
|
|
571
|
+
(e) => {
|
|
572
|
+
if (!e.touches || e.touches.length === 0) return
|
|
573
|
+
const t = e.touches[0]
|
|
574
|
+
const o = offset()
|
|
575
|
+
onDown(t.clientX + o.x, t.clientY + o.y)
|
|
576
|
+
},
|
|
577
|
+
{ passive: true, capture: true }
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Recursively attach to iframes at any nesting depth. Webapps like KIP
|
|
582
|
+
// embed other webapps (e.g. FSK) in their own iframes, so we walk the tree
|
|
583
|
+
// and observe each same-origin contentDocument for future iframe additions.
|
|
584
|
+
const attachedDocs = new WeakSet()
|
|
524
585
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
586
|
+
function attachToIframe(frame) {
|
|
587
|
+
const wire = () => {
|
|
588
|
+
let doc
|
|
589
|
+
try {
|
|
590
|
+
doc = frame.contentDocument
|
|
591
|
+
} catch {
|
|
592
|
+
return // cross-origin; can't reach
|
|
593
|
+
}
|
|
594
|
+
if (!doc || attachedDocs.has(doc)) return
|
|
595
|
+
attachedDocs.add(doc)
|
|
596
|
+
attachTapListener(doc, frame)
|
|
597
|
+
scanAndObserveDoc(doc)
|
|
598
|
+
}
|
|
599
|
+
frame.addEventListener('load', wire)
|
|
600
|
+
wire()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function scanAndObserveDoc(doc) {
|
|
604
|
+
// Attach to iframes already in this document.
|
|
605
|
+
doc.querySelectorAll('iframe').forEach((f) => attachToIframe(f))
|
|
606
|
+
|
|
607
|
+
// Observe future iframe additions anywhere in the doc tree.
|
|
608
|
+
const root = doc.documentElement || doc.body || doc
|
|
609
|
+
const obs = new MutationObserver((records) => {
|
|
610
|
+
for (const r of records) {
|
|
611
|
+
r.addedNodes.forEach((n) => {
|
|
612
|
+
if (!n || n.nodeType !== 1) return
|
|
613
|
+
if (n.tagName === 'IFRAME') attachToIframe(n)
|
|
614
|
+
// Descendants: catch iframes added as part of a subtree.
|
|
615
|
+
const nested = n.querySelectorAll && n.querySelectorAll('iframe')
|
|
616
|
+
if (nested) nested.forEach((f) => attachToIframe(f))
|
|
617
|
+
})
|
|
552
618
|
}
|
|
553
619
|
})
|
|
620
|
+
obs.observe(root, { childList: true, subtree: true })
|
|
554
621
|
}
|
|
555
622
|
|
|
623
|
+
attachTapListener(document, null)
|
|
624
|
+
attachedDocs.add(document)
|
|
625
|
+
scanAndObserveDoc(document)
|
|
626
|
+
|
|
556
627
|
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
557
|
-
positionCornerZone()
|
|
558
628
|
buildDock()
|
|
559
629
|
|
|
560
630
|
if (cfg.showNightModeButton) {
|
|
@@ -562,26 +632,47 @@
|
|
|
562
632
|
setInterval(fetchCurrentMode, 5000)
|
|
563
633
|
}
|
|
564
634
|
|
|
635
|
+
// Changes to any of these require a full rebuild (dock classes, magnification
|
|
636
|
+
// constants, dock items, etc.) — easiest to just reload the page.
|
|
637
|
+
const STRUCTURAL_KEYS = [
|
|
638
|
+
'position',
|
|
639
|
+
'iframeMode',
|
|
640
|
+
'iconSize',
|
|
641
|
+
'magnification',
|
|
642
|
+
'magnificationScale',
|
|
643
|
+
'showNightModeButton',
|
|
644
|
+
'showExitButton'
|
|
645
|
+
]
|
|
646
|
+
|
|
565
647
|
async function refreshConfig() {
|
|
566
648
|
try {
|
|
567
649
|
const [configRes, settingsRes] = await Promise.all([
|
|
568
650
|
fetch('/plugins/signalk-app-dock/config'),
|
|
569
651
|
fetch('/plugins/signalk-app-dock/settings')
|
|
570
652
|
])
|
|
571
|
-
const prev =
|
|
653
|
+
const prev = cfg
|
|
654
|
+
let next = cfg
|
|
572
655
|
if (configRes.ok) {
|
|
573
656
|
const data = await configRes.json()
|
|
574
657
|
const pluginCfg = data.configuration || data
|
|
575
|
-
|
|
658
|
+
next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
|
|
576
659
|
}
|
|
577
660
|
if (settingsRes.ok) {
|
|
578
661
|
const data = await settingsRes.json()
|
|
579
662
|
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
580
|
-
|
|
663
|
+
next = { ...next, apps: data.apps }
|
|
581
664
|
}
|
|
582
665
|
}
|
|
583
|
-
|
|
666
|
+
const structuralChanged = STRUCTURAL_KEYS.some((k) => prev[k] !== next[k])
|
|
667
|
+
if (structuralChanged) {
|
|
668
|
+
window.location.reload()
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
if (JSON.stringify(next.apps) !== JSON.stringify(prev.apps)) {
|
|
672
|
+
cfg = next
|
|
584
673
|
buildDock()
|
|
674
|
+
} else {
|
|
675
|
+
cfg = next
|
|
585
676
|
}
|
|
586
677
|
} catch {
|
|
587
678
|
// ignore transient errors (e.g. during plugin restart)
|
package/public/index.html
CHANGED
|
@@ -27,9 +27,6 @@
|
|
|
27
27
|
<div id="dock-inner"></div>
|
|
28
28
|
</nav>
|
|
29
29
|
|
|
30
|
-
<!-- Invisible corner hot-zone for long-press trigger -->
|
|
31
|
-
<div id="trigger-corner" aria-hidden="true"></div>
|
|
32
|
-
|
|
33
30
|
<!-- Loading indicator shown while an iframe is loading for the first time -->
|
|
34
31
|
<div id="loading-overlay" aria-hidden="true">
|
|
35
32
|
<div class="spinner"></div>
|
|
@@ -43,8 +40,7 @@
|
|
|
43
40
|
<div id="tour-body">
|
|
44
41
|
<p>App Dock gives you a macOS-style dock to quickly switch between Signal K webapps on touch screens.</p>
|
|
45
42
|
<p>
|
|
46
|
-
<strong>Double-tap
|
|
47
|
-
launch that app.
|
|
43
|
+
<strong>Double-tap anywhere</strong> on the screen to open the dock, then tap an icon to launch that app.
|
|
48
44
|
</p>
|
|
49
45
|
<p>
|
|
50
46
|
Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
|
|
@@ -175,13 +175,13 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
175
175
|
const cfg = configuration || {}
|
|
176
176
|
const [apps, setApps] = useState(() => cfg.apps || [])
|
|
177
177
|
const [position, setPosition] = useState(cfg.position || 'bottom')
|
|
178
|
-
const [triggerCorner, setTriggerCorner] = useState(cfg.triggerCorner || 'bottom-right')
|
|
179
178
|
const [iframeMode, setIframeMode] = useState(cfg.iframeMode || 'keep-alive')
|
|
180
179
|
const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
|
|
181
180
|
const [magnification, setMagnification] = useState(cfg.magnification !== false)
|
|
182
181
|
const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
|
|
183
182
|
const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
|
|
184
183
|
const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
|
|
184
|
+
const [tourDismissed, setTourDismissed] = useState(cfg.tourDismissed || false)
|
|
185
185
|
|
|
186
186
|
const [status, setStatus] = useState('')
|
|
187
187
|
const [statusError, setStatusError] = useState(false)
|
|
@@ -203,24 +203,24 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
203
203
|
const buildConfig = useCallback(
|
|
204
204
|
(appsList) => ({
|
|
205
205
|
position,
|
|
206
|
-
triggerCorner,
|
|
207
206
|
iframeMode,
|
|
208
207
|
iconSize,
|
|
209
208
|
magnification,
|
|
210
209
|
magnificationScale,
|
|
211
210
|
showNightModeButton,
|
|
212
211
|
showExitButton,
|
|
212
|
+
tourDismissed,
|
|
213
213
|
apps: appsList
|
|
214
214
|
}),
|
|
215
215
|
[
|
|
216
216
|
position,
|
|
217
|
-
triggerCorner,
|
|
218
217
|
iframeMode,
|
|
219
218
|
iconSize,
|
|
220
219
|
magnification,
|
|
221
220
|
magnificationScale,
|
|
222
221
|
showNightModeButton,
|
|
223
|
-
showExitButton
|
|
222
|
+
showExitButton,
|
|
223
|
+
tourDismissed
|
|
224
224
|
]
|
|
225
225
|
)
|
|
226
226
|
|
|
@@ -356,18 +356,6 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
356
356
|
]}
|
|
357
357
|
/>
|
|
358
358
|
|
|
359
|
-
<SelectField
|
|
360
|
-
label="Double-tap corner"
|
|
361
|
-
value={triggerCorner}
|
|
362
|
-
onChange={setTriggerCorner}
|
|
363
|
-
options={[
|
|
364
|
-
{ value: 'bottom-right', label: 'Bottom-right' },
|
|
365
|
-
{ value: 'bottom-left', label: 'Bottom-left' },
|
|
366
|
-
{ value: 'top-right', label: 'Top-right' },
|
|
367
|
-
{ value: 'top-left', label: 'Top-left' }
|
|
368
|
-
]}
|
|
369
|
-
/>
|
|
370
|
-
|
|
371
359
|
<SelectField
|
|
372
360
|
label="iFrame lifecycle"
|
|
373
361
|
value={iframeMode}
|
package/test/plugin.test.js
CHANGED
|
@@ -58,12 +58,13 @@ describe('schema', () => {
|
|
|
58
58
|
const plugin = pluginFactory(createMockApp())
|
|
59
59
|
const props = Object.keys(plugin.schema.properties)
|
|
60
60
|
assert.ok(props.includes('position'))
|
|
61
|
-
assert.ok(props.includes('triggerCorner'))
|
|
62
61
|
assert.ok(props.includes('iframeMode'))
|
|
63
62
|
assert.ok(props.includes('iconSize'))
|
|
64
63
|
assert.ok(props.includes('magnification'))
|
|
65
64
|
assert.ok(props.includes('magnificationScale'))
|
|
66
65
|
assert.ok(props.includes('apps'))
|
|
66
|
+
assert.ok(!props.includes('triggerCorner'), 'triggerCorner should be removed')
|
|
67
|
+
assert.ok(!props.includes('longPressActivation'), 'longPressActivation should be removed')
|
|
67
68
|
})
|
|
68
69
|
|
|
69
70
|
it('position enum contains all four edges', () => {
|
|
@@ -92,11 +93,6 @@ describe('schema', () => {
|
|
|
92
93
|
assert.equal(prop.default, false)
|
|
93
94
|
})
|
|
94
95
|
|
|
95
|
-
it('triggerCorner defaults to bottom-right', () => {
|
|
96
|
-
const plugin = pluginFactory(createMockApp())
|
|
97
|
-
assert.equal(plugin.schema.properties.triggerCorner.default, 'bottom-right')
|
|
98
|
-
})
|
|
99
|
-
|
|
100
96
|
it('defines tourDismissed with default false', () => {
|
|
101
97
|
const plugin = pluginFactory(createMockApp())
|
|
102
98
|
const prop = plugin.schema.properties.tourDismissed
|