This commit is contained in:
2025-11-02 22:25:00 +01:00
parent 74aacbc1eb
commit 626b6c16e0
3 changed files with 311 additions and 0 deletions
+143
View File
@@ -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;
});
};
+95
View File
@@ -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 };
+73
View File
@@ -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;