tildefriends/apps/wiki/tf-wiki-doc.js
Cory McWilliams 559504ae29
Some checks failed
Build Tilde Friends / Build-All (push) Has been cancelled
security: Use commonmarkjs with {safe: true} as intended.
2024-11-12 20:43:03 -05:00

307 lines
8.4 KiB
JavaScript

import {LitElement, html, unsafeHTML} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import * as commonmark from './commonmark.min.js';
class TfWikiDocElement extends LitElement {
static get properties() {
return {
whoami: {type: String},
wiki: {type: Object},
value: {type: Object},
blob: {type: String},
blob_original: {type: String},
blob_for_value: {type: String},
is_editing: {type: Boolean},
};
}
constructor() {
super();
}
markdown(md) {
let reader = new commonmark.Parser();
let writer = new commonmark.HtmlRenderer({safe: true});
let parsed = reader.parse(md || '');
let walker = parsed.walker();
let event;
while ((event = walker.next())) {
let node = event.node;
if (event.entering) {
if (node.destination?.startsWith('&')) {
node.destination =
'/' +
node.destination +
'/view?filename=' +
node.firstChild?.literal;
} else if (node.type === 'link') {
if (
node.destination.indexOf(':') == -1 &&
node.destination.indexOf('/') == -1
) {
node.destination = `#${this.wiki?.name}/${node.destination}`;
}
}
}
}
return writer.render(parsed);
}
title(md) {
let lines = (md || '').split('\n');
for (let line of lines) {
let m = line.match(/#+ (.*)/);
if (m) {
return m[1];
}
}
}
summary(md) {
let lines = (md || '').split('\n');
let result = [];
let have_content = false;
for (let line of lines) {
if (have_content && !line.trim().length) {
return result.join('\n');
}
if (!line.startsWith('#') && line.trim().length) {
have_content = true;
}
if (!line.startsWith('#')) {
result.push(line);
}
}
return result.join('\n');
}
thumbnail(md) {
let m = md
? md.match(/\!\[image:[^\]]+\]\((\&.{44}\.sha256)\).*/)
: undefined;
return m ? m[1] : undefined;
}
async load_blob() {
let blob = await tfrpc.rpc.get_blob(this.value?.blob);
if (!blob) {
console.warn(
"no blob found, we're going to assume the document is empty (load_blob())"
);
} else if (blob.endsWith('.box')) {
let d = await tfrpc.rpc.try_decrypt(this.whoami, blob);
if (d) {
blob = d;
}
}
this.blob = blob;
this.blob_original = blob;
}
on_edit(event) {
this.blob = event.srcElement.value;
}
on_discard(event) {
this.blob = this.blob_original;
this.is_editing = false;
}
async append_message(draft) {
let blob = this.blob;
if (draft || this.value?.private) {
blob = await tfrpc.rpc.encrypt(this.whoami, this.wiki.editors, blob);
}
let id = await tfrpc.rpc.store_blob(blob);
let message = {
type: 'wiki-doc',
key: this.value.id,
parent: this.value.parent,
blob: id,
mentions: this.blob.match(/(&.{44}.sha256)/g)?.map((x) => ({link: x})),
private: this.value?.private,
};
if (draft) {
message.recps = this.value.editors;
message = await tfrpc.rpc.encrypt(
this.whoami,
this.value.editors,
JSON.stringify(message)
);
}
await tfrpc.rpc.appendMessage(this.whoami, message);
this.is_editing = false;
}
async on_save_draft() {
return this.append_message(true);
}
async on_publish() {
return this.append_message(false);
}
async on_blog_publish() {
let blob = this.blob;
let id = await tfrpc.rpc.store_blob(blob);
let message = {
type: 'blog',
key: this.value.id,
parent: this.value.parent,
title: this.title(blob),
summary: this.summary(blob),
thumbnail: this.thumbnail(blob),
blog: id,
mentions: this.blob.match(/(&.{44}.sha256)/g)?.map((x) => ({link: x})),
};
await tfrpc.rpc.appendMessage(this.whoami, message);
this.is_editing = false;
}
convert_to_format(buffer, type, mime_type) {
return new Promise(function (resolve, reject) {
let img = new Image();
img.onload = function () {
let canvas = document.createElement('canvas');
let width_scale = Math.min(img.width, 1024) / img.width;
let height_scale = Math.min(img.height, 1024) / img.height;
let scale = Math.min(width_scale, height_scale);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
let context = canvas.getContext('2d');
context.drawImage(img, 0, 0, canvas.width, canvas.height);
let data_url = canvas.toDataURL(mime_type);
let result = atob(data_url.split(',')[1])
.split('')
.map((x) => x.charCodeAt(0));
resolve(result);
};
img.onerror = function (event) {
reject(new Error('Failed to load image.'));
};
let raw = Array.from(new Uint8Array(buffer))
.map((b) => String.fromCharCode(b))
.join('');
let original = `data:${type};base64,${btoa(raw)}`;
img.src = original;
});
}
humanSize(value) {
let units = ['B', 'kB', 'MB', 'GB'];
let i = 0;
while (i < units.length - 1 && value >= 1024) {
value /= 1024;
i++;
}
return `${Math.round(value * 10) / 10} ${units[i]}`;
}
async add_file(editor, file) {
try {
let self = this;
let buffer = await file.arrayBuffer();
let type = file.type;
let insert;
if (type.startsWith('image/')) {
let best_buffer;
let best_type;
for (let format of ['image/png', 'image/jpeg', 'image/webp']) {
let test_buffer = await self.convert_to_format(
buffer,
file.type,
format
);
if (!best_buffer || test_buffer.length < best_buffer.length) {
best_buffer = test_buffer;
best_type = format;
}
}
buffer = best_buffer;
type = best_type;
let id = await tfrpc.rpc.store_blob(buffer);
let name = type.split('/')[0] + ':' + file.name;
insert = `\n![${name}](${id})`;
} else {
buffer = Array.from(new Uint8Array(buffer));
let id = await tfrpc.rpc.store_blob(buffer);
let name = file.name;
insert = `\n[${name}](${id}) (${this.humanSize(buffer.length)})`;
}
document.execCommand('insertText', false, insert);
self.on_edit({srcElement: editor});
} catch (e) {
alert(e?.message);
}
}
paste(event) {
let self = this;
for (let item of event.clipboardData.items) {
let file = item.getAsFile();
if (file) {
self.add_file(event.srcElement, file);
event.preventDefault();
}
}
}
render() {
let value = JSON.stringify(this.value);
if (this.blob_for_value != value) {
this.blob_for_value = value;
this.blob = undefined;
this.blob_original = undefined;
this.load_blob();
}
let self = this;
let thumbnail_ref = this.thumbnail(this.blob);
return html`
<link rel="stylesheet" href="tildefriends.css"/>
<style>
a:link { color: #268bd2 }
a:visited { color: #6c71c4 }
a:hover { color: #859900 }
a:active { color: #2aa198 }
#editor-text-area {
background-color: #00000040;
color: white;
style="flex: 1 1;
min-height: 10em;
font-size: larger;
${this.value?.private ? 'border: 4px solid #800' : ''}
</style>
<div class="inline-flex-row">
<button ?disabled=${!this.whoami || this.is_editing} @click=${() => (self.is_editing = true)}>Edit</button>
<button ?disabled=${this.blob == this.blob_original} @click=${this.on_save_draft}>Save Draft</button>
<button ?disabled=${this.blob == this.blob_original && !this.value?.draft} @click=${this.on_publish}>Publish</button>
<button ?disabled=${!this.is_editing} @click=${this.on_discard}>Discard</button>
<button ?disabled=${!this.is_editing} @click=${() => (self.value = Object.assign({}, self.value, {private: !self.value.private}))}>${this.value?.private ? 'Make Public' : 'Make Private'}</button>
<button ?disabled=${!this.is_editing} @click=${this.on_blog_publish}>Publish Blog</button>
</div>
<div ?hidden=${!this.value?.private} style="color: #800">🔒 document is private</div>
<div class="flex-column" ${this.value?.private ? 'border-top: 4px solid #800' : ''}">
<textarea
rows="25"
?hidden=${!this.is_editing}
id="editor-text-area"
@input=${this.on_edit}
@paste=${this.paste}
.value=${this.blob ?? ''}></textarea>
<div style="flex: 1 1; margin-top: 16px">
<div ?hidden=${!this.is_editing} class="box">
Summary
<img ?hidden=${!thumbnail_ref} style="max-width: 128px; max-height: 128px; float: right" src="/${thumbnail_ref}/view">
<h1 ?hidden=${!this.title(this.blob)}>${unsafeHTML(this.markdown(this.title(this.blob)))}</h1>
${unsafeHTML(this.markdown(this.summary(this.blob)))}
</div>
${unsafeHTML(this.markdown(this.blob))}
</div>
</div>
`;
}
}
customElements.define('tf-wiki-doc', TfWikiDocElement);