diff --git a/html_template/pkg.go b/html_template/pkg.go index 4140f89..9abfc12 100644 --- a/html_template/pkg.go +++ b/html_template/pkg.go @@ -66,8 +66,6 @@ func (e *Engine) ReloadTemplates() { // {{{ } // }}} func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{ - var err error - // URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version. // To get rid of problems with cached content in browser on a new version release, // while also not disabling cache altogether. @@ -83,11 +81,7 @@ func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{ r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2]) if e.DevMode { - p := fmt.Sprintf("static/%s/%s", comp[1], comp[2]) - _, err = os.Stat(p) - if err == nil { - e.staticLocalFS.ServeHTTP(w, r) - } + e.staticLocalFS.ServeHTTP(w, r) return } } diff --git a/static/css/notes2.css b/static/css/notes2.css index 7c2320f..31e1f1f 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -52,51 +52,51 @@ html { #tree { grid-area: tree; display: grid; - background-color: #fafafa; + background-color: #ffffff; color: #444; z-index: 100; - border-right: 1px solid #ddd; - - n2-tree { - /*border: 2px solid #f8f8f8;*/ - padding: 16px 48px 16px 24px; - } - - &:focus-within { - n2-tree { - /* - border: 2px solid #fe5f55; - */ - } - - } - + border-right: 2px solid #ddd; #logo { display: grid; - position: relative; - justify-items: center; - margin-top: 8px; - margin-bottom: 8px; - margin-left: 24px; - margin-right: 24px; + grid-template-columns: min-content 1fr min-content; + align-items: center; + justify-items: start; cursor: pointer; + padding: 16px; + border-bottom: 1px solid #ccc; - img { - width: 128px; - left: -20px; + .el-search { + justify-self: end; + } + img:first-child { + height: 24px; + margin-right: 8px; } } .icons { display: flex; justify-content: center; - margin-bottom: 32px; + margin: 16px 0px 32px 0px; gap: 8px; } + n2-tree { + .el-treenodes { + margin: 32px; + } + } + + &:focus-within { + n2-tree { + } + + } + + .node { display: grid; grid-template-columns: 40px min-content; @@ -145,14 +145,27 @@ html { } } -#tree-nodes { - padding: 16px 32px; - /* - border-radius: 8px; -*/ - /* - box-shadow: 5px 5px 10px -5px rgba(0, 0, 0, 0.75); - */ +[id^="page-"] { + display: none; +} + +#main-page { + display: contents; + + &.node { + #page-node { + display: contents; + } + } + + &.storage { + #page-storage { + display: contents; + n2-pagestorage { + grid-area: content; + } + } + } } #crumbs { diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..299310f Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/images/collapsed.svg b/static/images/collapsed.svg index d93f4ca..db06415 100644 --- a/static/images/collapsed.svg +++ b/static/images/collapsed.svg @@ -8,7 +8,7 @@ version="1.1" id="svg1" sodipodi:docname="collapsed.svg" - inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -23,13 +23,13 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" - inkscape:zoom="4.8373092" - inkscape:cx="6.201795" - inkscape:cy="-12.40359" - inkscape:window-width="1916" - inkscape:window-height="1161" - inkscape:window-x="0" - inkscape:window-y="18" + inkscape:zoom="19.349237" + inkscape:cx="11.809251" + inkscape:cy="6.3051583" + inkscape:window-width="1093" + inkscape:window-height="1401" + inkscape:window-x="2560" + inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1" showguides="false" /> @@ -42,9 +42,13 @@ transform="translate(-102.39375,-146.31458)"> folder-outline + + style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#ffcc00;fill-opacity:1;stroke-width:0.330728;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" /> diff --git a/static/images/expanded.svg b/static/images/expanded.svg index 017e8a4..9b420a8 100644 --- a/static/images/expanded.svg +++ b/static/images/expanded.svg @@ -8,7 +8,7 @@ version="1.1" id="svg1" sodipodi:docname="expanded.svg" - inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xml:space="preserve" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" @@ -23,13 +23,13 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" - inkscape:zoom="11.17754" - inkscape:cx="20.845374" - inkscape:cy="26.929003" - inkscape:window-width="1916" - inkscape:window-height="1161" - inkscape:window-x="0" - inkscape:window-y="18" + inkscape:zoom="15.807429" + inkscape:cx="10.533022" + inkscape:cy="16.384701" + inkscape:window-width="1093" + inkscape:window-height="1401" + inkscape:window-x="1463" + inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1" />folder-openfolder-open-outline + style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#ffcc00;fill-opacity:1;stroke-width:0.264583;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" /> diff --git a/static/images/file_icons/application_pdf.svg b/static/images/file_icons/application_pdf.svg new file mode 100644 index 0000000..e503d4d --- /dev/null +++ b/static/images/file_icons/application_pdf.svg @@ -0,0 +1,107 @@ + + + +file-outline + + + + + + + + + + + + diff --git a/static/images/file_icons/generic.svg b/static/images/file_icons/generic.svg new file mode 100644 index 0000000..7ca6c2b --- /dev/null +++ b/static/images/file_icons/generic.svg @@ -0,0 +1,100 @@ + + + +file-outline + + + + + + + + + + + + diff --git a/static/images/icon_settings.svg b/static/images/icon_settings.svg new file mode 100644 index 0000000..1b692ad --- /dev/null +++ b/static/images/icon_settings.svg @@ -0,0 +1,49 @@ + + + + + + + + cog-outline + + + diff --git a/static/images/logo.svg b/static/images/logo.svg index 3b5efa4..f294234 100644 --- a/static/images/logo.svg +++ b/static/images/logo.svg @@ -2,12 +2,12 @@ - - - - + transform="translate(-69.267449,-144.54153)"> + + + NOTES + 2 + diff --git a/static/images/logo_small.svg b/static/images/logo_small.svg new file mode 100644 index 0000000..cb83d39 --- /dev/null +++ b/static/images/logo_small.svg @@ -0,0 +1,63 @@ + + + + + + + + + N2 + + diff --git a/static/js/app.mjs b/static/js/app.mjs index dc60b59..feeec3a 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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. diff --git a/static/js/file.mjs b/static/js/file.mjs new file mode 100644 index 0000000..2674737 --- /dev/null +++ b/static/js/file.mjs @@ -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 = ` + + +
+ ` + } + 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) diff --git a/static/js/lib/css_colorize.mjs b/static/js/lib/css_colorize.mjs new file mode 100644 index 0000000..f0fdb37 --- /dev/null +++ b/static/js/lib/css_colorize.mjs @@ -0,0 +1,207 @@ +export class Color { + constructor(r, g, b) { this.set(r, g, b); } + toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; } + + set(r, g, b) { + this.r = this.clamp(r); + this.g = this.clamp(g); + this.b = this.clamp(b); + } + + hueRotate(angle = 0) { + angle = angle / 180 * Math.PI; + let sin = Math.sin(angle); + let cos = Math.cos(angle); + + this.multiply([ + 0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928, + 0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283, + 0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072 + ]); + } + + grayscale(value = 1) { + this.multiply([ + 0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value), + 0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value), + 0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value) + ]); + } + + sepia(value = 1) { + this.multiply([ + 0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value), + 0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value), + 0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value) + ]); + } + + saturate(value = 1) { + this.multiply([ + 0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value, + 0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value, + 0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value + ]); + } + + multiply(matrix) { + let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]); + let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]); + let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]); + this.r = newR; this.g = newG; this.b = newB; + } + + brightness(value = 1) { this.linear(value); } + contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); } + + linear(slope = 1, intercept = 0) { + this.r = this.clamp(this.r * slope + intercept * 255); + this.g = this.clamp(this.g * slope + intercept * 255); + this.b = this.clamp(this.b * slope + intercept * 255); + } + + invert(value = 1) { + this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255); + this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255); + this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255); + } + + hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA. + let r = this.r / 255; + let g = this.g / 255; + let b = this.b / 255; + let max = Math.max(r, g, b); + let min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + let d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } h /= 6; + } + + return { + h: h * 100, + s: s * 100, + l: l * 100 + }; + } + + clamp(value) { + if (value > 255) { value = 255; } + else if (value < 0) { value = 0; } + return value; + } +} + +export class Solver { + constructor(target) { + this.target = target; + this.targetHSL = target.hsl(); + this.reusedColor = new Color(0, 0, 0); // Object pool + } + + solve() { + let result = this.solveNarrow(this.solveWide()); + return { + values: result.values, + loss: result.loss, + filter: this.css(result.values) + }; + } + + solveWide() { + const A = 5; + const c = 15; + const a = [60, 180, 18000, 600, 1.2, 1.2]; + + let best = { loss: Infinity }; + for (let i = 0; best.loss > 25 && i < 3; i++) { + let initial = [50, 20, 3750, 50, 100, 100]; + let result = this.spsa(A, a, c, initial, 1000); + if (result.loss < best.loss) { best = result; } + } return best; + } + + solveNarrow(wide) { + const A = wide.loss; + const c = 2; + const A1 = A + 1; + const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1]; + return this.spsa(A, a, c, wide.values, 500); + } + + spsa(A, a, c, values, iters) { + const alpha = 1; + const gamma = 0.16666666666666666; + + let best = null; + let bestLoss = Infinity; + let deltas = new Array(6); + let highArgs = new Array(6); + let lowArgs = new Array(6); + + for (let k = 0; k < iters; k++) { + let ck = c / Math.pow(k + 1, gamma); + for (let i = 0; i < 6; i++) { + deltas[i] = Math.random() > 0.5 ? 1 : -1; + highArgs[i] = values[i] + ck * deltas[i]; + lowArgs[i] = values[i] - ck * deltas[i]; + } + + let lossDiff = this.loss(highArgs) - this.loss(lowArgs); + for (let i = 0; i < 6; i++) { + let g = lossDiff / (2 * ck) * deltas[i]; + let ak = a[i] / Math.pow(A + k + 1, alpha); + values[i] = fix(values[i] - ak * g, i); + } + + let loss = this.loss(values); + if (loss < bestLoss) { best = values.slice(0); bestLoss = loss; } + } return { values: best, loss: bestLoss }; + + function fix(value, idx) { + let max = 100; + if (idx === 2 /* saturate */) { max = 7500; } + else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; } + + if (idx === 3 /* hue-rotate */) { + if (value > max) { value = value % max; } + else if (value < 0) { value = max + value % max; } + } else if (value < 0) { value = 0; } + else if (value > max) { value = max; } + return value; + } + } + + loss(filters) { // Argument is array of percentages. + let color = this.reusedColor; + color.set(0, 0, 0); + + color.invert(filters[0] / 100); + color.sepia(filters[1] / 100); + color.saturate(filters[2] / 100); + color.hueRotate(filters[3] * 3.6); + color.brightness(filters[4] / 100); + color.contrast(filters[5] / 100); + + let colorHSL = color.hsl(); + return Math.abs(color.r - this.target.r) + + Math.abs(color.g - this.target.g) + + Math.abs(color.b - this.target.b) + + Math.abs(colorHSL.h - this.targetHSL.h) + + Math.abs(colorHSL.s - this.targetHSL.s) + + Math.abs(colorHSL.l - this.targetHSL.l); + } + + css(filters) { + function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); } + return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%)`; + } +} diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs index dedb5d8..2cec808 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -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) diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs index ca85d3e..62a6996 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -92,30 +92,34 @@ 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({ renderer: { heading(token) { const content = this.parser.parseInline(token.tokens) - return `${content}\n` + return `${content}\n` }, paragraph(token) { const content = this.parser.parseInline(token.tokens) - return `

${content}

\n` + return `

${content}

\n` }, list(token) { @@ -134,7 +138,7 @@ export class MarkedPosition { }, listitem(token) { - return `
  • ${this.parser.parse(token.tokens)}
  • \n` + return `
  • ${this.parser.parse(token.tokens)}
  • \n` }, code(token) { @@ -143,12 +147,12 @@ export class MarkedPosition { const code = token.text.replace(other.endingNewline, '') + '\n' if (!langString) { - return `
    `
    +						return `
    `
     							+ (token.escaped ? code : escapeHtmlEntities(code, true))
     							+ '
    \n' } - return `
    '
     						+ (token.escaped ? code : escapeHtmlEntities(code, true))
    @@ -157,7 +161,7 @@ export class MarkedPosition {
     
     				blockquote(token) {
     					const body = this.parser.parse(token.tokens)
    -					return `
    \n${body}
    \n` + return `
    \n${body}
    \n` }, html(token) { @@ -169,11 +173,11 @@ export class MarkedPosition { }, hr(token) { - return `
    \n` + return `
    \n` }, checkbox(token) { - return ` ' }, @@ -218,7 +222,7 @@ export class MarkedPosition { if (token.tokens.length > 0) { const start = token.tokens[0].position.start.offset const end = token.tokens[0].position.end.offset - ofs = `onclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` + ofs = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` } const content = this.parser.parseInline(token.tokens); @@ -230,23 +234,23 @@ export class MarkedPosition { }, strong(token) { - return `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` }, em(token) { - return `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` }, codespan(token) { - return `${escapeHtmlEntities(token.text, true)}` + return `${escapeHtmlEntities(token.text, true)}` }, br(token) { - return `
    ` + return `
    ` }, del(token) { - return `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` }, link(token) { @@ -256,7 +260,7 @@ export class MarkedPosition { return text } token.href = cleanHref - let out = ' { + 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) + }// }}} } diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index e849e29..d29923f 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -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 diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index 50286af..ddaf891 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -86,404 +86,6 @@ export class Notes2 extends Component { }//}}} } -class Tree extends Component { - constructor(props) {//{{{ - super(props) - console.log('new tree') - this.treeNodeComponents = {} - this.treeTrunk = [] - this.selectedNode = null - this.expandedNodes = {} // keyed on UUID - this.treeDiv = createRef() - - // childrenFetchedCallbacks is keyed on a UUID and each - // item is an array with callbacks called when a UUID has - // had all children fetched. - this.childrenFetchedCallbacks = {} - - this.props.app.tree = this - - this.populateFirstLevel() - }//}}} - render({ app }) {//{{{ - const renderedTreeTrunk = this.treeTrunk.map(node => { - this.treeNodeComponents[node.UUID] = createRef() - return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` - }) - - return html` -
    - -
    - _mbus.dispatch('op-search')} /> - _sync.run()} /> -
    - ${renderedTreeTrunk} -
    ` - }//}}} - componentDidMount() {//{{{ - //this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event)) - - // This will show and select the treenode that is selected in the node UI. - const node = _notes2.current?.nodeUI.current?.node.value - if (node === null) - return - _notes2.current.tree.expandToTrunk(node) - this.setSelected(node) - }//}}} - - fetchChildrenNotify(uuid, fn) {//{{{ - if (this.childrenFetchedCallbacks[uuid] === undefined) - this.childrenFetchedCallbacks[uuid] = [fn] - else - this.childrenFetchedCallbacks[uuid].push(fn) - }//}}} - fetchChildrenOn(uuid) {//{{{ - if (this.childrenFetchedCallbacks[uuid] === undefined) - return - for (const fn of this.childrenFetchedCallbacks[uuid]) - fn(uuid) - delete this.childrenFetchedCallbacks[uuid] - }//}}} - - populateFirstLevel(callback = null) {//{{{ - nodeStore.get(ROOT_NODE) - .then(node => node.fetchChildren()) - .then(children => { - this.treeNodeComponents = {} - this.treeTrunk = [] - for (const node of children) { - // The root node isn't supposed to be shown in the tree. - if (node.UUID === ROOT_NODE) - continue - if (node.ParentUUID === ROOT_NODE) - this.treeTrunk.push(node) - } - this.forceUpdate() - if (callback) - callback() - - }) - .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) - }//}}} - setSelected(node, dontExpand) {//{{{ - // The previously selected node, if any, needs to be rerendered - // to not retain its 'selected' class. - const prevUUID = this.selectedNode?.UUID - this.selectedNode = node - if (prevUUID) - this.treeNodeComponents[prevUUID]?.current.forceUpdate() - - // And now the newly selected node is rerendered. - this.treeNodeComponents[node.UUID]?.current.forceUpdate() - - if (!dontExpand) - this.setNodeExpanded(node, true) - }//}}} - isSelected(node) {//{{{ - return this.selectedNode?.UUID === node.UUID - }//}}} - async expandToTrunk(node) {//{{{ - // Get all ancestors from a certain node up to the highest grandparent. - const ancestry = await nodeStore.getNodeAncestry(node, []) - for (const i in ancestry) { - await nodeStore.node(ancestry[i].UUID).fetchChildren() - this.setNodeExpanded(ancestry[i], true) - } - - // Already a top node, no need to expand anything. - if (ancestry.length === 0) - return - - // Start the chain of by expanding the top node. - this.setNodeExpanded(ancestry[ancestry.length - 1], true) - }//}}} - getNodeExpanded(UUID) {//{{{ - if (this.expandedNodes[UUID] === undefined) - this.expandedNodes[UUID] = signal(false) - return this.expandedNodes[UUID].value - }//}}} - async setNodeExpanded(node, value) {//{{{ - return new Promise((resolve, reject) => { - const work = uuid => { - // Creating a default value if it doesn't exist already. - this.getNodeExpanded(uuid) - this.expandedNodes[uuid].value = value - resolve() - } - - if (node.hasFetchedChildren()) { - work(node.UUID) - return - } else { - this.fetchChildrenNotify(node.UUID, uuid => work(uuid)) - } - }) - }//}}} - getParentWithNextSibling(node) {//{{{ - let currNode = node - while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) { - currNode = currNode.getParent() - } - return currNode?.getSiblingAfter() - }//}}} - getLastExpandedNode(node) {//{{{ - let currNode = node - while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) { - currNode = currNode.Children[currNode.Children.length - 1] - } - return currNode - }//}}} - - async recursiveExpand(node, state) {//{{{ - if (state) - await this.setNodeExpanded(node, true) - - for (const child of node.Children) - await this.recursiveExpand(child, state) - - if (!state) - await this.setNodeExpanded(node, false) - }//}}} - - async keyHandler(event) {//{{{ - let handled = true - const n = this.selectedNode - const Space = ' ' - - // This handler would otherwise react to stuff like Ctrl+L. - if (event.ctrlKey || event.altKey) - return - - switch (event.key) { - // Space and enter is toggling expansion. - // Holding shift down does it recursively. - case Space: - case 'Enter': - const expanded = this.getNodeExpanded(n.UUID) - if (event.shiftKey) { - this.recursiveExpand(n, !expanded) - } else { - this.setNodeExpanded(n, !expanded) - } - break - - case 'g': - case 'Home': - this.navigateTop() - break - - case 'G': - case 'End': - this.navigateBottom() - break - - case 'j': - case 'ArrowDown': - await this.navigateDown(this.selectedNode) - break - - case 'k': - case 'ArrowUp': - await this.navigateUp(this.selectedNode) - break - - case 'h': - case 'ArrowLeft': - await this.navigateLeft(this.selectedNode) - break - - case 'l': - case 'ArrowRight': - await this.navigateRight(this.selectedNode) - break - - default: - // nonsole.log(event.key) - handled = false - } - - if (handled) { - event.preventDefault() - event.stopPropagation() - } - }//}}} - async navigateLeft(n) {//{{{ - if (n === null) - return - - const expanded = this.getNodeExpanded(n.UUID) - if (expanded && n.hasChildren()) { - this.setNodeExpanded(n, false) - return - } - - if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { - await _notes2.current.goToNode(n.getParent()?.UUID, true, true) - return - } - - const siblingBefore = n.getSiblingBefore() - const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID) - if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - const siblingAbove = this.getLastExpandedNode(siblingBefore) - await _notes2.current.goToNode(siblingAbove?.UUID, true, true) - return - } - - await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true) - }//}}} - async navigateRight(n) {//{{{ - if (n === null) - return - - const siblingAfter = n.getSiblingAfter() - const expanded = this.getNodeExpanded(n.UUID) - - if (!expanded && n.hasChildren()) { - this.setNodeExpanded(n, true) - return - } - - if (expanded && n.hasChildren()) { - await _notes2.current.goToNode(n.Children[0]?.UUID, true, true) - return - } - - if (n.isLastSibling()) { - const nextNode = this.getParentWithNextSibling(n) - await _notes2.current.goToNode(nextNode?.UUID, true, true) - return - } - - await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) - }//}}} - async navigateUp(n) {//{{{ - if (n === null) - return - - let parent = null - const siblingBefore = n.getSiblingBefore() - let siblingExpanded = false - if (siblingBefore !== null) - siblingExpanded = this.getNodeExpanded(siblingBefore.UUID) - - if (n.isFirstSibling()) { - parent = n.getParent() - if (parent?.UUID === ROOT_NODE) - return - await _notes2.current.goToNode(parent?.UUID, true, true) - return - } - - if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true) - return - } - - if (siblingBefore) { - await _notes2.current.goToNode(siblingBefore.UUID, true, true) - return - } - }//}}} - async navigateDown(n) {//{{{ - if (n === null) - return - - const nodeExpanded = this.getNodeExpanded(n.UUID) - - // Last node, not expanded, so it matters not whether it has children or not. - // Traverse upward to nearest parent with next sibling. - if (!nodeExpanded && n.isLastSibling()) { - const wantedNode = this.getParentWithNextSibling(n) - await _notes2.current.goToNode(wantedNode?.UUID, true, true) - return - } - - if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) { - const wantedNode = this.getParentWithNextSibling(n) - await _notes2.current.goToNode(wantedNode?.UUID, true, true) - return - } - - // Node not expanded. Go to this node's next sibling. - // GoToNode will abort if given null. - if (!nodeExpanded || !n.hasChildren()) { - await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) - return - } - - // Node is expanded. - // Children will be visually beneath this node, if any. - if (nodeExpanded && n.hasChildren()) { - await _notes2.current.goToNode(n.Children[0].UUID, true, true) - return - } - }//}}} - async navigateTop() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - await _notes2.current.goToNode(root.Children[0]?.UUID, true, true) - }//}}} - async navigateBottom() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - - const toplevel = root.Children[root.Children.length - 1] - const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) - - if (toplevelExpanded) { - const lastnode = this.getLastExpandedNode(toplevel) - await _notes2.current.goToNode(lastnode?.UUID, true, true) - } else - await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, true) - }//}}} -} - -class TreeNode extends Component { - constructor(props) {//{{{ - super(props) - this.children_populated = signal(false) - if (this.props.node.Level === 0 || this.props.tree.getNodeExpanded(this.props.node.UUID)) - this.fetchChildren() - }//}}} - render({ tree, node, parent }) {//{{{ - // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible. - const selected = tree.isSelected(node) ? 'selected' : '' - - if (!this.children_populated.value && tree.getNodeExpanded(parent?.props.node.UUID)) - this.fetchChildren() - - const children = node.Children.map(node => { - tree.treeNodeComponents[node.UUID] = createRef() - return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />` - }) - - let expandImg = '' - if (node.Children.length === 0) - expandImg = html`` - else { - if (tree.getNodeExpanded(node.UUID)) - expandImg = html`` - else - expandImg = html`` - } - - return html` -
    -
    { tree.setNodeExpanded(node, !tree.getNodeExpanded(node.UUID)) }}>${expandImg}
    -
    window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}
    -
    ${children}
    -
    ` - }//}}} - async fetchChildren() {//{{{ - await this.props.node.fetchChildren() - this.children_populated.value = true - }//}}} -} - class Op { constructor(id) { this.id = id diff --git a/static/js/node.mjs b/static/js/page_node.mjs similarity index 83% rename from static/js/node.mjs rename to static/js/page_node.mjs index 7959831..86c29c0 100644 --- a/static/js/node.mjs +++ b/static/js/page_node.mjs @@ -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 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 diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs new file mode 100644 index 0000000..931a718 --- /dev/null +++ b/static/js/page_storage.mjs @@ -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 = ` +

    Local storage

    +
    +
    +
    + ` + } + 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) diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 9b58cf7..e432f15 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -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 = `
    0 / 0
    ` - } + }// }}} constructor() {//{{{ super() diff --git a/static/js/tree.mjs b/static/js/tree.mjs index 3732fc5..b672742 100644 --- a/static/js/tree.mjs +++ b/static/js/tree.mjs @@ -1,14 +1,19 @@ import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' +import { Color, Solver } from './lib/css_colorize.mjs' export class N2Tree extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` - -
    + +
    + +
    ` @@ -31,7 +36,7 @@ export class N2Tree extends CustomHTMLElement { this.elSync.addEventListener('click', () => _sync.run()) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) - _mbus.subscribe('NODE_MODIFIED', ({ detail })=>{ + _mbus.subscribe('NODE_MODIFIED', ({ detail }) => { const node = detail.data.node const treenode = this.treeNodeComponents[node.get('UUID')] @@ -43,6 +48,12 @@ export class N2Tree extends CustomHTMLElement { }) this.populateFirstLevel() + + /* XXX - set color */ + let color = new Color(255, 96, 80) + let solver = new Solver(color) + let result = solver.solve() + this.elSettings.style.filter = result.filter }// }}} render() {// {{{ if (this.rendered) diff --git a/static/service_worker.js b/static/service_worker.js index 1717eef..80b79a1 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -29,7 +29,8 @@ const CACHED_ASSETS = [ '/js/{{ .VERSION }}/lib/sjcl.js', '/js/{{ .VERSION }}/marked_position.mjs', '/js/{{ .VERSION }}/mbus.mjs', - '/js/{{ .VERSION }}/node.mjs', + '/js/{{ .VERSION }}/page_node.mjs', + '/js/{{ .VERSION }}/page_storage.mjs', '/js/{{ .VERSION }}/node_store.mjs', '/js/{{ .VERSION }}/notes2.mjs', '/js/{{ .VERSION }}/sync.mjs', diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index c744166..ead95a9 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -14,7 +14,7 @@ "checklist": "/js/{{ .VERSION }}/checklist.mjs", "crypto": "/js/{{ .VERSION }}/crypto.mjs", "node_store": "/js/{{ .VERSION }}/node_store.mjs", - "node": "/js/{{ .VERSION }}/node.mjs", + "node": "/js/{{ .VERSION }}/page_node.mjs", "tree": "/js/{{ .VERSION }}/tree.mjs" } } diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 77b74a6..80c084a 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,18 +1,34 @@ {{ define "page" }}
    -
    - - + +
    + +
    + +
    + + +
    +
    + + +
    +