diff --git a/frontend/src/desk/db.js b/frontend/src/desk/db.js new file mode 100644 index 0000000..b49f91e --- /dev/null +++ b/frontend/src/desk/db.js @@ -0,0 +1,143 @@ +const DB_NAME = 'papercrate_desk'; +const DB_VERSION = 1; +const LAYOUT_STORE = 'layouts'; + +const currentDbPromise = { value: null }; + +const openDatabase = () => { + if (currentDbPromise.value) { + return currentDbPromise.value; + } + + currentDbPromise.value = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB not available')); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LAYOUT_STORE)) { + const store = db.createObjectStore(LAYOUT_STORE, { + keyPath: ['tenantId', 'viewId', 'documentId'], + }); + store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false }); + store.createIndex('tenantIdx', 'tenantId', { unique: false }); + store.createIndex('updatedIdx', 'updatedAt', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + }); + + return currentDbPromise.value; +}; + +export const fetchLayoutRecords = async ({ tenantId, viewId }) => { + if (!tenantId || !viewId) { + return []; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, 'readonly'); + const store = transaction.objectStore(LAYOUT_STORE); + const index = store.index('tenantViewIdx'); + const request = index.getAll([tenantId, viewId]); + + return await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error || new Error('Failed to fetch layout records')); + }); + } catch (error) { + console.warn('[desk] Failed to read layout records', error); + return []; + } +}; + +export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { + if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) { + return; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, 'readwrite'); + const store = transaction.objectStore(LAYOUT_STORE); + const timestamp = Date.now(); + + entries.forEach((entry) => { + if (!entry || !entry.documentId) { + return; + } + store.put({ + tenantId, + viewId, + documentId: entry.documentId, + centerX: Number(entry.centerX) || 0, + centerY: Number(entry.centerY) || 0, + rotation: Number(entry.rotation) || 0, + zIndex: Number(entry.zIndex) || 0, + updatedAt: entry.updatedAt || timestamp, + }); + }); + + await new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('Failed to persist layout records')); + transaction.onabort = () => reject(transaction.error || new Error('Layout transaction aborted')); + }); + } catch (error) { + console.warn('[desk] Failed to upsert layout records', error); + } +}; + +export const deleteTenantLayouts = async (tenantId) => { + if (!tenantId) { + return; + } + try { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, 'readwrite'); + const store = transaction.objectStore(LAYOUT_STORE); + const index = store.index('tenantIdx'); + const request = index.openCursor(tenantId); + + await new Promise((resolve, reject) => { + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolve(); + } + }; + request.onerror = () => reject(request.error || new Error('Failed to delete tenant layouts')); + }); + } catch (error) { + console.warn('[desk] Failed to clean tenant layouts', error); + } +}; + +export const closeDeskDatabase = () => { + if (!currentDbPromise.value) { + return; + } + currentDbPromise.value = currentDbPromise.value.then((db) => { + try { + db.close(); + } catch (error) { + console.warn('[desk] Failed to close IndexedDB', error); + } + return null; + }); +}; diff --git a/frontend/src/documents/tagTransfer.js b/frontend/src/documents/tagTransfer.js new file mode 100644 index 0000000..5679c89 --- /dev/null +++ b/frontend/src/documents/tagTransfer.js @@ -0,0 +1,95 @@ +const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; +const TAG_TEXT_MIME_TYPE = 'text/plain'; + +const serializePayload = (payload) => { + try { + return JSON.stringify(payload); + } catch (error) { + console.warn('[tagTransfer] Failed to serialize payload', error); + return null; + } +}; + +export const createTagTransferPayload = (tag, sourceDocId = null) => { + if (!tag || !tag.id) { + return null; + } + + return { + id: tag.id, + label: tag.label || '', + sourceDocId: sourceDocId ?? null, + }; +}; + +export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => { + if (!dataTransfer) { + return; + } + + const payload = createTagTransferPayload(tag, sourceDocId); + if (!payload) { + return; + } + + const serialized = serializePayload(payload); + if (!serialized) { + return; + } + + try { + dataTransfer.setData(TAG_MIME_TYPES[0], serialized); + dataTransfer.setData(TAG_MIME_TYPES[1], serialized); + if (payload.label) { + dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label); + } + } catch (error) { + console.warn('[tagTransfer] Failed to write drag data', error); + } +}; + +export const readTagTransferData = (dataTransfer) => { + if (!dataTransfer) { + return null; + } + + for (let index = 0; index < TAG_MIME_TYPES.length; index += 1) { + const type = TAG_MIME_TYPES[index]; + try { + const raw = dataTransfer.getData(type); + if (raw) { + return raw; + } + } catch (error) { + console.warn('[tagTransfer] Failed to read drag data for type', type, error); + } + } + return null; +}; + +export const parseTagTransferPayload = (input) => { + const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input; + const raw = readTagTransferData(dataTransfer); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw); + } catch (error) { + console.warn('[tagTransfer] Failed to parse drag payload', error); + } + + return null; +}; + +export const isTagTransferEvent = (event) => { + const types = event?.dataTransfer?.types; + if (!types) { + return false; + } + const typeList = Array.isArray(types) ? types : Array.from(types); + return TAG_MIME_TYPES.some((type) => typeList.includes(type)); +}; + +export { TAG_MIME_TYPES }; diff --git a/frontend/src/ui/usePointerTap.js b/frontend/src/ui/usePointerTap.js new file mode 100644 index 0000000..f37b5d6 --- /dev/null +++ b/frontend/src/ui/usePointerTap.js @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef } from 'react'; + +const defaultFilter = (event) => { + if (!event) { + return false; + } + const { type, button, pointerType, isPrimary } = event; + const isPointerUp = type === 'pointerup'; + const buttonValid = + button == null || button === 0 || (isPointerUp && (button === -1 || button === 0)); + if (!buttonValid) { + return false; + } + if (pointerType === 'touch' && isPrimary === false) { + return false; + } + return true; +}; + +const usePointerTap = ({ + onSingle, + onDouble, + delay = 240, + filter = defaultFilter, +} = {}) => { + const timerRef = useRef(null); + + useEffect(() => () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + return useCallback( + (event, metadata = undefined) => { + if (!filter(event)) { + return; + } + + if (typeof event.persist === 'function') { + event.persist(); + } + + const context = { + clientX: event.clientX, + clientY: event.clientY, + pointerType: event.pointerType, + event, + data: metadata, + }; + + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + if (typeof onDouble === 'function') { + onDouble(context); + } + return; + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + if (typeof onSingle === 'function') { + onSingle(context); + } + }, delay); + }, + [delay, filter, onDouble, onSingle], + ); +}; + +export default usePointerTap;