const globalTransform = new Transform() let selectedBlock = null let markdownData let thumbnails = {} let board async function RenderBoard(mountPoint, values) { if (!values.boardId) return; const response = await fetch(`/api/v1/boards/${values.boardId}`); markdownData = await response.json(); try { const thumbsResponse = await fetch(`/api/v1/boards/${values.boardId}/thumbnails`) let thumbsFormData = await thumbsResponse.formData() for (const key of thumbsFormData.keys()) { let buffer = await thumbsFormData.get(key).arrayBuffer() let b64 = await bytesToBase64DataUrl(buffer, key.endsWith('.svg') ? "image/svg+xml" : "image/png") thumbnails[key] = b64 } } catch (e) { console.log("failed to load thumbnails: ", e) } document.title = markdownData.name; board = new Board(mountPoint, markdownData); } class Board { constructor(mountPoint) { mountPoint.innerHTML = `
` document.body.classList.add("hide_scroll") const boards = mountPoint.querySelector(".boards") this.sidebar = new Sidebar(boards) this.display = new Display(boards) this.sidebar.setSaveButtonState() document.addEventListener('blockselected', (e) => this.sidebar.onSelected(e.detail.block)) document.addEventListener('blockunselected', (e) => this.sidebar.onUnselected(e.detail.block)) } } class Sidebar { constructor(mountPoint) { this.div = document.createElement('div') this.div.classList.add('boards_sidebar') this.div.innerHTML = `
Save
Add Image
Add Text
` mountPoint.appendChild(this.div) const save_button = this.div.querySelector("#save_button") document.addEventListener('boardchanged', (e) => this.setSaveButtonState(true)) this.save_button = save_button save_button.addEventListener('click', async (e) => { if (e.button !== 0) return await this.saveBoard() }) document.addEventListener('keydown', async e => { if (e.ctrlKey && e.code === 'KeyS') { e.preventDefault(); await this.saveBoard() } }); const addImageButton = this.div.querySelector("#add_image_button") addImageButton.addEventListener('click', async (e) => { const imageId = await fullScreenImageSelect() const block = board.display.addImage(imageId, { "x": 200, "y": 200 }) const onLoad = (e) => { block.internal.removeEventListener('load', onLoad) block.blockData.size = { 'x': block.internal.naturalWidth, 'y': block.internal.naturalHeight } block.applyTransform() } block.internal.addEventListener('load', onLoad) }) const addTextButton = this.div.querySelector("#add_text_button") addTextButton.addEventListener('click', async (e) => { const block = board.display.addText('', { "x": 200, "y": 200 }) }) } onSelected(block) { if (block.blockData.type === 'image') { this.editor = new ImageEditor(this.div, block) } else if (block.blockData.type === 'text') { this.editor = new TextEditor(this.div, block) } } onUnselected(block) { if (this.editor) this.editor.destroy() this.editor = null } setSaveButtonState(needToSave) { if (needToSave) { this.save_button.classList.remove('boards_sidebar__button') this.save_button.classList.add('boards_sidebar__save_button') } else { this.save_button.classList.add('boards_sidebar__button') this.save_button.classList.remove('boards_sidebar__save_button') } } async saveBoard() { const newBoard = { "id": markdownData.id, "name": markdownData.name, "blocks": board.display.blocks.map(it => it.getBlockData()) } const response = await fetch(`/api/v1/boards/${markdownData.id}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, method: "PUT", body: JSON.stringify(newBoard) }) const result = await response.text(); console.log('result', result) this.setSaveButtonState(false) } } class Display { constructor(mountPoint) { this.div = document.createElement('div') this.div.classList.add('boards_display') mountPoint.appendChild(this.div) globalTransform.display = this.div this.blocks = [] for (const block of markdownData.blocks) { this.blocks.push(new Block(this.div, block)) } this.div.addEventListener("click", (e) => { if (e.button !== 0) return if (!selectedBlock) return selectedBlock.makeUnselected(); selectedBlock = null }) this.boundByAllObjects() document.addEventListener("keypress", (e) => { if (e.key === '1') { this.boundByAllObjects() } else if (e.key === "Delete") { this.blocks = this.blocks.filter(it => it !== selectedBlock) selectedBlock.destroy() selectedBlock = null } }); document.addEventListener("blockselected", (e) => this.onBlockSelected(e.detail.block)); document.addEventListener("paste", async (e) => await this.onPaste(e)) this.initMovement() // this.initDrawing() } initDrawing() { this.points = [] this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svg.setAttribute('stroke-linecap', "round") this.svg.setAttribute('stroke-linejoin', "round") this.svg.innerHTML = `` this.path = this.svg.querySelector('path') this.div.appendChild(this.svg) this.drawingMode = true this.isDrawing = false let x let y this.onMouseDown = e => { if (e.button !== 0 || !this.drawingMode) { return } e.stopPropagation() this.points = [e.x - this.div.offsetLeft, e.y - this.div.offsetTop] x = e.x - this.div.offsetLeft y = e.y - this.div.offsetTop this.isDrawing = true } this.onMouseMove = e => { if (!this.isDrawing) return e.stopPropagation() const newX = e.x - this.div.offsetLeft const newY = e.y - this.div.offsetTop if (Math.sqrt((x - newX) * (x - newX) + (y - newY) * (y - newY)) > 0) { this.points.push(newX) this.points.push(newY) x = newX y = newY } this.renderSvg(this.points, 'rgb(255,0,0)', 15) } this.onMouseUp = e => { if (e.button !== 0) return e.stopPropagation() this.isDrawing = false this.drawingMode = false console.log('this.points', this.points.length) } this.div.addEventListener('mousedown', this.onMouseDown) document.addEventListener('mousemove', this.onMouseMove) document.addEventListener('mouseup', this.onMouseUp) } renderSvg(points, color, width) { if (points.length < 4) return const aabb = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity } for (let i = 0; i < points.length; i += 2) { const x = points[i] const y = points[i + 1] if (x < aabb.x0) aabb.x0 = x if (x > aabb.x1) aabb.x1 = x if (y < aabb.y0) aabb.y0 = y if (y > aabb.y1) aabb.y1 = y } const w = aabb.x1 - aabb.x0 + width * 2 const h = aabb.y1 - aabb.y0 + width * 2 this.svg.setAttribute('xmlns', "http://www.w3.org/2000/svg") this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`) this.svg.setAttribute('width', `${w}px`) this.svg.setAttribute('height', `${h}px`) this.svg.classList.add('boards_display__block_lines') this.svg.style.left = toPixels(aabb.x0 - width) this.svg.style.top = toPixels(aabb.y0 - width) let pathD = `M ${this.points[0] - aabb.x0 + width} ${this.points[1] - aabb.y0 + width} Q` for (let i = 2; i < this.points.length; i += 2) { pathD += ' ' + (this.points[i] - aabb.x0 + width) pathD += ' ' + (this.points[i + 1] - aabb.y0 + width) } if (this.points.length % 4 === 0) { pathD += ' ' + (this.points[this.points.length - 2] - aabb.x0 + width) + ' ' + (this.points[this.points.length - 1] - aabb.y0 + width) } this.path.setAttribute('d', pathD); this.path.setAttribute('stroke', color); this.path.setAttribute('stroke-width', `${width}`); } initMovement() { this.isPanning = false this.onMouseDown = e => { if (e.button !== 1) { return } this.isPanning = true } this.onMouseMove = e => { if (!this.isPanning) return globalTransform.movePixels(e.movementX, e.movementY) } this.onMouseUp = e => { if (e.button !== 1) return this.isPanning = false } this.onWheel = e => { globalTransform.addScale( e.deltaY * -0.001, e.pageX - this.div.offsetLeft, e.pageY - this.div.offsetTop ) } this.div.addEventListener('wheel', this.onWheel) this.div.addEventListener('mousedown', this.onMouseDown) document.addEventListener('mousemove', this.onMouseMove) document.addEventListener('mouseup', this.onMouseUp) } boundByAllObjects() { const aabb = calculateAABB(this.blocks) globalTransform.boundAABB(aabb) } onBlockSelected(block) { selectedBlock = block for (const b of this.blocks) { if (b !== block) { b.makeUnselected(); } } } async onPaste(e) { for (const file of e.clipboardData.files) { if (!file.type.startsWith('image/')) return; const response = await fetch(`/api/v1/images/${file.name}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, method: "PUT", body: await file.arrayBuffer() }) const imageId = (await response.json()).image; const size = await imageSize(file) this.addImage(imageId, size) } } addText(text, size) { const blockData = { "block_id": generateRandomId(), "type": "text", "position": { "x": 0, "y": 0 }, "size": size, "text": text, } const block = new Block(this.div, blockData) this.blocks.push(block) return block } addImage(imageId, size) { const blockData = { "block_id": generateRandomId(), "type": "image", "position": { "x": 0, "y": 0 }, "size": size, "image_id": imageId, } const block = new Block(this.div, blockData) this.blocks.push(block) return block } } class ImageEditor { constructor(mountPoint, block) { this.div = document.createElement('div') this.div.innerHTML = `
${block.blockData.image_id}
` this.fixAspectButton = document.createElement('div') this.fixAspectButton.innerHTML = "Fix aspect" this.fixAspectButton.classList.add('boards_sidebar__button') this.onFixAspect = (e) => { if (!block) return; block.fixAspect() } this.fixAspectButton.addEventListener('click', this.onFixAspect) this.div.appendChild(this.fixAspectButton) mountPoint.appendChild(this.div) } destroy() { this.div.remove() this.fixAspectButton.removeEventListener('click', this.onFixAspect) } } class TextEditor { constructor(mountPoint, block) { this.div = document.createElement('div') this.div.innerHTML = `
Text
` this.editor = document.createElement('div') this.editor.innerHTML = block.blockData.text this.editor.contentEditable = "true" this.editor.classList.add('boards_sidebar__text_editor') this.onInput = (e) => { if (!block) return; block.setText(this.editor.innerHTML) } this.editor.addEventListener('input', this.onInput) this.div.appendChild(this.editor) mountPoint.appendChild(this.div) } destroy() { this.editor.removeEventListener('input', this.onInput) this.div.remove() } }