| 1 | import { create } from "zustand"; |
| 2 | |
| 3 | export interface BrowserSurfaceRect { |
| 4 | readonly x: number; |
| 5 | readonly y: number; |
| 6 | readonly width: number; |
| 7 | readonly height: number; |
| 8 | } |
| 9 | |
| 10 | export interface BrowserSurfacePresentation { |
| 11 | readonly rect: BrowserSurfaceRect | null; |
| 12 | readonly visible: boolean; |
| 13 | readonly zIndex: number; |
| 14 | readonly content: BrowserSurfaceContentPresentation | null; |
| 15 | readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; |
| 16 | readonly fitSourceContent: boolean; |
| 17 | readonly cornerRadius: number; |
| 18 | readonly updatedAt: number; |
| 19 | readonly owner: symbol | null; |
| 20 | } |
| 21 | |
| 22 | export interface BrowserSurfaceContentPresentation { |
| 23 | readonly x: number; |
| 24 | readonly y: number; |
| 25 | readonly width: number; |
| 26 | readonly height: number; |
| 27 | readonly scale: number; |
| 28 | readonly scrollLeft: number; |
| 29 | readonly scrollTop: number; |
| 30 | } |
| 31 | |
| 32 | interface BrowserSurfaceStoreState { |
| 33 | readonly activityByTabId: Record<string, number>; |
| 34 | readonly byTabId: Record<string, BrowserSurfacePresentation>; |
| 35 | readonly acquireActivity: (tabId: string) => () => void; |
| 36 | readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; |
| 37 | readonly present: ( |
| 38 | tabId: string, |
| 39 | owner: symbol, |
| 40 | rect: BrowserSurfaceRect, |
| 41 | visible: boolean, |
| 42 | cornerRadius: number, |
| 43 | zIndex: number, |
| 44 | ) => void; |
| 45 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; |
| 46 | readonly release: (tabId: string, owner: symbol) => void; |
| 47 | } |
| 48 | |
| 49 | export interface BrowserSurfaceLease { |
| 50 | readonly present: ( |
| 51 | rect: BrowserSurfaceRect, |
| 52 | visible: boolean, |
| 53 | cornerRadius?: number, |
| 54 | zIndex?: number, |
| 55 | ) => boolean; |
| 56 | readonly release: () => void; |
| 57 | } |
| 58 | |
| 59 | export function resolveBrowserSurfacePanelRect( |
| 60 | byTabId: Readonly<Record<string, BrowserSurfacePresentation>>, |
| 61 | tabId: string, |
| 62 | ): BrowserSurfaceRect | null { |
| 63 | const current = byTabId[tabId]; |
| 64 | return current?.rect ?? null; |
| 65 | } |
| 66 | |
| 67 | const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => |
| 68 | left !== null && |
| 69 | left.x === right.x && |
| 70 | left.y === right.y && |
| 71 | left.width === right.width && |
| 72 | left.height === right.height; |
| 73 | |
| 74 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ |
| 75 | activityByTabId: {}, |
| 76 | byTabId: {}, |
| 77 | acquireActivity: (tabId) => { |
| 78 | let released = false; |
| 79 | set((state) => ({ |
| 80 | activityByTabId: { |
| 81 | ...state.activityByTabId, |
| 82 | [tabId]: (state.activityByTabId[tabId] ?? 0) + 1, |
| 83 | }, |
| 84 | })); |
| 85 | return () => { |
| 86 | if (released) return; |
| 87 | released = true; |
| 88 | set((state) => { |
| 89 | const count = state.activityByTabId[tabId] ?? 0; |
| 90 | const activityByTabId = { ...state.activityByTabId }; |
| 91 | if (count <= 1) delete activityByTabId[tabId]; |
| 92 | else activityByTabId[tabId] = count - 1; |
| 93 | return { activityByTabId }; |
| 94 | }); |
| 95 | }; |
| 96 | }, |
| 97 | claim: (tabId, owner, fitSourceContent) => |
| 98 | set((state) => { |
| 99 | const current = state.byTabId[tabId]; |
| 100 | if (current?.owner === owner) return state; |
| 101 | return { |
| 102 | byTabId: { |
| 103 | ...state.byTabId, |
| 104 | [tabId]: { |
| 105 | rect: current?.rect ?? null, |
| 106 | visible: false, |
| 107 | zIndex: current?.zIndex ?? 30, |
| 108 | content: current?.content ?? null, |
| 109 | fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, |
| 110 | fitSourceContent, |
| 111 | cornerRadius: current?.cornerRadius ?? 0, |
| 112 | updatedAt: Date.now(), |
| 113 | owner, |
| 114 | }, |
| 115 | }, |
| 116 | }; |
| 117 | }), |
| 118 | present: (tabId, owner, rect, visible, cornerRadius, zIndex) => |
| 119 | set((state) => { |
| 120 | const current = state.byTabId[tabId]; |
| 121 | if (current?.owner !== owner) return state; |
| 122 | if ( |
| 123 | current && |
| 124 | current.visible === visible && |
| 125 | current.cornerRadius === cornerRadius && |
| 126 | current.zIndex === zIndex && |
| 127 | rectEquals(current.rect, rect) |
| 128 | ) { |
| 129 | return state; |
| 130 | } |
| 131 | return { |
| 132 | byTabId: { |
| 133 | ...state.byTabId, |
| 134 | [tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() }, |
| 135 | }, |
| 136 | }; |
| 137 | }), |
| 138 | presentContent: (tabId, content) => |
| 139 | set((state) => { |
| 140 | const current = state.byTabId[tabId]; |
| 141 | if (!current) { |
| 142 | return { |
| 143 | byTabId: { |
| 144 | ...state.byTabId, |
| 145 | [tabId]: { |
| 146 | rect: null, |
| 147 | visible: false, |
| 148 | zIndex: 30, |
| 149 | content, |
| 150 | fittedSourceContent: null, |
| 151 | fitSourceContent: false, |
| 152 | cornerRadius: 0, |
| 153 | updatedAt: Date.now(), |
| 154 | owner: null, |
| 155 | }, |
| 156 | }, |
| 157 | }; |
| 158 | } |
| 159 | const previous = current.content; |
| 160 | if ( |
| 161 | previous && |
| 162 | previous.x === content.x && |
| 163 | previous.y === content.y && |
| 164 | previous.width === content.width && |
| 165 | previous.height === content.height && |
| 166 | previous.scale === content.scale && |
| 167 | previous.scrollLeft === content.scrollLeft && |
| 168 | previous.scrollTop === content.scrollTop |
| 169 | ) { |
| 170 | return state; |
| 171 | } |
| 172 | return { |
| 173 | byTabId: { |
| 174 | ...state.byTabId, |
| 175 | [tabId]: { |
| 176 | ...current, |
| 177 | content, |
| 178 | fittedSourceContent: |
| 179 | current.fitSourceContent && current.fittedSourceContent === null |
| 180 | ? content |
| 181 | : current.fittedSourceContent, |
| 182 | updatedAt: Date.now(), |
| 183 | }, |
| 184 | }, |
| 185 | }; |
| 186 | }), |
| 187 | release: (tabId, owner) => |
| 188 | set((state) => { |
| 189 | const current = state.byTabId[tabId]; |
| 190 | if (current?.owner !== owner) return state; |
| 191 | return { |
| 192 | byTabId: { |
| 193 | ...state.byTabId, |
| 194 | [tabId]: { |
| 195 | ...current, |
| 196 | visible: false, |
| 197 | fittedSourceContent: null, |
| 198 | fitSourceContent: false, |
| 199 | updatedAt: Date.now(), |
| 200 | owner: null, |
| 201 | }, |
| 202 | }, |
| 203 | }; |
| 204 | }), |
| 205 | })); |
| 206 | |
| 207 | export const acquireBrowserSurfaceActivity = (tabId: string): (() => void) => |
| 208 | useBrowserSurfaceStore.getState().acquireActivity(tabId); |
| 209 | |
| 210 | export function acquireBrowserSurface( |
| 211 | tabId: string, |
| 212 | fitSourceContent = false, |
| 213 | ): BrowserSurfaceLease { |
| 214 | const owner = Symbol(`browser-surface:${tabId}`); |
| 215 | let released = false; |
| 216 | useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); |
| 217 | |
| 218 | return { |
| 219 | present: (rect, visible, cornerRadius = 0, zIndex = 30) => { |
| 220 | if (released) return false; |
| 221 | if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; |
| 222 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); |
| 223 | return true; |
| 224 | }, |
| 225 | release: () => { |
| 226 | if (released) return; |
| 227 | released = true; |
| 228 | useBrowserSurfaceStore.getState().release(tabId, owner); |
| 229 | }, |
| 230 | }; |
| 231 | } |