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 = `
`
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 = `
`
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 = ``
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()
}
}