Some checks failed
		
		
	
	Build Tilde Friends / Build-All (push) Has been cancelled
				
			
		
			
				
	
	
		
			307 lines
		
	
	
		
			8.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			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`;
 | 
						|
			} 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);
 |