Jaimodiji commited on
Commit
d81f838
·
verified ·
1 Parent(s): 8ab98be

Upload folder using huggingface_hub

Browse files
.claude/settings.local.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx wrangler deploy:*)"
5
+ ]
6
+ }
7
+ }
capacitor.config.ts CHANGED
@@ -4,6 +4,7 @@ const getServerUrl = () => {
4
  const env = process.env.CAPACITOR_SERVER_IP_ENV
5
  if (env === 'local') return 'http://192.168.0.169:5173'
6
  if (env === 'hf') return 'https://jaimodiji-my-multiplayer-app.hf.space'
 
7
  return 'http://tshonq.duckdns.org:5173'
8
  }
9
 
@@ -11,6 +12,7 @@ const getAllowNavigation = () => {
11
  const env = process.env.CAPACITOR_SERVER_IP_ENV
12
  if (env === 'local') return '192.168.0.169'
13
  if (env === 'hf') return 'jaimodiji-my-multiplayer-app.hf.space'
 
14
  return 'tshonq.duckdns.org'
15
  }
16
 
 
4
  const env = process.env.CAPACITOR_SERVER_IP_ENV
5
  if (env === 'local') return 'http://192.168.0.169:5173'
6
  if (env === 'hf') return 'https://jaimodiji-my-multiplayer-app.hf.space'
7
+ if (env === 'cloudflare') return 'https://multiplayer-template.bossemail.workers.dev'
8
  return 'http://tshonq.duckdns.org:5173'
9
  }
10
 
 
12
  const env = process.env.CAPACITOR_SERVER_IP_ENV
13
  if (env === 'local') return '192.168.0.169'
14
  if (env === 'hf') return 'jaimodiji-my-multiplayer-app.hf.space'
15
+ if (env === 'cloudflare') return 'multiplayer-template.bossemail.workers.dev'
16
  return 'tshonq.duckdns.org'
17
  }
18
 
client/components/BackupControl.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
2
  import { useEditor, TldrawUiMenuItem } from 'tldraw'
3
  import { useAuth } from '../hooks/useAuth'
4
  import { useBackups } from '../hooks/useBackups'
@@ -57,3 +57,97 @@ export function BackupMenuItem({ roomId }: { roomId: string }) {
57
  />
58
  )
59
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, ChangeEvent } from 'react'
2
  import { useEditor, TldrawUiMenuItem } from 'tldraw'
3
  import { useAuth } from '../hooks/useAuth'
4
  import { useBackups } from '../hooks/useBackups'
 
57
  />
58
  )
59
  }
60
+
61
+ export function DownloadMenuItem({ roomId }: { roomId: string }) {
62
+ const editor = useEditor()
63
+
64
+ const handleDownload = () => {
65
+ try {
66
+ const snapshot = editor.getSnapshot()
67
+ const jsonStr = JSON.stringify({
68
+ snapshot,
69
+ roomId,
70
+ timestamp: Date.now(),
71
+ source: 'tldraw-multiplayer'
72
+ }, null, 2)
73
+
74
+ const blob = new Blob([jsonStr], { type: 'application/json' })
75
+ const url = URL.createObjectURL(blob)
76
+ const link = document.createElement('a')
77
+ link.href = url
78
+ link.download = `tldraw-room-${roomId}-${new Date().toISOString().slice(0,10)}.json`
79
+ document.body.appendChild(link)
80
+ link.click()
81
+ document.body.removeChild(link)
82
+ URL.revokeObjectURL(url)
83
+ } catch (e: any) {
84
+ console.error('Failed to download backup', e)
85
+ alert('Failed to download backup: ' + e.message)
86
+ }
87
+ }
88
+
89
+ return (
90
+ <TldrawUiMenuItem
91
+ id="download-json"
92
+ label="Download File"
93
+ icon="download"
94
+ readonlyOk
95
+ onSelect={handleDownload}
96
+ />
97
+ )
98
+ }
99
+
100
+ export function RestoreMenuItem() {
101
+ const editor = useEditor()
102
+ const inputRef = useRef<HTMLInputElement>(null)
103
+
104
+ const handleRestoreClick = () => {
105
+ inputRef.current?.click()
106
+ }
107
+
108
+ const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
109
+ const file = e.target.files?.[0]
110
+ if (!file) return
111
+
112
+ const reader = new FileReader()
113
+ reader.onload = (event) => {
114
+ try {
115
+ const jsonStr = event.target?.result as string
116
+ const data = JSON.parse(jsonStr)
117
+
118
+ // Handle both raw snapshot or our wrapped format
119
+ const snapshot = data.snapshot || data
120
+
121
+ if (window.confirm('This will replace the current board content. Are you sure?')) {
122
+ editor.loadSnapshot(snapshot)
123
+ }
124
+ } catch (e: any) {
125
+ console.error('Failed to parse backup file', e)
126
+ alert('Failed to restore backup: Invalid file format')
127
+ } finally {
128
+ // Reset input
129
+ if (inputRef.current) inputRef.current.value = ''
130
+ }
131
+ }
132
+ reader.readAsText(file)
133
+ }
134
+
135
+ return (
136
+ <>
137
+ <TldrawUiMenuItem
138
+ id="restore-json"
139
+ label="Restore from File"
140
+ icon="external-link"
141
+ readonlyOk
142
+ onSelect={handleRestoreClick}
143
+ />
144
+ <input
145
+ ref={inputRef}
146
+ type="file"
147
+ accept=".json"
148
+ style={{ display: 'none' }}
149
+ onChange={handleFileChange}
150
+ />
151
+ </>
152
+ )
153
+ }
client/pages/RoomPage.tsx CHANGED
@@ -33,7 +33,7 @@ import { BrushManager } from '../components/BrushManager'
33
  import { EquationRenderer } from '../components/EquationRendererSimple'
34
  import { getEraserSettings } from '../utils/eraserUtils'
35
  import { EquationShapeUtil } from '../shapes/EquationShapeUtil'
36
- import { BackupMenuItem } from '../components/BackupControl'
37
 
