Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .claude/settings.local.json +7 -0
- capacitor.config.ts +2 -0
- client/components/BackupControl.tsx +95 -1
- client/pages/RoomPage.tsx +5 -1
- public/color_rm.html +0 -0
- public/scripts/ColorRmApp.js +0 -0
- public/scripts/CommonPdfImport.js +120 -0
- public/scripts/LiveSync.js +325 -0
- public/scripts/PDFLibrary.js +340 -0
- public/scripts/Registry.js +178 -0
- public/scripts/SplitView.js +828 -0
- public/scripts/UI.js +83 -0
- public/scripts/main.js +52 -0
- public/scripts/spen_engine.js +84 -0
- worker/worker.ts +1 -1
- wrangler.toml +4 -4
.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)} • ${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 |
-
|
| 27 |
|
| 28 |
[[migrations]]
|
| 29 |
tag = "v2"
|
| 30 |
-
|
| 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 = "
|
| 42 |
-
preview_id = "
|
| 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 = [
|