Upload files to IndexedDB

This commit is contained in:
Magnus Åhall 2026-06-01 14:27:41 +02:00
parent 5bd5ef1f02
commit 8b421ea59e
15 changed files with 539 additions and 99 deletions

View file

@ -29,6 +29,14 @@ export class App {
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
})
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
const classList = document.querySelector('#main-page').classList
classList.forEach(e =>
classList.remove(e)
)
classList.add(page)
})
window.addEventListener('keydown', event => this.keyHandler(event))
window.addEventListener('popstate', event => this.popState(event))
document.getElementById('notes2').addEventListener('click', event => {
@ -36,6 +44,8 @@ export class App {
document.getElementById('node-content')?.focus()
})
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
window._sync = new Sync()
// I think it is uncomfortable having the sync running as soon as the page load.

81
static/js/file.mjs Normal file
View file

@ -0,0 +1,81 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs";
export class N2File extends CustomHTMLElement {
static {
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
display: inline-grid;
grid-template-columns: min-content min-content;
align-items: center;
white-space: nowrap;
cursor: pointer;
.el-image {
max-width: var(--thumbnail-width);
max-height: var(--thumbnail-height);
}
.el-filename {
display: none;
font-weight: bold;
background-color: #ddd;
border-radius: 4px;
padding: 4px 8px;
margin-left: 8px;
}
}
</style>
<img data-el="image" src="/images/${_VERSION}/file_icons/generic.svg">
<div data-el="filename"></div>
`
}
constructor() {
super(true)
this.addEventListener('click', event => {
event.preventDefault()
event.stopPropagation()
window.open(
URL.createObjectURL(this.file),
(event.ctrlKey || event.shiftKey) ? '_blank' : '_self',
)
})
this.render()
}
async render() {
const src = this.getAttribute('src')
// N2's db:// URLs are fetched from IndexedDB.
if (src.toLowerCase().match('^db://[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')) {
// image population has to happen asynchronously,
// while Marked lib has to be returned a string when exiting this function.
// populateImg makes sure this returned img element exists and then populates it
// with the image from IndexedDB.
const file = await globalThis.nodeStore.files.get(src.slice(5))
if (!file)
return
this.file = file.file
if (file.file.type.startsWith('image/'))
this.elImage.src = URL.createObjectURL(file.file)
else {
// Check for and use an existing MIME type icon.
// Place them in static/images/file_icons/ and replace the slash with an underscore.
const url = `/images/${_VERSION}/file_icons/${file.file.type.replaceAll('/', '_')}.svg`
const res = await fetch(url)
if (res.ok)
this.elImage.src = url
this.elFilename.innerText = file.file.name
this.elFilename.style.display = 'block'
}
} else
this.elImage.src = src
}
}
customElements.define('n2-file', N2File)

View file

@ -1,10 +1,10 @@
export class CustomHTMLElement extends HTMLElement {
constructor() {// {{{
constructor(useShadow) {// {{{
super()
this.appendChild(this.constructor.tmpl.content.cloneNode(true))
this.querySelectorAll('*').forEach(el => {
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
workOn.querySelectorAll('*').forEach(el => {
const field = el.dataset.field
if (field !== undefined) {
const fieldName = this.toElementName('field', field)

View file

@ -92,18 +92,22 @@ function escapeHtmlEntities(html, encode) {// {{{
export class MarkedPosition {
constructor() {// {{{
window.setpos = (event) => {
event.stopPropagation()
event.preventDefault()
_mbus.dispatch('MARKDOWN_EDIT', {
position: {
start: event.target.dataset.offsetStart,
end: event.target.dataset.offsetEnd,
}
})
}
window.setpos = (event) => this.setpos(event)
this.render()
}// }}}
setpos(event) {// {{{
event.stopPropagation()
event.preventDefault()
_mbus.dispatch('MARKDOWN_EDIT', {
position: {
start: event.target.closest('[data-offset-start]').dataset.offsetStart,
end: event.target.closest('[data-offset-start]').dataset.offsetEnd,
}
})
}// }}}
render() {// {{{
const markedObject = this
this.marked = new Marked()
this.marked.use(markedTokenPosition())
this.marked.use({
@ -265,7 +269,6 @@ export class MarkedPosition {
},
image(token) {
if (token.tokens) {
token.text = this.parser.parseInline(token.tokens, this.parser.textRenderer)
}
@ -274,12 +277,11 @@ export class MarkedPosition {
return escapeHtmlEntities(token.text)
}
token.href = cleanHref
let out = `<img ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
let out = `<n2-file ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
if (token.title) {
out += ` title="${escapeHtmlEntities(token.title)}"`
}
out += '>'
out += '></n2-file>'
return out
},
@ -291,8 +293,34 @@ export class MarkedPosition {
}
})
}// }}}
}// }}}}}}
parse(text) {// {{{
return this.marked.parse(text)
}// }}}
async whenElementExist(id) {// {{{
// The element could have already been created.
const element = document.getElementById(id)
if (element) {
return element
}
const observer = new MutationObserver((_mutations, observer) => {
const target = document.getElementById(id)
if (target) {
observer.disconnect()
return target
}
})
observer.observe(document.documentElement, {
childList: true,
subtree: true
})
}// }}}
async populateImg(fileID, elementID) {// {{{
let img = await globalThis.nodeStore.files.get(fileID)
const el = await this.whenElementExist(elementID)
el.src = URL.createObjectURL(img.file)
}// }}}
}

View file

@ -12,10 +12,11 @@ export class NodeStore {
this.nodes = {}
this.sendQueue = null
this.nodesHistory = null
this.files = null
}//}}}
initializeDB() {//{{{
return new Promise((resolve, reject) => {
const req = indexedDB.open('notes', 7)
const req = indexedDB.open('notes', 8)
// Schema upgrades for IndexedDB.
// These can start from different points depending on updates to Notes2 since a device was online.
@ -24,6 +25,7 @@ export class NodeStore {
let appState
let sendQueue
let nodesHistory
let files
const db = event.target.result
const trx = event.target.transaction
@ -61,6 +63,10 @@ export class NodeStore {
case 7:
trx.objectStore('nodes_history').createIndex('byUUID', 'UUID', { unique: false })
break
case 8:
files = db.createObjectStore('files', { keyPath: 'UUID' })
break
}
}
}
@ -69,6 +75,7 @@ export class NodeStore {
this.db = event.target.result
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files')
this.initializeRootNode()
.then(() => resolve())
}
@ -159,39 +166,6 @@ export class NodeStore {
})
}//}}}
/*
upsertNodeRecords(records) {//{{{
return new Promise((resolve, reject) => {
const t = this.db.transaction('nodes', 'readwrite')
const nodeStore = t.objectStore('nodes')
t.onerror = (event) => {
console.log('transaction error', event.target.error)
reject(event.target.error)
}
t.oncomplete = () => {
resolve()
}
// records is an object, not an array.
for (const i in records) {
const record = records[i]
let addReq
let op
if (record.Deleted) {
op = 'deleting'
addReq = nodeStore.delete(record.UUID)
} else {
op = 'upserting'
// 'modified' is a local property for tracking
// nodes needing to be synced to backend.
record.modified = 0
addReq = nodeStore.put(record)
}
}
})
}//}}}
*/
getTreeNodes(parent, newLevel) {//{{{
return new Promise((resolve, reject) => {
// Parent of toplevel nodes is ROOT_NODE in indexedDB.
@ -376,8 +350,20 @@ class SimpleNodeStore {
}
})
}//}}}
get(key) {//{{{
return new Promise((resolve, _reject) => {
const req = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.get(key)
req.onsuccess = (event) => {
resolve(event.target.result)
}
})
}//}}}
retrieve(limit) {//{{{
return new Promise((resolve, reject) => {
return new Promise((resolve, _reject) => {
const cursorReq = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
@ -433,4 +419,51 @@ class SimpleNodeStore {
}//}}}
}
export class StoreFile {
static createFromFileObject(f) {
const obj = new StoreFile()
obj.name = f.name
obj.size = f.size
obj.mime = f.type
return obj
}
constructor() {
this.name = ''
this.size = 0
this.mime = ''
this.objectURL = null // URL.createObjectURL(blob)
}
data() {
return {
}
}
}
export function uuidv7() {
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
// current timestamp in ms
const timestamp = BigInt(Date.now())
// timestamp
value[0] = Number((timestamp >> 40n) & 0xffn)
value[1] = Number((timestamp >> 32n) & 0xffn)
value[2] = Number((timestamp >> 24n) & 0xffn)
value[3] = Number((timestamp >> 16n) & 0xffn)
value[4] = Number((timestamp >> 8n) & 0xffn)
value[5] = Number(timestamp & 0xffn)
// version and variant
value[6] = (value[6] & 0x0f) | 0x70
value[8] = (value[8] & 0x3f) | 0x80
const str = Array.from(value)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}
// vim: foldmethod=marker

View file

@ -1,8 +1,8 @@
import { ROOT_NODE } from 'node_store'
import { ROOT_NODE, uuidv7, StoreFile } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs'
export class N2NodeUI extends CustomHTMLElement {
export class N2PageNodeUI extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
@ -72,6 +72,7 @@ export class N2NodeUI extends CustomHTMLElement {
}
})
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
this.showMarkdown(true)
@ -118,6 +119,47 @@ export class N2NodeUI extends CustomHTMLElement {
return this.classList.contains('show-markdown')
}
}// }}}
async pasteHandler(event) {
const clipboardItems = event.clipboardData?.items
if (!clipboardItems)
return
for (const item of clipboardItems) {
switch (item.kind) {
case 'string':
continue
case 'file':
const file = item.getAsFile()
if (!file)
throw new Error("Couldn't convert image to file object.")
const uuid = uuidv7()
await globalThis.nodeStore.files.add({ data: { UUID: uuid, file: file }})
const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd]
this.elNodeContent.setRangeText(`![${file.name}](db://${uuid})`, start, end, 'select');
break
default:
alert(`Unknown paste type of '${item.kind}'`)
}
}
}
// Example usage: Displaying the image or preparing it for upload
handleImageBlob(blob) {
// 1. Create a local URL to preview it in an <img> tag if needed
const localUrl = URL.createObjectURL(blob)
console.log('Local preview URL:', localUrl)
// 2. Or prepare it for a FormData upload
const formData = new FormData()
formData.append('image', blob, 'pasted-image.png')
// fetch('/upload', { method: 'POST', body: formData })
}
editMarkdown(data) {// {{{
this.showMarkdown(false)
this.elNodeContent.selectionStart = data.position.start
@ -125,7 +167,7 @@ export class N2NodeUI extends CustomHTMLElement {
this.elNodeContent.focus()
}// }}}
}
customElements.define('n2-nodeui', N2NodeUI)
customElements.define('n2-nodeui', N2PageNodeUI)
export class Node {
static sort(a, b) {//{{{
@ -260,30 +302,4 @@ export class Node {
}//}}}
}
function uuidv7() {
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
// current timestamp in ms
const timestamp = BigInt(Date.now())
// timestamp
value[0] = Number((timestamp >> 40n) & 0xffn)
value[1] = Number((timestamp >> 32n) & 0xffn)
value[2] = Number((timestamp >> 24n) & 0xffn)
value[3] = Number((timestamp >> 16n) & 0xffn)
value[4] = Number((timestamp >> 8n) & 0xffn)
value[5] = Number(timestamp & 0xffn)
// version and variant
value[6] = (value[6] & 0x0f) | 0x70
value[8] = (value[8] & 0x3f) | 0x80
const str = Array.from(value)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}
// vim: foldmethod=marker

View file

@ -0,0 +1,28 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
export class N2PageStorage extends CustomHTMLElement {
static {
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<h1>Local storage</h1>
<div data-el="count-nodes"></div>
<div data-el="count-queued-nodes"></div>
<div data-el="count-history-nodes"></div>
`
}
constructor() {
super()
window._mbus.subscribe('SHOW_PAGE', () => this.render())
}
async render() {
const countNodes = await globalThis.nodeStore.nodeCount()
const countQueuedNodes = await globalThis.nodeStore.sendQueue.count()
const countHistoryNodes = await globalThis.nodeStore.nodesHistory.count()
this.elCountNodes.innerText = countNodes
this.elCountQueuedNodes.innerText = countQueuedNodes
this.elCountHistoryNodes.innerText = countHistoryNodes
}
}
customElements.define('n2-pagestorage', N2PageStorage)

View file

@ -9,6 +9,9 @@ export class Sync {
}//}}}
async run() {//{{{
// XXX - Delete me
return
try {
let duration = 0 // in ms
@ -163,13 +166,13 @@ export class Sync {
}
export class N2SyncProgress extends CustomHTMLElement {
static {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<progress data-el="progress" min=0 max=137 value=0></progress>
<div data-el="count" class="count">0 / 0</div>
`
}
}// }}}
constructor() {//{{{
super()