38
  const customShapeUtils = [
39
  ...defaultShapeUtils.filter(u => u.type !== 'geo'),
@@ -238,6 +238,10 @@ export function RoomPage() {
238
  onSelect={() => navigate('/')}
239
  />
240
  </TldrawUiMenuGroup>
 
 
 
 
241
  <TldrawUiMenuGroup id="share">
242
  <TldrawUiMenuItem
243
  id="share-link"
 
33
  import { EquationRenderer } from '../components/EquationRendererSimple'
34
  import { getEraserSettings } from '../utils/eraserUtils'
35
  import { EquationShapeUtil } from '../shapes/EquationShapeUtil'
36
+ import { BackupMenuItem, DownloadMenuItem, RestoreMenuItem } from '../components/BackupControl'
37
 
38
  const customShapeUtils = [
39
  ...defaultShapeUtils.filter(u => u.type !== 'geo'),
 
238
  onSelect={() => navigate('/')}
239
  />
240
  </TldrawUiMenuGroup>
241
+ <TldrawUiMenuGroup id="file">
242
+ <DownloadMenuItem roomId={roomId} />
243
+ <RestoreMenuItem />
244
+ </TldrawUiMenuGroup>
245
  <TldrawUiMenuGroup id="share">
246
  <TldrawUiMenuItem
247
  id="share-link"
public/color_rm.html CHANGED
The diff for this file is too large to render. See raw diff
 
public/scripts/ColorRmApp.js ADDED
The diff for this file is too large to render. See raw diff
 
public/scripts/CommonPdfImport.js ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PDFLibrary } from './PDFLibrary.js';
2
+
3
+ /**
4
+ * CommonPdfImport - Bridge between PDFLibrary and ColorRM apps
5
+ * Handles PDF selection/import for both main and split view apps
6
+ */
7
+ export const CommonPdfImport = {
8
+ target: null, // 'main' | 'split'
9
+ app: null,
10
+ splitViewApp: null,
11
+
12
+ /**
13
+ * Show PDF library for target app
14
+ * @param {'main'|'split'} target - Which app to import into
15
+ */
16
+ showLibrary(target = 'main') {
17
+ this.target = target;
18
+
19
+ PDFLibrary.show(async (pdf) => {
20
+ await this.importPdf(pdf);
21
+ });
22
+ },
23
+
24
+ /**
25
+ * Import selected PDF into target app
26
+ */
27
+ async importPdf(pdf) {
28
+ if (!pdf || !pdf.blob) {
29
+ console.error('CommonPdfImport: Invalid PDF entry');
30
+ return;
31
+ }
32
+
33
+ // Create a File object from the blob for handleImport
34
+ const file = new File([pdf.blob], pdf.name + '.pdf', { type: 'application/pdf' });
35
+
36
+ if (this.target === 'split') {
37
+ // Import into split view
38
+ const splitApp = this.splitViewApp || window.SplitView?.app;
39
+ if (splitApp) {
40
+ await this.importIntoApp(splitApp, file, pdf.name);
41
+ } else {
42
+ console.warn('CommonPdfImport: Split view app not available');
43
+ alert('Split View is not ready.');
44
+ }
45
+ } else {
46
+ // Import into main app
47
+ const mainApp = this.app || window.App;
48
+ if (mainApp && mainApp.handleImport) {
49
+ await mainApp.handleImport({ target: { files: [file] } }, false);
50
+ } else {
51
+ console.warn('CommonPdfImport: Main app not available');
52
+ alert('Main app is not ready.');
53
+ }
54
+ }
55
+ },
56
+
57
+ /**
58
+ * Import PDF into a specific ColorRmApp instance
59
+ */
60
+ async importIntoApp(app, file, name) {
61
+ if (!app) return;
62
+
63
+ try {
64
+ // Create a new session for this PDF
65
+ const projectId = `sv_${Date.now()}`;
66
+
67
+ // Create session in app's database
68
+ await app.dbPut('sessions', {
69
+ id: projectId,
70
+ name: name,
71
+ pageCount: 0,
72
+ lastMod: Date.now(),
73
+ ownerId: 'local',
74
+ idx: 0,
75
+ bookmarks: [],
76
+ clipboardBox: [],
77
+ state: null
78
+ });
79
+
80
+ // Set as current session
81
+ app.state.sessionId = projectId;
82
+ app.state.projectName = name;
83
+
84
+ // Import the PDF
85
+ await app.importBaseFile(file);
86
+
87
+ console.log('CommonPdfImport: Imported into app:', name);
88
+ } catch (error) {
89
+ console.error('CommonPdfImport: Import failed:', error);
90
+ alert('Import failed: ' + error.message);
91
+ }
92
+ },
93
+
94
+ /**
95
+ * Initialize with app references
96
+ */
97
+ init(mainApp) {
98
+ if (mainApp) {
99
+ this.app = mainApp;
100
+ }
101
+ window.CommonPdfImport = this;
102
+ window.PDFLibrary = PDFLibrary;
103
+ },
104
+
105
+ /**
106
+ * Set split view app reference
107
+ */
108
+ setSplitViewApp(app) {
109
+ this.splitViewApp = app;
110
+ },
111
+
112
+ // Legacy methods for backwards compatibility
113
+ show() {
114
+ this.showLibrary('main');
115
+ },
116
+
117
+ pick(target) {
118
+ this.showLibrary(target);
119
+ }
120
+ };
public/scripts/LiveSync.js ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { createClient, LiveObject, LiveMap, LiveList } from 'https://cdn.jsdelivr.net/npm/@liveblocks/client@3.12.1/+esm';
3
+
4
+ export class LiveSyncClient {
5
+ constructor(appInstance) {
6
+ this.app = appInstance;
7
+ this.client = null;
8
+ this.room = null;
9
+ this.userId = localStorage.getItem('color_rm_user_id');
10
+ this.ownerId = null;
11
+ this.projectId = null;
12
+ this.unsubscribes = [];
13
+ this.isInitializing = true;
14
+ this.root = null;
15
+ }
16
+
17
+ async init(ownerId, projectId) {
18
+ // We need Registry to be available globally or passed in.
19
+ // Assuming window.Registry is still global for now or we import it.
20
+ const regUser = window.Registry?.getUsername();
21
+ if (regUser) this.userId = regUser;
22
+
23
+ if (!this.userId) {
24
+ this.userId = `user_${Math.random().toString(36).substring(2, 9)}`;
25
+ localStorage.setItem('color_rm_user_id', this.userId);
26
+ }
27
+
28
+ const roomId = `room_${ownerId}`;
29
+ this.ownerId = ownerId;
30
+ this.projectId = projectId;
31
+
32
+ // Update URL only if this is the main app (hacky check? or let the app handle it)
33
+ // For now, only update hash if this sync client is attached to the main app.
34
+ // We can check this via a config flag on the app.
35
+ if (this.app.config.isMain) {
36
+ window.location.hash = `/color_rm/${ownerId}/${projectId}`;
37
+ }
38
+
39
+ if (this.room && this.room.id === roomId) {
40
+ console.log(`Liveblocks: Switching Project sub-key to ${projectId} in existing room.`);
41
+ await this.setupProjectSync(projectId);
42
+ return;
43
+ }
44
+
45
+ if (this.room) this.leave();
46
+
47
+ this.app.ui.setSyncStatus('syncing');
48
+ console.log(`Liveblocks: Connecting to Owner Room: ${roomId}`);
49
+
50
+ if (!this.client) {
51
+ this.client = createClient({
52
+ authEndpoint: "/api/liveblocks-auth",
53
+ });
54
+ }
55
+
56
+ const { room, leave } = this.client.enterRoom(roomId, {
57
+ initialStorage: {
58
+ projects: new LiveMap()
59
+ }
60
+ });
61
+
62
+ this.room = room;
63
+ this.leave = leave;
64
+
65
+ room.subscribe("status", (status) => {
66
+ if (status === "connected") this.app.ui.setSyncStatus('saved');
67
+ else if (status === "disconnected") this.app.ui.setSyncStatus('offline');
68
+ if (this.app.renderDebug) this.app.renderDebug();
69
+ });
70
+
71
+ const { root } = await room.getStorage();
72
+ this.root = root;
73
+
74
+ await this.setupProjectSync(projectId);
75
+
76
+ this.isInitializing = false;
77
+ console.log("Liveblocks: Room Ready.");
78
+ }
79
+
80
+ async setupProjectSync(projectId) {
81
+ if (!this.root) {
82
+ const { root } = await this.room.getStorage();
83
+ this.root = root;
84
+ }
85
+ const projects = this.root.get("projects");
86
+
87
+ // Ensure the project structure exists
88
+ if (!projects.has(projectId)) {
89
+ projects.set(projectId, new LiveObject({
90
+ metadata: new LiveObject({
91
+ name: this.app.state.projectName || "Untitled",
92
+ baseFileName: this.app.state.baseFileName || null,
93
+ idx: 0,
94
+ pageCount: 0,
95
+ pageLocked: false,
96
+ ownerId: this.ownerId
97
+ }),
98
+ pagesHistory: new LiveMap(),
99
+ bookmarks: new LiveList([]),
100
+ colors: new LiveList([])
101
+ }));
102
+ }
103
+
104
+ this.syncStorageToLocal();
105
+
106
+ // Refresh project-specific subscription
107
+ this.unsubscribes.forEach(unsub => unsub());
108
+ this.unsubscribes = [
109
+ this.room.subscribe(projects.get(projectId), () => {
110
+ this.syncProjectData();
111
+ if (this.app.renderDebug) this.app.renderDebug();
112
+ }, { isDeep: true }),
113
+ // Subscribe to Presence (Others)
114
+ this.room.subscribe("others", () => {
115
+ this.renderUsers();
116
+ this.renderCursors();
117
+ })
118
+ ];
119
+
120
+ // Initialize presence for self
121
+ this.room.updatePresence({
122
+ userId: this.userId,
123
+ userName: "User " + this.userId.slice(-4),
124
+ cursor: null,
125
+ pageIdx: this.app.state.idx
126
+ });
127
+
128
+ this.renderUsers();
129
+ }
130
+
131
+ updateCursor(pt) {
132
+ if (!this.room) return;
133
+ this.room.updatePresence({
134
+ cursor: pt,
135
+ pageIdx: this.app.state.idx
136
+ });
137
+ }
138
+
139
+ renderCursors() {
140
+ const container = this.app.getElement('cursorLayer');
141
+ if (!container) return;
142
+
143
+ // Clear old cursors
144
+ container.innerHTML = '';
145
+
146
+ if (!this.app.state.showCursors) return;
147
+
148
+ if (!this.room) return;
149
+
150
+ const others = this.room.getOthers();
151
+ const canvas = this.app.getElement('canvas');
152
+ const viewport = this.app.getElement('viewport');
153
+ if (!canvas || !viewport) return;
154
+
155
+ const rect = canvas.getBoundingClientRect();
156
+ const viewRect = viewport.getBoundingClientRect();
157
+
158
+ others.forEach(user => {
159
+ const presence = user.presence;
160
+ if (!presence || !presence.cursor || presence.pageIdx !== this.app.state.idx) return;
161
+
162
+ const div = document.createElement('div');
163
+ div.className = 'remote-cursor';
164
+
165
+ // Map canvas coordinates to screen coordinates
166
+ const x = (presence.cursor.x * this.app.state.zoom + this.app.state.pan.x) * (rect.width / this.app.state.viewW) + rect.left - viewRect.left;
167
+ const y = (presence.cursor.y * this.app.state.zoom + this.app.state.pan.y) * (rect.height / this.app.state.viewH) + rect.top - viewRect.top;
168
+
169
+ div.style.left = `${x}px`;
170
+ div.style.top = `${y}px`;
171
+ div.style.borderColor = 'var(--accent)';
172
+
173
+ div.innerHTML = `
174
+ <div class="cursor-pointer"></div>
175
+ <div class="cursor-label">${presence.userName || 'User'}</div>
176
+ `;
177
+ container.appendChild(div);
178
+ });
179
+ }
180
+
181
+ renderUsers() {
182
+ const el = this.app.getElement('userList');
183
+ if (!el) return;
184
+
185
+ const others = this.room.getOthers();
186
+ let html = `
187
+ <div class="user-item self">
188
+ <div class="user-dot" style="background:var(--primary)"></div>
189
+ <span>You (${this.userId.slice(-4)})</span>
190
+ </div>
191
+ `;
192
+
193
+ others.forEach(user => {
194
+ const info = user.presence;
195
+ if (!info || !info.userId) return;
196
+ html += `
197
+ <div class="user-item">
198
+ <div class="user-dot" style="background:var(--accent)"></div>
199
+ <span>Collaborator (${info.userId.slice(-4)})</span>
200
+ </div>
201
+ `;
202
+ });
203
+
204
+ el.innerHTML = html;
205
+ }
206
+
207
+ getProject() {
208
+ if (!this.root || !this.projectId) return null;
209
+ return this.root.get("projects").get(this.projectId);
210
+ }
211
+
212
+ syncStorageToLocal() {
213
+ const project = this.getProject();
214
+ if (!project) return;
215
+
216
+ const metadata = project.get("metadata").toObject();
217
+ this.app.state.projectName = metadata.name;
218
+ this.app.state.idx = metadata.idx;
219
+ this.app.state.pageLocked = metadata.pageLocked;
220
+ this.app.state.ownerId = metadata.ownerId;
221
+
222
+ const titleEl = this.app.getElement('headerTitle');
223
+ if(titleEl) titleEl.innerText = metadata.name;
224
+
225
+ this.app.state.bookmarks = project.get("bookmarks").toArray();
226
+ this.app.renderBookmarks();
227
+
228
+ this.app.state.colors = project.get("colors").toArray();
229
+ this.app.renderSwatches();
230
+
231
+ this.syncHistory();
232
+ this.app.loadPage(this.app.state.idx, false);
233
+ }
234
+
235
+ syncProjectData() {
236
+ const project = this.getProject();
237
+ if (!project || this.isInitializing) return;
238
+
239
+ const metadata = project.get("metadata").toObject();
240
+ console.log(`Liveblocks Sync: Remote PageCount=${metadata.pageCount}, Local PageCount=${this.app.state.images.length}`);
241
+
242
+ this.app.state.projectName = metadata.name;
243
+ this.app.state.baseFileName = metadata.baseFileName || null;
244
+ this.app.state.pageLocked = metadata.pageLocked;
245
+ this.app.state.ownerId = metadata.ownerId;
246
+
247
+ const titleEl = this.app.getElement('headerTitle');
248
+ if(titleEl) titleEl.innerText = metadata.name;
249
+
250
+ if (this.app.state.idx !== metadata.idx) {
251
+ this.app.loadPage(metadata.idx, false);
252
+ }
253
+
254
+ // AUTO-RETRY base file fetch if remote has pages but we don't
255
+ if (metadata.pageCount > 0 && this.app.state.images.length === 0) {
256
+ console.log("Liveblocks: Remote has content but local is empty. Triggering fetch...");
257
+ this.app.retryBaseFetch();
258
+ }
259
+
260
+ this.syncHistory();
261
+ this.app.updateLockUI();
262
+ }
263
+
264
+ syncHistory() {
265
+ const project = this.getProject();
266
+ if (!project || this.isInitializing) return;
267
+
268
+ const pagesHistory = project.get("pagesHistory");
269
+ let currentIdxChanged = false;
270
+
271
+ // Priority: Update current page immediately
272
+ const currentRemote = pagesHistory.get(this.app.state.idx.toString());
273
+ if (currentRemote) {
274
+ const newHist = currentRemote.toArray();
275
+ const localImg = this.app.state.images[this.app.state.idx];
276
+ if (localImg) {
277
+ localImg.history = newHist;
278
+ currentIdxChanged = true;
279
+ }
280
+ }
281
+
282
+ // Background sync all other pages
283
+ this.app.state.images.forEach((img, idx) => {
284
+ if (idx === this.app.state.idx) return; // Already handled
285
+ const remote = pagesHistory.get(idx.toString());
286
+ if (remote) img.history = remote.toArray();
287
+ });
288
+
289
+ if (currentIdxChanged) this.app.render();
290
+ }
291
+
292
+ // --- Local -> Remote Updates ---
293
+ updateMetadata(updates) {
294
+ const project = this.getProject();
295
+ if (project) project.get("metadata").update(updates);
296
+ }
297
+
298
+ addStroke(pageIdx, stroke) {
299
+ const project = this.getProject();
300
+ if (!project) return;
301
+ const pagesHistory = project.get("pagesHistory");
302
+ const key = pageIdx.toString();
303
+ if (!pagesHistory.has(key)) {
304
+ pagesHistory.set(key, new LiveList([]));
305
+ }
306
+ pagesHistory.get(key).push(stroke);
307
+ }
308
+
309
+ setHistory(pageIdx, history) {
310
+ const project = this.getProject();
311
+ if (!project) return;
312
+ const pagesHistory = project.get("pagesHistory");
313
+ pagesHistory.set(pageIdx.toString(), new LiveList(history || []));
314
+ }
315
+
316
+ updateBookmarks(bookmarks) {
317
+ const project = this.getProject();
318
+ if (project) project.set("bookmarks", new LiveList(bookmarks || []));
319
+ }
320
+
321
+ updateColors(colors) {
322
+ const project = this.getProject();
323
+ if (project) project.set("colors", new LiveList(colors || []));
324
+ }
325
+ }
public/scripts/PDFLibrary.js ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PDFLibrary - Shared PDF storage for ColorRM apps
3
+ * Both main (collaborative) and split view (local) apps can access this library
4
+ */
5
+
6
+ export const PDFLibrary = {
7
+ DB_NAME: 'ColorRM_PDFLibrary',
8
+ DB_VERSION: 1,
9
+ STORE_NAME: 'pdfs',
10
+
11
+ db: null,
12
+ modal: null,
13
+ onSelect: null, // Callback when PDF is selected
14
+
15
+ /**
16
+ * Initialize the PDF library database
17
+ */
18
+ async init() {
19
+ return new Promise((resolve, reject) => {
20
+ const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
21
+
22
+ request.onerror = () => reject(request.error);
23
+ request.onsuccess = () => {
24
+ this.db = request.result;
25
+ console.log('PDFLibrary: Database initialized');
26
+ resolve();
27
+ };
28
+
29
+ request.onupgradeneeded = (event) => {
30
+ const db = event.target.result;
31
+ if (!db.objectStoreNames.contains(this.STORE_NAME)) {
32
+ const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
33
+ store.createIndex('name', 'name', { unique: false });
34
+ store.createIndex('timestamp', 'timestamp', { unique: false });
35
+ }
36
+ };
37
+ });
38
+ },
39
+
40
+ /**
41
+ * Upload a PDF to the library
42
+ */
43
+ async upload(file) {
44
+ if (!this.db) await this.init();
45
+ if (!file || !file.type.includes('pdf')) {
46
+ throw new Error('Invalid file type. Please select a PDF.');
47
+ }
48
+
49
+ const id = `pdf_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
50
+ const name = file.name.replace('.pdf', '').replace('.PDF', '');
51
+
52
+ const entry = {
53
+ id,
54
+ name,
55
+ blob: file,
56
+ size: file.size,
57
+ timestamp: Date.now()
58
+ };
59
+
60
+ return new Promise((resolve, reject) => {
61
+ const tx = this.db.transaction([this.STORE_NAME], 'readwrite');
62
+ const store = tx.objectStore(this.STORE_NAME);
63
+ const req = store.put(entry);
64
+
65
+ req.onsuccess = () => {
66
+ console.log('PDFLibrary: Uploaded', name);
67
+ resolve(entry);
68
+ };
69
+ req.onerror = () => reject(req.error);
70
+ });
71
+ },
72
+
73
+ /**
74
+ * Get all PDFs in the library
75
+ */
76
+ async getAll() {
77
+ if (!this.db) await this.init();
78
+
79
+ return new Promise((resolve, reject) => {
80
+ const tx = this.db.transaction([this.STORE_NAME], 'readonly');
81
+ const store = tx.objectStore(this.STORE_NAME);
82
+ const req = store.getAll();
83
+
84
+ req.onsuccess = () => {
85
+ const results = req.result || [];
86
+ results.sort((a, b) => b.timestamp - a.timestamp);
87
+ resolve(results);
88
+ };
89
+ req.onerror = () => reject(req.error);
90
+ });
91
+ },
92
+
93
+ /**
94
+ * Get a specific PDF by ID
95
+ */
96
+ async get(id) {
97
+ if (!this.db) await this.init();
98
+
99
+ return new Promise((resolve, reject) => {
100
+ const tx = this.db.transaction([this.STORE_NAME], 'readonly');
101
+ const store = tx.objectStore(this.STORE_NAME);
102
+ const req = store.get(id);
103
+
104
+ req.onsuccess = () => resolve(req.result);
105
+ req.onerror = () => reject(req.error);
106
+ });
107
+ },
108
+
109
+ /**
110
+ * Delete a PDF from the library
111
+ */
112
+ async delete(id) {
113
+ if (!this.db) await this.init();
114
+
115
+ return new Promise((resolve, reject) => {
116
+ const tx = this.db.transaction([this.STORE_NAME], 'readwrite');
117
+ const store = tx.objectStore(this.STORE_NAME);
118
+ const req = store.delete(id);
119
+
120
+ req.onsuccess = () => {
121
+ console.log('PDFLibrary: Deleted', id);
122
+ resolve();
123
+ };
124
+ req.onerror = () => reject(req.error);
125
+ });
126
+ },
127
+
128
+ /**
129
+ * Format file size for display
130
+ */
131
+ formatSize(bytes) {
132
+ if (bytes < 1024) return bytes + ' B';
133
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
134
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
135
+ },
136
+
137
+ /**
138
+ * Create and inject the modal HTML
139
+ */
140
+ createModal() {
141
+ if (document.getElementById('pdfLibraryModal')) return;
142
+
143
+ const modal = document.createElement('div');
144
+ modal.id = 'pdfLibraryModal';
145
+ modal.className = 'overlay';
146
+ modal.style.cssText = 'display:none; position:fixed; inset:0; background:rgba(0,0,0,0.85); z-index:10000; align-items:center; justify-content:center;';
147
+
148
+ modal.innerHTML = `
149
+ <div class="card" style="width:90%; max-width:600px; max-height:85vh; display:flex; flex-direction:column;">
150
+ <!-- Header -->
151
+ <div style="display:flex; justify-content:space-between; align-items:center; padding:20px; border-bottom:1px solid var(--border);">
152
+ <div>
153
+ <h3 style="margin:0; font-size:1.2rem;">PDF Library</h3>
154
+ <p style="margin:4px 0 0; font-size:0.75rem; color:#666;">Select a PDF to open or upload a new one</p>
155
+ </div>
156
+ <button id="pdfLibraryClose" class="btn btn-icon" style="background:none; border:none;">
157
+ <i class="bi bi-x-lg"></i>
158
+ </button>
159
+ </div>
160
+
161
+ <!-- Upload Area -->
162
+ <div id="pdfLibraryDropzone" style="margin:16px; padding:24px; border:2px dashed #333; border-radius:8px; text-align:center; cursor:pointer; transition:all 0.2s;">
163
+ <i class="bi bi-cloud-arrow-up" style="font-size:2rem; color:#666;"></i>
164
+ <p style="margin:8px 0 0; color:#888; font-size:0.85rem;">Click or drag PDF here to upload</p>
165
+ <input type="file" id="pdfLibraryInput" accept=".pdf" style="display:none;">
166
+ </div>
167
+
168
+ <!-- PDF List -->
169
+ <div style="flex:1; overflow-y:auto; padding:0 16px 16px;">
170
+ <div id="pdfLibraryList"></div>
171
+ </div>
172
+
173
+ <!-- Footer -->
174
+ <div style="padding:16px; border-top:1px solid var(--border); display:flex; gap:12px;">
175
+ <button id="pdfLibraryCancel" class="btn" style="flex:1; justify-content:center;">
176
+ Cancel
177
+ </button>
178
+ </div>
179
+ </div>
180
+ `;
181
+
182
+ document.body.appendChild(modal);
183
+ this.modal = modal;
184
+ this.bindModalEvents();
185
+ },
186
+
187
+ /**
188
+ * Bind modal event handlers
189
+ */
190
+ bindModalEvents() {
191
+ const modal = this.modal;
192
+ if (!modal) return;
193
+
194
+ // Close buttons
195
+ modal.querySelector('#pdfLibraryClose').onclick = () => this.hide();
196
+ modal.querySelector('#pdfLibraryCancel').onclick = () => this.hide();
197
+
198
+ // Click outside to close
199
+ modal.onclick = (e) => {
200
+ if (e.target === modal) this.hide();
201
+ };
202
+
203
+ // Dropzone
204
+ const dropzone = modal.querySelector('#pdfLibraryDropzone');
205
+ const input = modal.querySelector('#pdfLibraryInput');
206
+
207
+ dropzone.onclick = () => input.click();
208
+
209
+ dropzone.ondragover = (e) => {
210
+ e.preventDefault();
211
+ dropzone.style.borderColor = 'var(--primary)';
212
+ dropzone.style.background = 'rgba(59, 130, 246, 0.1)';
213
+ };
214
+
215
+ dropzone.ondragleave = () => {
216
+ dropzone.style.borderColor = '#333';
217
+ dropzone.style.background = 'transparent';
218
+ };
219
+
220
+ dropzone.ondrop = async (e) => {
221
+ e.preventDefault();
222
+ dropzone.style.borderColor = '#333';
223
+ dropzone.style.background = 'transparent';
224
+
225
+ const file = e.dataTransfer.files[0];
226
+ if (file) await this.handleUpload(file);
227
+ };
228
+
229
+ input.onchange = async (e) => {
230
+ const file = e.target.files[0];
231
+ if (file) await this.handleUpload(file);
232
+ input.value = '';
233
+ };
234
+ },
235
+
236
+ /**
237
+ * Handle file upload
238
+ */
239
+ async handleUpload(file) {
240
+ try {
241
+ await this.upload(file);
242
+ await this.refreshList();
243
+ } catch (err) {
244
+ alert('Upload failed: ' + err.message);
245
+ }
246
+ },
247
+
248
+ /**
249
+ * Refresh the PDF list
250
+ */
251
+ async refreshList() {
252
+ const container = document.getElementById('pdfLibraryList');
253
+ if (!container) return;
254
+
255
+ const pdfs = await this.getAll();
256
+
257
+ if (pdfs.length === 0) {
258
+ container.innerHTML = `
259
+ <div style="text-align:center; padding:40px; color:#666;">
260
+ <i class="bi bi-file-earmark-pdf" style="font-size:3rem; opacity:0.3;"></i>
261
+ <p style="margin-top:12px;">No PDFs in library</p>
262
+ </div>
263
+ `;
264
+ return;
265
+ }
266
+
267
+ container.innerHTML = '';
268
+
269
+ pdfs.forEach(pdf => {
270
+ const date = new Date(pdf.timestamp).toLocaleDateString();
271
+ const item = document.createElement('div');
272
+ item.className = 'bm-item';
273
+ item.style.cssText = 'display:flex; align-items:center; gap:12px; padding:14px; margin-bottom:8px; cursor:pointer;';
274
+
275
+ item.innerHTML = `
276
+ <i class="bi bi-file-earmark-pdf" style="font-size:1.5rem; color:#ef4444;"></i>
277
+ <div style="flex:1; min-width:0;">
278
+ <div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${pdf.name}</div>
279
+ <div style="font-size:0.75rem; color:#666;">${this.formatSize(pdf.size)} &bull; ${date}</div>
280
+ </div>
281
+ <button class="pdf-delete-btn bm-del" data-id="${pdf.id}" title="Delete" style="opacity:0.5;">
282
+ <i class="bi bi-trash3"></i>
283
+ </button>
284
+ `;
285
+
286
+ // Select PDF
287
+ item.onclick = (e) => {
288
+ if (e.target.closest('.pdf-delete-btn')) return;
289
+ this.selectPdf(pdf);
290
+ };
291
+
292
+ // Delete button
293
+ item.querySelector('.pdf-delete-btn').onclick = async (e) => {
294
+ e.stopPropagation();
295
+ if (confirm(`Delete "${pdf.name}"?`)) {
296
+ await this.delete(pdf.id);
297
+ await this.refreshList();
298
+ }
299
+ };
300
+
301
+ container.appendChild(item);
302
+ });
303
+ },
304
+
305
+ /**
306
+ * Handle PDF selection
307
+ */
308
+ selectPdf(pdf) {
309
+ this.hide();
310
+ if (this.onSelect) {
311
+ this.onSelect(pdf);
312
+ }
313
+ },
314
+
315
+ /**
316
+ * Show the library modal
317
+ * @param {Function} callback - Called with selected PDF entry
318
+ */
319
+ async show(callback) {
320
+ this.createModal();
321
+ this.onSelect = callback;
322
+
323
+ if (this.modal) {
324
+ this.modal.style.display = 'flex';
325
+ await this.refreshList();
326
+ }
327
+ },
328
+
329
+ /**
330
+ * Hide the library modal
331
+ */
332
+ hide() {
333
+ if (this.modal) {
334
+ this.modal.style.display = 'none';
335
+ }
336
+ }
337
+ };
338
+
339
+ // Auto-initialize
340
+ PDFLibrary.init().catch(console.error);
public/scripts/Registry.js ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ export const Registry = {
3
+ // App instance reference - set by the main app during initialization
4
+ app: null,
5
+
6
+ setApp(appInstance) {
7
+ this.app = appInstance;
8
+ },
9
+
10
+ getApp() {
11
+ // Fallback to window.App for backwards compatibility
12
+ return this.app || window.App;
13
+ },
14
+
15
+ getToken() { return localStorage.getItem('tldraw_auth_token'); },
16
+ getUsername() { return localStorage.getItem('tldraw_auth_username'); },
17
+
18
+ async sync() {
19
+ const token = this.getToken();
20
+ if (!token) return; // Anonymous users don't sync registry
21
+
22
+ try {
23
+ // 1. Fetch Cloud Registry
24
+ const res = await fetch('/api/color_rm/registry', {
25
+ headers: { 'Authorization': `Bearer ${token}` }
26
+ });
27
+ if (!res.ok) {
28
+ if (res.status === 401) {
29
+ console.warn("Registry: Auth token invalid/expired, skipping sync.");
30
+ return; // Silent fail for expired tokens
31
+ }
32
+ throw new Error("Registry fetch failed");
33
+ }
34
+ const data = await res.json();
35
+ console.log('Registry Sync Data:', data);
36
+ const cloudProjects = data.projects || [];
37
+ console.log('Cloud Projects:', cloudProjects);
38
+ const cloudIds = new Set(cloudProjects.map(p => p.id));
39
+
40
+ // 2. Merge into Local DB
41
+ const app = this.getApp();
42
+ if (!app || !app.db) {
43
+ console.warn("Registry: App DB not ready.");
44
+ return;
45
+ }
46
+
47
+ const tx = app.db.transaction('sessions', 'readwrite');
48
+ const store = tx.objectStore('sessions');
49
+
50
+ // Get all local first to compare
51
+ const localRequest = await new Promise((resolve) => {
52
+ const r = store.getAll();
53
+ r.onsuccess = () => resolve(r.result);
54
+ });
55
+
56
+ const localMap = new Map(localRequest.map(p => [p.id, p]));
57
+
58
+ for (const cp of cloudProjects) {
59
+ const local = localMap.get(cp.id);
60
+
61
+ if (!local) {
62
+ // New project from cloud (metadata only)
63
+ await app.dbPut('sessions', {
64
+ id: cp.id,
65
+ name: cp.name,
66
+ pageCount: cp.pageCount,
67
+ lastMod: cp.lastMod,
68
+ ownerId: cp.ownerId,
69
+ baseFileName: cp.baseFileName,
70
+ idx: 0,
71
+ bookmarks: [],
72
+ clipboardBox: [],
73
+ state: null,
74
+ isCloudBackedUp: true
75
+ });
76
+ } else {
77
+ // Update existing
78
+ let changed = false;
79
+ if (cp.lastMod > (local.lastMod || 0)) {
80
+ local.name = cp.name;
81
+ local.pageCount = cp.pageCount;
82
+ local.lastMod = cp.lastMod;
83
+ local.ownerId = cp.ownerId;
84
+ changed = true;
85
+ }
86
+ // Mark as backed up
87
+ if (!local.isCloudBackedUp) {
88
+ local.isCloudBackedUp = true;
89
+ changed = true;
90
+ }
91
+
92
+ if (changed) await app.dbPut('sessions', local);
93
+ }
94
+ }
95
+
96
+ // Optional: Mark locals as NOT backed up if they are missing from cloud list?
97
+ // Only do this if we are sure the list is complete.
98
+ const userId = localStorage.getItem('color_rm_user_id') || (app.liveSync && app.liveSync.userId);
99
+
100
+ for (const [id, local] of localMap) {
101
+ if (local.isCloudBackedUp && !cloudIds.has(id)) {
102
+ // Verify ownership before unmarking
103
+ if (local.ownerId === userId) {
104
+ local.isCloudBackedUp = false;
105
+ await app.dbPut('sessions', local);
106
+ }
107
+ }
108
+ }
109
+
110
+ } catch (e) {
111
+ console.warn("Registry sync error:", e);
112
+ }
113
+ },
114
+
115
+ async upsert(project) {
116
+ const token = this.getToken();
117
+ if (!token) return;
118
+
119
+ // Prepare lightweight metadata object
120
+ const payload = {
121
+ id: project.id,
122
+ name: project.name,
123
+ pageCount: project.pageCount,
124
+ lastMod: project.lastMod,
125
+ ownerId: project.ownerId,
126
+ baseFileName: project.baseFileName
127
+ };
128
+
129
+ fetch('/api/color_rm/registry', {
130
+ method: 'POST',
131
+ headers: {
132
+ 'Authorization': `Bearer ${token}`,
133
+ 'Content-Type': 'application/json'
134
+ },
135
+ body: JSON.stringify({ project: payload })
136
+ })
137
+ .then(res => {
138
+ if (res.status === 401) {
139
+ console.warn("Registry: Auth token expired on upsert.");
140
+ return;
141
+ }
142
+ if (res.ok) {
143
+ // Mark as backed up locally
144
+ const app = this.getApp();
145
+ if (app && app.dbGet) {
146
+ app.dbGet('sessions', project.id).then(s => {
147
+ if (s) {
148
+ s.isCloudBackedUp = true;
149
+ app.dbPut('sessions', s).then(() => {
150
+ // Refresh list if dashboard is open
151
+ if(document.getElementById('dashboardModal') && document.getElementById('dashboardModal').style.display === 'flex') {
152
+ app.loadSessionList();
153
+ }
154
+ });
155
+ }
156
+ });
157
+ }
158
+ }
159
+ })
160
+ .catch(e => console.warn("Registry upsert failed:", e));
161
+ },
162
+
163
+ async delete(projectId) {
164
+ const token = this.getToken();
165
+ if (!token) return;
166
+
167
+ fetch(`/api/color_rm/registry/${projectId}`, {
168
+ method: 'DELETE',
169
+ headers: { 'Authorization': `Bearer ${token}` }
170
+ })
171
+ .then(res => {
172
+ if (res.status === 401) {
173
+ console.warn("Registry: Auth token expired on delete.");
174
+ }
175
+ })
176
+ .catch(e => console.warn("Registry delete failed:", e));
177
+ }
178
+ };
public/scripts/SplitView.js ADDED
@@ -0,0 +1,828 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ColorRmApp } from './ColorRmApp.js';
2
+ import { CommonPdfImport } from './CommonPdfImport.js';
3
+ import { PDFLibrary } from './PDFLibrary.js';
4
+
5
+ /**
6
+ * Split View - Full-featured local drawing viewer
7
+ * Uses a second ColorRmApp instance in non-collaborative mode
8
+ */
9
+
10
+ // Minimal UI stub for split view (no dashboard, toasts are optional)
11
+ const SplitViewUI = {
12
+ showDashboard() {},
13
+ hideDashboard() {},
14
+ showToast(msg) { console.log('SplitView:', msg); },
15
+ showInput(title, placeholder, callback) {
16
+ const text = prompt(title);
17
+ if (text) callback(text);
18
+ },
19
+ showExportModal() {},
20
+ showLoader() {},
21
+ hideLoader() {},
22
+ toggleLoader(show, msg) {
23
+ // Simple console feedback for split view
24
+ if (show) console.log('SplitView Loading:', msg || '...');
25
+ else console.log('SplitView: Loading complete');
26
+ },
27
+ updateProgress(percent, msg) {
28
+ console.log(`SplitView Progress: ${Math.round(percent)}% - ${msg || ''}`);
29
+ },
30
+ setSyncStatus(status) {}
31
+ };
32
+
33
+ export const SplitView = {
34
+ isEnabled: false,
35
+ app: null, // ColorRmApp instance for split view
36
+
37
+ // IndexedDB for project list (separate from main app)
38
+ rightDB: null,
39
+ DB_NAME: 'ColorRMSplitViewFull',
40
+ DB_VERSION: 1,
41
+ STORE_NAME: 'projects',
42
+
43
+ /**
44
+ * Initialize DB for project metadata
45
+ */
46
+ async initDB() {
47
+ return new Promise((resolve, reject) => {
48
+ const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
49
+
50
+ request.onerror = () => reject(request.error);
51
+ request.onsuccess = () => {
52
+ this.rightDB = request.result;
53
+ resolve();
54
+ };
55
+
56
+ request.onupgradeneeded = (event) => {
57
+ const db = event.target.result;
58
+ if (!db.objectStoreNames.contains(this.STORE_NAME)) {
59
+ db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
60
+ }
61
+ };
62
+ });
63
+ },
64
+
65
+ /**
66
+ * Toggle split view
67
+ */
68
+ async toggle() {
69
+ if (!this.rightDB) await this.initDB();
70
+
71
+ this.isEnabled = !this.isEnabled;
72
+
73
+ if (this.isEnabled) {
74
+ await this.enable();
75
+ } else {
76
+ this.disable();
77
+ }
78
+ },
79
+
80
+ /**
81
+ * Enable split view
82
+ */
83
+ async enable() {
84
+ const viewport = document.querySelector('.viewport');
85
+ const workspace = document.querySelector('.workspace');
86
+ const sidebar = document.querySelector('.sidebar');
87
+
88
+ if (!viewport || !workspace) return;
89
+
90
+ // Create container
91
+ let container = document.getElementById('splitViewContainer');
92
+ if (!container) {
93
+ container = document.createElement('div');
94
+ container.id = 'splitViewContainer';
95
+ container.className = 'split-view-container';
96
+ container.innerHTML = this.getHTML();
97
+ workspace.appendChild(container);
98
+ }
99
+
100
+ container.style.display = 'flex';
101
+
102
+ // Move main viewport to left panel
103
+ const leftPanel = document.getElementById('leftPanel');
104
+ if (leftPanel) {
105
+ viewport.dataset.originalParent = 'workspace';
106
+ leftPanel.appendChild(viewport);
107
+ }
108
+
109
+ // Keep main sidebar visible
110
+ if (sidebar) sidebar.style.display = 'flex';
111
+
112
+ // Initialize the split view app
113
+ await this.initApp();
114
+
115
+ // Bind events
116
+ this.bindEvents();
117
+
118
+ // Load last project if available
119
+ await this.loadLastProject();
120
+
121
+ console.log('Split view enabled (full drawing mode)');
122
+ },
123
+
124
+ /**
125
+ * Get HTML for split view panel
126
+ */
127
+ getHTML() {
128
+ return `
129
+ <!-- Panels Container -->
130
+ <div style="display:flex; flex:1; overflow:hidden; gap:1px;">
131
+ <!-- Left: Main Canvas -->
132
+ <div class="split-view-panel" id="leftPanel" style="flex:1;"></div>
133
+
134
+ <!-- Right: Full Drawing App -->
135
+ <div id="rightAppContainer" style="display:flex; flex:1; overflow:hidden; position:relative;">
136
+
137
+ <!-- Right Sidebar -->
138
+ <div id="svSidebar" class="sidebar" style="width:200px; border-left:none; border-right:1px solid var(--border); display:flex; flex-direction:column; z-index:40;">
139
+
140
+ <!-- Sidebar Tabs -->
141
+ <div class="sb-tabs">
142
+ <div class="sb-tab active" id="svTabTools" data-tab="tools">Tools</div>
143
+ <div class="sb-tab" id="svTabPages" data-tab="pages">Pages</div>
144
+ <div class="sb-tab" id="svHideSidebar" style="flex:0; padding:14px; min-width:40px; cursor:pointer;" title="Hide sidebar"><i class="bi bi-chevron-left"></i></div>
145
+ </div>
146
+
147
+ <!-- Tools Panel -->
148
+ <div class="sidebar-content" id="svPanelTools" style="padding:16px;">
149
+ <div class="control-section">
150
+ <h4>Instruments</h4>
151
+ <div class="tool-row">
152
+ <button class="btn tool-btn" id="svToolNone" data-tool="none"><i class="bi bi-cursor"></i> Move</button>
153
+ <button class="btn tool-btn" id="svToolHand" data-tool="hand"><i class="bi bi-hand-index-thumb"></i> Hand</button>
154
+ </div>
155
+ <div class="tool-row">
156
+ <button class="btn tool-btn" id="svToolPen" data-tool="pen"><i class="bi bi-pen"></i> Pen</button>
157
+ <button class="btn tool-btn" id="svToolEraser" data-tool="eraser"><i class="bi bi-eraser"></i> Erase</button>
158
+ </div>
159
+ <div class="tool-row">
160
+ <button class="btn tool-btn" id="svToolShape" data-tool="shape"><i class="bi bi-square"></i> Shape</button>
161
+ <button class="btn tool-btn" id="svToolLasso" data-tool="lasso"><i class="bi bi-bounding-box-circles"></i> Lasso</button>
162
+ </div>
163
+ </div>
164
+
165
+ <!-- Tool Settings -->
166
+ <div class="control-section" id="svToolSettings" style="margin-top:16px;">
167
+ <h4>Settings</h4>
168
+
169
+ <!-- Pen Colors -->
170
+ <div id="svPenColors" style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px;">
171
+ <div class="color-dot" style="background:#ef4444" data-color="#ef4444"></div>
172
+ <div class="color-dot" style="background:#3b82f6" data-color="#3b82f6"></div>
173
+ <div class="color-dot" style="background:#22c55e" data-color="#22c55e"></div>
174
+ <div class="color-dot" style="background:#eab308" data-color="#eab308"></div>
175
+ <div class="color-dot" style="background:#000000" data-color="#000000"></div>
176
+ <div class="color-dot" style="background:#ffffff; border-color:#666;" data-color="#ffffff"></div>
177
+ </div>
178
+
179
+ <!-- Size Slider -->
180
+ <div style="display:flex; align-items:center; gap:8px;">
181
+ <span style="font-size:0.7rem; color:#888;">Size</span>
182
+ <input type="range" id="svBrushSize" min="1" max="50" value="3" class="slider" style="flex:1">
183
+ </div>
184
+
185
+ <!-- Shape Options (shown when shape tool selected) -->
186
+ <div id="svShapeOptions" style="display:none; margin-top:12px;">
187
+ <div class="shape-grid" style="grid-template-columns: repeat(4, 1fr);">
188
+ <div class="shape-btn" data-shape="rectangle"><i class="bi bi-square"></i></div>
189
+ <div class="shape-btn" data-shape="circle"><i class="bi bi-circle"></i></div>
190
+ <div class="shape-btn" data-shape="line"><i class="bi bi-dash-lg"></i></div>
191
+ <div class="shape-btn" data-shape="arrow"><i class="bi bi-arrow-right"></i></div>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- Eraser Options -->
196
+ <div id="svEraserOptions" style="display:none; margin-top:12px;">
197
+ <div style="display:flex; justify-content:space-between; align-items:center;">
198
+ <span style="font-size:0.8rem; color:#aaa">Stroke Eraser</span>
199
+ <label class="switch" style="transform:scale(0.8)">
200
+ <input type="checkbox" id="svStrokeEraser">
201
+ <span class="slider-switch"></span>
202
+ </label>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- Preview Toggle -->
208
+ <div class="control-section" style="margin-top:16px;">
209
+ <div style="display:flex; align-items:center; justify-content:space-between; padding:10px; border:1px solid #222; border-radius:6px; background:#0a0a0a;">
210
+ <span style="font-size:0.75rem; color:#888; font-weight:600; text-transform:uppercase;">Preview</span>
211
+ <label class="switch">
212
+ <input type="checkbox" id="svPreviewToggle">
213
+ <span class="slider-switch"></span>
214
+ </label>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- Import Button -->
219
+ <div class="control-section" style="margin-top:auto; padding-top:16px;">
220
+ <button class="btn btn-primary" id="svImportBtn" style="width:100%; justify-content:center;">
221
+ <i class="bi bi-folder-plus"></i> PDF Library
222
+ </button>
223
+ <button class="btn" id="svProjectsBtn" style="width:100%; justify-content:center; margin-top:8px; border-color:#444;">
224
+ <i class="bi bi-folder"></i> My Projects
225
+ </button>
226
+ </div>
227
+ </div>
228
+
229
+ <!-- Pages Panel -->
230
+ <div class="sidebar-content" id="svPanelPages" style="display:none; padding:16px;">
231
+ <div id="svPageList" class="sb-page-grid" style="grid-template-columns: 1fr; gap:10px;"></div>
232
+ </div>
233
+ </div>
234
+
235
+ <!-- Right Viewport (Canvas Area) -->
236
+ <div id="viewport" class="viewport" style="position:relative; background:#000; flex:1; display:flex; align-items:center; justify-content:center; overflow:hidden;">
237
+
238
+ <!-- Floating Sidebar Toggle -->
239
+ <button id="svSidebarToggle" class="btn btn-icon"
240
+ style="position:absolute; top:12px; left:12px; z-index:100; display:none; background:var(--bg-panel); border:1px solid var(--border);"
241
+ title="Show Sidebar">
242
+ <i class="bi bi-layout-sidebar"></i>
243
+ </button>
244
+
245
+ <!-- Canvas -->
246
+ <canvas id="canvas" oncontextmenu="return false;"></canvas>
247
+
248
+ <!-- Context Toolbar for selections -->
249
+ <div id="contextToolbar" class="context-toolbar">
250
+ <button class="ctx-btn" title="Delete" id="svDeleteBtn"><i class="bi bi-trash3"></i></button>
251
+ </div>
252
+
253
+ <!-- Navigation Island -->
254
+ <div class="nav-island">
255
+ <button class="btn btn-icon" id="svPrevPage" style="background:transparent; border:none; border-radius:4px;"><i class="bi bi-chevron-left"></i></button>
256
+
257
+ <div style="display:flex; align-items:center; gap:4px; font-family:monospace; font-size:0.8rem; font-weight:700;">
258
+ <input type="number" id="svPageInput" style="background:transparent; border:none; color:white; width:32px; text-align:right; padding:2px;" min="1" value="1">
259
+ <span id="svPageTotal" style="color:#555;">/ --</span>
260
+ </div>
261
+
262
+ <button class="btn btn-icon" id="svNextPage" style="background:transparent; border:none; border-radius:4px;"><i class="bi bi-chevron-right"></i></button>
263
+
264
+ <div style="width:1px; height:16px; background:#333;"></div>
265
+
266
+ <button id="svZoomReset" class="btn btn-sm" style="background:transparent; border:none; font-family:monospace; font-size:0.75rem; min-width:50px; color:#888;">100%</button>
267
+
268
+ <div style="width:1px; height:16px; background:#333;"></div>
269
+
270
+ <button class="btn btn-icon" id="svUndo" style="background:transparent; border:none; border-radius:4px;"><i class="bi bi-arrow-counterclockwise" style="font-size:0.8rem"></i></button>
271
+ </div>
272
+
273
+ <!-- Status Badge -->
274
+ <div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
275
+ <div class="badge" style="background:rgba(255,255,255,0.05); color:#888; font-size:0.6rem; padding:4px 8px;">LOCAL</div>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ `;
281
+ },
282
+
283
+ /**
284
+ * Initialize ColorRmApp for split view
285
+ */
286
+ async initApp() {
287
+ const container = document.getElementById('rightAppContainer');
288
+ if (!container) return;
289
+
290
+ // Create the split view app instance
291
+ this.app = new ColorRmApp({
292
+ isMain: false,
293
+ container: container,
294
+ collaborative: false, // No LiveSync
295
+ dbName: 'ColorRM_SplitView_V1' // Separate database
296
+ });
297
+
298
+ // Initialize with minimal UI (no registry sync, no LiveSync)
299
+ await this.app.init(SplitViewUI, null, null);
300
+
301
+ // Expose for debugging
302
+ window.SplitViewApp = this.app;
303
+
304
+ // Register with CommonPdfImport
305
+ CommonPdfImport.setSplitViewApp(this.app);
306
+
307
+ console.log('SplitView ColorRmApp initialized');
308
+ },
309
+
310
+ /**
311
+ * Bind all event handlers
312
+ */
313
+ bindEvents() {
314
+ // Sidebar toggle
315
+ const hideSidebar = document.getElementById('svHideSidebar');
316
+ const showSidebar = document.getElementById('svSidebarToggle');
317
+ const svSidebar = document.getElementById('svSidebar');
318
+
319
+ if (hideSidebar) {
320
+ hideSidebar.onclick = () => {
321
+ svSidebar.style.display = 'none';
322
+ showSidebar.style.display = 'flex';
323
+ };
324
+ }
325
+ if (showSidebar) {
326
+ showSidebar.onclick = () => {
327
+ svSidebar.style.display = 'flex';
328
+ showSidebar.style.display = 'none';
329
+ };
330
+ }
331
+
332
+ // Sidebar tabs
333
+ document.getElementById('svTabTools')?.addEventListener('click', () => this.switchTab('tools'));
334
+ document.getElementById('svTabPages')?.addEventListener('click', () => this.switchTab('pages'));
335
+
336
+ // Tool buttons
337
+ document.querySelectorAll('#svSidebar [data-tool]').forEach(btn => {
338
+ btn.addEventListener('click', () => {
339
+ const tool = btn.dataset.tool;
340
+ this.setTool(tool);
341
+ });
342
+ });
343
+
344
+ // Color dots
345
+ document.querySelectorAll('#svPenColors .color-dot').forEach(dot => {
346
+ dot.addEventListener('click', () => {
347
+ if (this.app) {
348
+ this.app.state.penColor = dot.dataset.color;
349
+ this.app.state.shapeBorder = dot.dataset.color;
350
+ }
351
+ });
352
+ });
353
+
354
+ // Brush size slider
355
+ const sizeSlider = document.getElementById('svBrushSize');
356
+ if (sizeSlider) {
357
+ sizeSlider.addEventListener('input', (e) => {
358
+ if (this.app) {
359
+ const val = parseInt(e.target.value);
360
+ this.app.state.penSize = val;
361
+ this.app.state.eraserSize = val;
362
+ this.app.state.shapeWidth = val;
363
+ }
364
+ });
365
+ }
366
+
367
+ // Shape buttons
368
+ document.querySelectorAll('#svShapeOptions [data-shape]').forEach(btn => {
369
+ btn.addEventListener('click', () => {
370
+ if (this.app) {
371
+ document.querySelectorAll('#svShapeOptions [data-shape]').forEach(b => b.classList.remove('active'));
372
+ btn.classList.add('active');
373
+ this.app.state.shapeType = btn.dataset.shape;
374
+ }
375
+ });
376
+ });
377
+
378
+ // Stroke eraser toggle
379
+ const strokeEraser = document.getElementById('svStrokeEraser');
380
+ if (strokeEraser) {
381
+ strokeEraser.addEventListener('change', (e) => {
382
+ if (this.app) {
383
+ this.app.state.eraserType = e.target.checked ? 'stroke' : 'pixel';
384
+ }
385
+ });
386
+ }
387
+
388
+ // Preview toggle
389
+ const previewToggle = document.getElementById('svPreviewToggle');
390
+ if (previewToggle) {
391
+ previewToggle.addEventListener('change', (e) => {
392
+ if (this.app) {
393
+ this.app.state.previewOn = e.target.checked;
394
+ this.app.render();
395
+ }
396
+ });
397
+ }
398
+
399
+ // Navigation
400
+ document.getElementById('svPrevPage')?.addEventListener('click', () => this.app?.loadPage(this.app.state.idx - 1));
401
+ document.getElementById('svNextPage')?.addEventListener('click', () => this.app?.loadPage(this.app.state.idx + 1));
402
+ document.getElementById('svUndo')?.addEventListener('click', () => this.app?.undo());
403
+ document.getElementById('svZoomReset')?.addEventListener('click', () => {
404
+ if (this.app) {
405
+ this.app.state.zoom = 1;
406
+ this.app.state.pan = { x: 0, y: 0 };
407
+ this.app.render();
408
+ this.updateZoomDisplay();
409
+ }
410
+ });
411
+
412
+ // Page input
413
+ const pageInput = document.getElementById('svPageInput');
414
+ if (pageInput) {
415
+ pageInput.addEventListener('change', (e) => {
416
+ const page = parseInt(e.target.value) - 1;
417
+ this.app?.loadPage(page);
418
+ });
419
+ }
420
+
421
+ // Import button - show PDF library
422
+ document.getElementById('svImportBtn')?.addEventListener('click', () => {
423
+ CommonPdfImport.showLibrary('split');
424
+ });
425
+
426
+ // Projects button
427
+ document.getElementById('svProjectsBtn')?.addEventListener('click', () => this.showProjectManager());
428
+
429
+ // Delete button
430
+ document.getElementById('svDeleteBtn')?.addEventListener('click', () => this.app?.deleteSelected());
431
+
432
+ // Hook into app's render to update page info
433
+ if (this.app) {
434
+ const originalLoadPage = this.app.loadPage.bind(this.app);
435
+ this.app.loadPage = async (i, broadcast) => {
436
+ await originalLoadPage(i, broadcast);
437
+ this.updatePageInfo();
438
+ this.updateZoomDisplay();
439
+ };
440
+
441
+ // Also update on render for zoom changes
442
+ const originalRender = this.app.render.bind(this.app);
443
+ this.app.render = () => {
444
+ originalRender();
445
+ this.updateZoomDisplay();
446
+ };
447
+ }
448
+ },
449
+
450
+ /**
451
+ * Switch sidebar tab
452
+ */
453
+ switchTab(tab) {
454
+ document.getElementById('svTabTools')?.classList.toggle('active', tab === 'tools');
455
+ document.getElementById('svTabPages')?.classList.toggle('active', tab === 'pages');
456
+ document.getElementById('svPanelTools').style.display = tab === 'tools' ? 'flex' : 'none';
457
+ document.getElementById('svPanelPages').style.display = tab === 'pages' ? 'block' : 'none';
458
+
459
+ if (tab === 'pages') {
460
+ this.renderPageThumbnails();
461
+ }
462
+ },
463
+
464
+ /**
465
+ * Set active tool
466
+ */
467
+ setTool(tool) {
468
+ if (!this.app) return;
469
+
470
+ this.app.setTool(tool);
471
+
472
+ // Update button states
473
+ document.querySelectorAll('#svSidebar [data-tool]').forEach(btn => {
474
+ btn.classList.toggle('active', btn.dataset.tool === tool);
475
+ });
476
+
477
+ // Show/hide tool-specific options
478
+ document.getElementById('svShapeOptions').style.display = tool === 'shape' ? 'block' : 'none';
479
+ document.getElementById('svEraserOptions').style.display = tool === 'eraser' ? 'block' : 'none';
480
+ },
481
+
482
+ /**
483
+ * Update page info display
484
+ */
485
+ updatePageInfo() {
486
+ if (!this.app) return;
487
+ const pageInput = document.getElementById('svPageInput');
488
+ const pageTotal = document.getElementById('svPageTotal');
489
+
490
+ if (pageInput) pageInput.value = this.app.state.idx + 1;
491
+ if (pageTotal) pageTotal.textContent = `/ ${this.app.state.images.length}`;
492
+ },
493
+
494
+ /**
495
+ * Update zoom display
496
+ */
497
+ updateZoomDisplay() {
498
+ if (!this.app) return;
499
+ const zoomBtn = document.getElementById('svZoomReset');
500
+ if (zoomBtn) {
501
+ zoomBtn.textContent = Math.round(this.app.state.zoom * 100) + '%';
502
+ }
503
+ },
504
+
505
+ /**
506
+ * Render page thumbnails in sidebar
507
+ */
508
+ renderPageThumbnails() {
509
+ if (!this.app) return;
510
+ const container = document.getElementById('svPageList');
511
+ if (!container) return;
512
+
513
+ container.innerHTML = '';
514
+
515
+ this.app.state.images.forEach((img, idx) => {
516
+ const item = document.createElement('div');
517
+ item.className = 'sb-page-item';
518
+ item.style.cssText = 'aspect-ratio: auto; cursor: pointer; position: relative;';
519
+ if (idx === this.app.state.idx) item.classList.add('active');
520
+
521
+ const imgEl = document.createElement('img');
522
+ imgEl.style.cssText = 'width:100%; display:block; border-radius:4px; border:1px solid #333;';
523
+
524
+ // Create thumbnail from blob
525
+ if (img.blob) {
526
+ const url = URL.createObjectURL(img.blob);
527
+ imgEl.src = url;
528
+ imgEl.onload = () => URL.revokeObjectURL(url);
529
+ }
530
+
531
+ const num = document.createElement('div');
532
+ num.className = 'sb-page-num';
533
+ num.textContent = idx + 1;
534
+
535
+ item.appendChild(imgEl);
536
+ item.appendChild(num);
537
+ item.onclick = () => {
538
+ this.app.loadPage(idx);
539
+ this.renderPageThumbnails(); // Refresh to update active state
540
+ };
541
+
542
+ container.appendChild(item);
543
+ });
544
+ },
545
+
546
+ /**
547
+ * Disable split view
548
+ */
549
+ disable() {
550
+ const viewport = document.querySelector('.viewport');
551
+ const workspace = document.querySelector('.workspace');
552
+ const sidebar = document.querySelector('.sidebar');
553
+ const container = document.getElementById('splitViewContainer');
554
+ const leftPanel = document.getElementById('leftPanel');
555
+
556
+ if (!viewport || !workspace) return;
557
+
558
+ console.log('Disabling split view...');
559
+
560
+ if (container) container.style.display = 'none';
561
+
562
+ if (leftPanel && leftPanel.contains(viewport)) {
563
+ leftPanel.removeChild(viewport);
564
+ }
565
+
566
+ if (viewport.parentNode === workspace) workspace.removeChild(viewport);
567
+ if (sidebar && sidebar.parentNode === workspace) workspace.removeChild(sidebar);
568
+ if (container && container.parentNode === workspace) workspace.removeChild(container);
569
+
570
+ if (sidebar) {
571
+ workspace.appendChild(sidebar);
572
+ sidebar.style.display = 'flex';
573
+ }
574
+ workspace.appendChild(viewport);
575
+ viewport.style.display = 'flex';
576
+
577
+ if (container) workspace.appendChild(container);
578
+
579
+ console.log('Split view disabled');
580
+ },
581
+
582
+ /**
583
+ * Handle PDF import for split view (called by CommonPdfImport)
584
+ * This is now a simpler wrapper since CommonPdfImport handles most logic
585
+ */
586
+ async handlePdfImport(file) {
587
+ if (!this.app || !file) return;
588
+
589
+ try {
590
+ console.log('SplitView: Importing PDF:', file.name);
591
+
592
+ const projectName = file.name.replace(/\.pdf$/i, '');
593
+
594
+ // Let CommonPdfImport handle the actual import
595
+ await CommonPdfImport.importIntoApp(this.app, file, projectName);
596
+
597
+ // Update page info
598
+ this.updatePageInfo();
599
+ this.renderPageThumbnails();
600
+
601
+ // Save to project list
602
+ await this.saveProjectMeta(this.app.state.sessionId, projectName);
603
+
604
+ console.log('SplitView: PDF imported successfully');
605
+
606
+ } catch (error) {
607
+ console.error('SplitView: Error importing PDF:', error);
608
+ alert('Error importing PDF: ' + error.message);
609
+ }
610
+ },
611
+
612
+ /**
613
+ * Save project metadata
614
+ */
615
+ async saveProjectMeta(id, name) {
616
+ if (!this.rightDB) return;
617
+
618
+ const tx = this.rightDB.transaction([this.STORE_NAME], 'readwrite');
619
+ const store = tx.objectStore(this.STORE_NAME);
620
+
621
+ await new Promise((resolve, reject) => {
622
+ const req = store.put({
623
+ id: id,
624
+ name: name,
625
+ timestamp: Date.now()
626
+ });
627
+ req.onsuccess = () => resolve();
628
+ req.onerror = () => reject(req.error);
629
+ });
630
+ },
631
+
632
+ /**
633
+ * Get all projects
634
+ */
635
+ async getAllProjects() {
636
+ if (!this.rightDB) return [];
637
+
638
+ const tx = this.rightDB.transaction([this.STORE_NAME], 'readonly');
639
+ const store = tx.objectStore(this.STORE_NAME);
640
+
641
+ return new Promise((resolve, reject) => {
642
+ const req = store.getAll();
643
+ req.onsuccess = () => resolve(req.result || []);
644
+ req.onerror = () => reject(req.error);
645
+ });
646
+ },
647
+
648
+ /**
649
+ * Load last project
650
+ */
651
+ async loadLastProject() {
652
+ try {
653
+ const projects = await this.getAllProjects();
654
+ if (projects.length > 0) {
655
+ projects.sort((a, b) => b.timestamp - a.timestamp);
656
+ const lastProject = projects[0];
657
+
658
+ // Try to open the session
659
+ await this.app.openSession(lastProject.id);
660
+ this.updatePageInfo();
661
+ this.renderPageThumbnails();
662
+ }
663
+ } catch (error) {
664
+ console.log('SplitView: No previous project to load');
665
+ }
666
+ },
667
+
668
+ /**
669
+ * Show project manager
670
+ */
671
+ async showProjectManager() {
672
+ let modal = document.getElementById('svProjectModal');
673
+ if (!modal) {
674
+ modal = document.createElement('div');
675
+ modal.id = 'svProjectModal';
676
+ modal.className = 'overlay';
677
+ modal.style.cssText = 'display:none; position:fixed; inset:0; background:rgba(0,0,0,0.8); z-index:9999; align-items:center; justify-content:center;';
678
+ modal.innerHTML = `
679
+ <div class="card" style="width:90%; max-width:500px; max-height:80vh; display:flex; flex-direction:column;">
680
+ <div style="display:flex; justify-content:space-between; align-items:center; padding:20px; border-bottom:1px solid var(--border);">
681
+ <h3 style="margin:0;">Local Projects</h3>
682
+ <button id="svCloseProjectModal" style="background:none; border:none; color:#888; cursor:pointer; font-size:1.2rem;">
683
+ <i class="bi bi-x-lg"></i>
684
+ </button>
685
+ </div>
686
+ <div id="svProjectList" style="flex:1; overflow-y:auto; padding:20px;"></div>
687
+ <div style="padding:20px; border-top:1px solid var(--border);">
688
+ <button class="btn btn-primary" id="svImportNewBtn" style="width:100%;">
689
+ <i class="bi bi-folder-plus"></i> Open PDF Library
690
+ </button>
691
+ </div>
692
+ </div>
693
+ `;
694
+ document.body.appendChild(modal);
695
+
696
+ document.getElementById('svCloseProjectModal').onclick = () => modal.style.display = 'none';
697
+ document.getElementById('svImportNewBtn').onclick = () => {
698
+ modal.style.display = 'none';
699
+ CommonPdfImport.showLibrary('split');
700
+ };
701
+ }
702
+
703
+ // Populate list
704
+ await this.refreshProjectList();
705
+ modal.style.display = 'flex';
706
+ },
707
+
708
+ /**
709
+ * Refresh project list
710
+ */
711
+ async refreshProjectList() {
712
+ const container = document.getElementById('svProjectList');
713
+ if (!container) return;
714
+
715
+ const projects = await this.getAllProjects();
716
+
717
+ if (projects.length === 0) {
718
+ container.innerHTML = `
719
+ <div style="text-align:center; padding:40px; color:#666;">
720
+ <i class="bi bi-folder-x" style="font-size:3rem; margin-bottom:10px;"></i>
721
+ <p>No projects yet</p>
722
+ </div>
723
+ `;
724
+ return;
725
+ }
726
+
727
+ projects.sort((a, b) => b.timestamp - a.timestamp);
728
+
729
+ container.innerHTML = '';
730
+ projects.forEach(project => {
731
+ const isActive = this.app && this.app.state.sessionId === project.id;
732
+ const date = new Date(project.timestamp).toLocaleDateString();
733
+
734
+ const item = document.createElement('div');
735
+ item.className = 'bm-item';
736
+ item.style.cssText = `display:flex; justify-content:space-between; align-items:center; padding:15px; margin-bottom:8px; ${isActive ? 'border-color:#fff;' : ''}`;
737
+
738
+ item.innerHTML = `
739
+ <div style="flex:1; cursor:pointer;" class="proj-name">
740
+ <div style="font-weight:600; margin-bottom:5px;">${project.name}</div>
741
+ <div style="font-size:0.8rem; color:#888;">${date}</div>
742
+ </div>
743
+ <div style="display:flex; gap:8px; align-items:center;">
744
+ ${isActive ? '<span style="color:#0f0; font-size:0.8rem;">● Active</span>' : ''}
745
+ <button class="bm-del proj-del" title="Delete"><i class="bi bi-trash3"></i></button>
746
+ </div>
747
+ `;
748
+
749
+ item.querySelector('.proj-name').onclick = async () => {
750
+ await this.app.openSession(project.id);
751
+ this.updatePageInfo();
752
+ this.renderPageThumbnails();
753
+ document.getElementById('svProjectModal').style.display = 'none';
754
+ };
755
+
756
+ item.querySelector('.proj-del').onclick = async (e) => {
757
+ e.stopPropagation();
758
+ await this.deleteProject(project.id);
759
+ };
760
+
761
+ container.appendChild(item);
762
+ });
763
+ },
764
+
765
+ /**
766
+ * Delete project
767
+ */
768
+ async deleteProject(projectId) {
769
+ if (!confirm('Delete this project?')) return;
770
+
771
+ // Delete from project list DB
772
+ if (this.rightDB) {
773
+ const tx = this.rightDB.transaction([this.STORE_NAME], 'readwrite');
774
+ await new Promise(resolve => {
775
+ const req = tx.objectStore(this.STORE_NAME).delete(projectId);
776
+ req.onsuccess = resolve;
777
+ });
778
+ }
779
+
780
+ // Delete pages and session from app's DB
781
+ if (this.app && this.app.db) {
782
+ // Delete pages
783
+ const tx = this.app.db.transaction(['pages', 'sessions'], 'readwrite');
784
+ const pagesStore = tx.objectStore('pages');
785
+ const sessionsStore = tx.objectStore('sessions');
786
+
787
+ const pages = await new Promise(resolve => {
788
+ const req = pagesStore.index('sessionId').getAll(projectId);
789
+ req.onsuccess = () => resolve(req.result);
790
+ });
791
+
792
+ for (const page of pages) {
793
+ pagesStore.delete(page.id);
794
+ }
795
+ sessionsStore.delete(projectId);
796
+ }
797
+
798
+ // If this was the active project, clear the view
799
+ if (this.app && this.app.state.sessionId === projectId) {
800
+ this.app.state.images = [];
801
+ this.app.state.sessionId = null;
802
+ this.app.render();
803
+ this.updatePageInfo();
804
+ }
805
+
806
+ await this.refreshProjectList();
807
+ },
808
+
809
+ /**
810
+ * Initialize
811
+ */
812
+ async init() {
813
+ try {
814
+ if (typeof pdfjsLib !== 'undefined') {
815
+ pdfjsLib.GlobalWorkerOptions.workerSrc =
816
+ 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
817
+ }
818
+
819
+ await this.initDB();
820
+ console.log('Split View (Full) initialized');
821
+
822
+ // Expose for CommonPdfImport
823
+ window.SplitView = this;
824
+ } catch (error) {
825
+ console.error('SplitView init error:', error);
826
+ }
827
+ }
828
+ };
public/scripts/UI.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const UI = {
2
+ showDashboard: () => {
3
+ const db = document.getElementById('dashboardModal');
4
+ if (db) db.style.display='flex';
5
+ if (window.App && window.App.loadSessionList) window.App.loadSessionList();
6
+ },
7
+ hideDashboard: () => {
8
+ const db = document.getElementById('dashboardModal');
9
+ if (db) db.style.display='none';
10
+ },
11
+ showExportModal: () => {
12
+ const em = document.getElementById('exportModal');
13
+ if (em) em.style.display='flex';
14
+ if (window.App && window.App.renderDlGrid) window.App.renderDlGrid();
15
+ },
16
+ toggleLoader: (show, text) => {
17
+ const loader = document.getElementById('loader');
18
+ if (loader) loader.style.display = show ? 'grid' : 'none';
19
+ if(text) {
20
+ const lt = document.getElementById('loadText');
21
+ if (lt) lt.innerText = text;
22
+ }
23
+ if(show) {
24
+ const pb = document.getElementById('progBar');
25
+ if (pb) pb.style.width='0%';
26
+ const pd = document.getElementById('progDetail');
27
+ if (pd) pd.innerText='';
28
+ }
29
+ },
30
+ updateProgress: (pct, msg) => {
31
+ const pb = document.getElementById('progBar');
32
+ if (pb) pb.style.width = pct + '%';
33
+ const pd = document.getElementById('progDetail');
34
+ if (msg && pd) pd.innerText = msg;
35
+ },
36
+ showInput: (title, placeholder, callback) => {
37
+ const m = document.getElementById('inputModal');
38
+ const i = document.getElementById('inputField');
39
+ const b = document.getElementById('inputConfirmBtn');
40
+ const t = document.getElementById('inputTitle');
41
+
42
+ if (!m || !i || !b) return;
43
+
44
+ if (t) t.innerText = title;
45
+ i.value = '';
46
+ i.placeholder = placeholder;
47
+ m.style.display = 'flex';
48
+ i.focus();
49
+
50
+ const confirm = () => {
51
+ const val = i.value.trim();
52
+ if(val) {
53
+ m.style.display = 'none';
54
+ callback(val);
55
+ }
56
+ };
57
+ b.onclick = confirm;
58
+ i.onkeydown = (e) => { if(e.key==='Enter') confirm(); };
59
+ },
60
+ showToast: (msg) => {
61
+ const t = document.getElementById('toast');
62
+ if (!t) return;
63
+ t.innerText = msg;
64
+ t.classList.add('show');
65
+ setTimeout(() => t.classList.remove('show'), 2000);
66
+ },
67
+ setSyncStatus: (status) => {
68
+ const el = document.getElementById('syncStatus');
69
+ if (!el) return;
70
+
71
+ if (status === 'saved') {
72
+ el.innerHTML = '<span style="color:#fff">●</span> Synced';
73
+ setTimeout(() => { if(el.innerText.includes('Synced')) el.innerHTML = ''; }, 3000);
74
+ } else if (status === 'syncing') {
75
+ el.innerHTML = '<span style="color:#888">○</span> Saving';
76
+ } else if (status === 'offline') {
77
+ el.innerHTML = '<span style="color:#ff4d4d">●</span> Offline';
78
+ } else if (status === 'new') {
79
+ el.innerHTML = '<span style="color:#fff">●</span> New Project';
80
+ setTimeout(() => el.innerHTML = '', 5000);
81
+ }
82
+ }
83
+ };
public/scripts/main.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ColorRmApp } from './ColorRmApp.js';
2
+ import { UI } from './UI.js';
3
+ import { Registry } from './Registry.js';
4
+ import { LiveSyncClient } from './LiveSync.js';
5
+ import { CommonPdfImport } from './CommonPdfImport.js';
6
+ import { SplitView } from './SplitView.js';
7
+ import { PDFLibrary } from './PDFLibrary.js';
8
+
9
+ // Expose globals for compatibility and debugging
10
+ window.UI = UI;
11
+ window.Registry = Registry;
12
+ window.CommonPdfImport = CommonPdfImport;
13
+ window.PDFLibrary = PDFLibrary;
14
+
15
+ // Initialize the main application instance
16
+ const app = new ColorRmApp({ isMain: true });
17
+
18
+ // Register app with Registry for proper instance binding
19
+ Registry.setApp(app);
20
+
21
+ // Add SplitView integration
22
+ app.toggleSplitView = function() {
23
+ SplitView.toggle();
24
+ const btn = document.getElementById('splitViewToggle');
25
+ if (btn) {
26
+ if (SplitView.isEnabled) {
27
+ btn.classList.add('active');
28
+ } else {
29
+ btn.classList.remove('active');
30
+ }
31
+ }
32
+ };
33
+
34
+ window.App = app;
35
+
36
+ // Initialize when DOM is ready
37
+ document.addEventListener('DOMContentLoaded', async () => {
38
+ console.log("Initializing ColorRM ESM...");
39
+
40
+ // Initialize common modules
41
+ CommonPdfImport.init(app);
42
+ await SplitView.init();
43
+
44
+ // Initialize App with dependencies
45
+ // Note: LiveSyncClient class is passed, App will instantiate it
46
+ await app.init(UI, Registry, LiveSyncClient);
47
+
48
+ // Expose LiveSync for debugging
49
+ window.LiveSync = app.liveSync;
50
+
51
+ console.log("ColorRM Initialized.");
52
+ });
public/scripts/spen_engine.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ export function initializeSPen(canvasElement) {
3
+ let isPen = false
4
+ let lastEvent = null
5
+
6
+ // S-Pen uses button ID 5
7
+ const SPEN_BUTTON_ID = 5
8
+
9
+ const handlePointerDown = (e) => {
10
+ // CRITICAL: Only dispatch if the event target is the canvas or inside it
11
+ if (e.target !== canvasElement && !canvasElement.contains(e.target)) {
12
+ return
13
+ }
14
+
15
+ if (e.button === SPEN_BUTTON_ID) {
16
+ isPen = true
17
+ // Dispatch a synthetic 'touchstart' or 'pointerdown' event
18
+ const newEvent = new PointerEvent('pointerdown', {
19
+ ...e,
20
+ button: 0, // Pretend it's a left-click/touch
21
+ isPrimary: true,
22
+ bubbles: true,
23
+ cancelable: true
24
+ })
25
+ lastEvent = newEvent
26
+ // Dispatch specifically to the canvas
27
+ canvasElement.dispatchEvent(newEvent)
28
+ e.preventDefault()
29
+ }
30
+ }
31
+
32
+ const handlePointerMove = (e) => {
33
+ // If we are dragging, we might be outside the canvas, but we still want to track it
34
+ // IF we started inside. But for S-Pen hover/move, we generally want it scoped.
35
+ // However, standard pointer capture should handle drags.
36
+
37
+ // For simple robust behavior: if isPen is true, we keep dispatching.
38
+ // If isPen is false, we check target.
39
+
40
+ if (!isPen && (e.target !== canvasElement && !canvasElement.contains(e.target))) {
41
+ return
42
+ }
43
+
44
+ if (isPen) {
45
+ const newEvent = new PointerEvent('pointermove', {
46
+ ...e,
47
+ button: 0,
48
+ isPrimary: true,
49
+ bubbles: true,
50
+ cancelable: true
51
+ })
52
+ lastEvent = newEvent
53
+ canvasElement.dispatchEvent(newEvent)
54
+ e.preventDefault()
55
+ }
56
+ }
57
+
58
+ const handlePointerUp = (e) => {
59
+ if (isPen) {
60
+ isPen = false
61
+ const newEvent = new PointerEvent('pointerup', {
62
+ ...e,
63
+ button: 0,
64
+ isPrimary: true,
65
+ bubbles: true,
66
+ cancelable: true
67
+ })
68
+ canvasElement.dispatchEvent(newEvent)
69
+ e.preventDefault()
70
+ }
71
+ }
72
+
73
+ // We still listen on window to catch events that bubble up or happen globally (like up outside)
74
+ // but we added target checks in Down and Move to prevent cross-talk.
75
+ window.addEventListener('pointerdown', handlePointerDown)
76
+ window.addEventListener('pointermove', handlePointerMove)
77
+ window.addEventListener('pointerup', handlePointerUp)
78
+
79
+ return () => {
80
+ window.removeEventListener('pointerdown', handlePointerDown)
81
+ window.removeEventListener('pointermove', handlePointerMove)
82
+ window.removeEventListener('pointerup', handlePointerUp)
83
+ }
84
+ }
worker/worker.ts CHANGED
@@ -140,7 +140,7 @@ const router = AutoRouter<IRequest, [env: Env, ctx: ExecutionContext]>({
140
  if (!env.LIVEBLOCKS_SECRET_KEY) {
141
  return new Response('Missing LIVEBLOCKS_SECRET_KEY', { status: 500 })
142
  }
143
-
144
  const liveblocks = new Liveblocks({
145
  secret: env.LIVEBLOCKS_SECRET_KEY,
146
  })
 
140
  if (!env.LIVEBLOCKS_SECRET_KEY) {
141
  return new Response('Missing LIVEBLOCKS_SECRET_KEY', { status: 500 })
142
  }
143
+
144
  const liveblocks = new Liveblocks({
145
  secret: env.LIVEBLOCKS_SECRET_KEY,
146
  })
wrangler.toml CHANGED
@@ -23,11 +23,11 @@ bindings = [
23
  # Durable objects require migrations to create/modify/delete them
24
  [[migrations]]
25
  tag = "v1"
26
- new_classes = ["TldrawDurableObject"]
27
 
28
  [[migrations]]
29
  tag = "v2"
30
- new_classes = ["ColorRmDurableObject"]
31
 
32
  # We store rooms and asset uploads in an R2 bucket
33
  [[r2_buckets]]
@@ -38,8 +38,8 @@ preview_bucket_name = 'tldraw-shubh'
38
  # KV Namespace for User Authentication
39
  [[kv_namespaces]]
40
  binding = "TLDRAW_USERS_KV"
41
- id = "2d0c648825224097839356393437651a" # Placeholder - replace with actual ID from `npx wrangler kv:namespace create TLDRAW_USERS_KV`
42
- preview_id = "2d0c648825224097839356393437651a"
43
 
44
  [env.preview]
45
  durable_objects.bindings = [
 
23
  # Durable objects require migrations to create/modify/delete them
24
  [[migrations]]
25
  tag = "v1"
26
+ new_sqlite_classes = ["TldrawDurableObject"]
27
 
28
  [[migrations]]
29
  tag = "v2"
30
+ new_sqlite_classes = ["ColorRmDurableObject"]
31
 
32
  # We store rooms and asset uploads in an R2 bucket
33
  [[r2_buckets]]
 
38
  # KV Namespace for User Authentication
39
  [[kv_namespaces]]
40
  binding = "TLDRAW_USERS_KV"
41
+ id = "d0faab9b5c524d23abeec7f62190bacd" # Placeholder - replace with actual ID from `npx wrangler kv:namespace create TLDRAW_USERS_KV`
42
+ preview_id = "d0faab9b5c524d23abeec7f62190bacd"
43
 
44
  [env.preview]
45
  durable_objects.bindings = [