Last active
December 19, 2024 04:51
-
-
Save tonyfast/58beefac5de6606fd23da2bbb7b8c069 to your computer and use it in GitHub Desktop.
collaborative
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { select, drag, local } from 'd3'; | |
import slugify from "slugify"; | |
import { v4 as uuidv4 } from 'uuid'; | |
import hljs from 'highlight.js'; | |
import markdownit from "markdown-it"; | |
import footnote_plugin from "markdown-it-footnote" | |
import deflist_plugin from "markdown-it-deflist" | |
import { basicSetup } from "codemirror" | |
import { EditorView, keymap } from "@codemirror/view" | |
import { indentWithTab } from "@codemirror/commands" | |
import * as Y from 'yjs' | |
import { yCollab } from 'y-codemirror.next' | |
import { WebrtcProvider } from 'y-webrtc' | |
const HEADINGS = "table.cells h1,h2,h3,h4,h5,h6,[role=heading]"; | |
// class is not an idref but we class elements based on their parent document | |
const IDREF_SELECTORS = ["id", "aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "form", "name", "class"]; | |
const IDREF_SELECTOR = IDREF_SELECTORS.map(x => `[${x}]`).join(","); | |
const CELL_TYPES = ["code", "markdown", "raw"] | |
const OUTPUT_TYPES = ["display_data", "execute_result"] | |
const LITERATE = /^\s*%{2}\s+/m; | |
class Templates { | |
constructor(document) { | |
this.document = document.node(); | |
this.cells_body = this.document.getElementById("tpl:cell-row").content.querySelector("tbody"); | |
this.docs_row = this.document.getElementById("tpl:doc.meta").content.querySelector("tr"); | |
this.cell_row = this.document.getElementById("tpl:cell-row").content.querySelector("tr"); | |
this.cell_output = this.document.getElementById("tpl:output").content.querySelector("table"); | |
this.cell_output_row = this.document.getElementById("tpl:output-row").content.querySelector("tr"); | |
this.toc_row = this.document.getElementById("tpl:toc").content.querySelector("tr"); | |
this.toc_body = this.document.getElementById("tpl:toc").content.querySelector("tbody"); | |
} | |
new_cells_body() { return this.cells_body.cloneNode() } | |
new_cell_row() { return this.cell_row.cloneNode(true) } | |
new_docs_row() { return this.docs_row.cloneNode(true) } | |
new_cell_output() { return this.cell_output.cloneNode(true) } | |
new_cell_output_row() { return this.cell_output_row.cloneNode(true) } | |
new_toc_row() { return this.toc_row.cloneNode(true) } | |
new_toc_body() { return this.toc_row.cloneNode() } | |
static new_markdown_cell(source = "", cell = {}) { | |
return { | |
cell_type: "markdown", | |
id: uuidv4(), | |
metadata: {}, | |
source: source.split("\n"), | |
...cell | |
} | |
} | |
static new_code_cell(source = "", cell = {}) { | |
return { ...this.new_markdown_cell(source), cell_type: "code", outputs: [], ...cell } | |
} | |
static new_notebook(cells = [], metadata = {}, nb) { | |
return { cells: cells, metadata: metadata } | |
} | |
} | |
export default class Notebook { | |
constructor(document) { | |
document = document.node === undefined ? select(document) : document; | |
this.document = document | |
let section = this.document.select("section"); | |
let nb = { cells: [] } | |
if (section.datum() === undefined) { | |
let nbformat = section.select("script.nb").text(); | |
if (nbformat) { | |
nb = JSON.parse(nbformat); | |
section.datum(nb) | |
} | |
} | |
this.templates = new Templates(document) | |
// an array holding the order of documents | |
this.order = new Array() | |
this.cells = new Map() | |
this.md_env = {} | |
this.appendix = { | |
references: Templates.new_markdown_cell("", { id: "references" }), | |
equations: Templates.new_markdown_cell("", { id: "equations" }), | |
supplementary: Templates.new_markdown_cell("", { id: "supplementary" }), | |
} | |
} | |
// update the class attributes then build the interface from the worked values. | |
// uses arguments to build the document | |
updateDocumentSummary(selection) { | |
if (!selection.size()) { return } | |
let d = selection.datum() | |
selection.attr("id", d.id); | |
selection.select(".id").text(d.id) | |
selection.select(".path").text(d.metadata?.path) | |
selection.select(".all.cells>output").text(d.cells.length) | |
selection.select(".markdown.cells>output").text(d.cells.filter(d => d.cell_type == "markdown").length) | |
selection.select(".code.cells>output").text(d.cells.filter(d => d.cell_type == "code").length) | |
} | |
fromDom() { | |
docs = Array(); | |
for (const body of document.querySelectorAll("table.cells>tbody")) { | |
let doc = Templates.new_notebook(); | |
docs.push(doc) | |
for (const row of body.querySelectorAll("&>tr.cell")) { | |
let cell = Notebook.fromDomCell(row); | |
doc.cells.push(cell) | |
} | |
} | |
} | |
static fromDomCell(row) { | |
let cell = Object.fromEntries(new FormData(row.node().querySelector("form"))); | |
cell.source = cell.source.split("\n").map(s => s + "\n") | |
if (cell.cell_type == "code") { | |
cell.outputs = Array() | |
for (const output_body of row.querySelectorAll("table.outputs>tbody")) { | |
for (const entry of output_body.querySelectorAll("&>tr")) { | |
} | |
} | |
} | |
return cell | |
} | |
update() { | |
let self = this; | |
let section = this.document.select("section"); | |
let documents = Object.values(arguments).map((d) => { return { id: uuidv4(), ...d } }); | |
let order = Array() | |
let args = Array() | |
if (!arguments) { | |
args = this.document.node().querySelectorAll("table#docs>tbody").map( | |
select | |
).map(s => s.datum()) | |
} | |
console.error(args) | |
for (const nb of arguments || args) { | |
let local_order = Array(); | |
order.push(local_order) | |
for (const cell of nb.cells) { | |
cell.id = cell.id || uuidv4(); | |
self.cells.set(cell.id, cell); | |
local_order.push(cell.id) | |
} | |
} | |
self.order.length = order.length | |
for (const [i, d] of order.entries()) { | |
self.order[i] = d | |
} | |
// everything should be a notebook by the time it reaches here. | |
// lets assume we are getting the whole document in the arguments | |
// the command line tool would provide that. | |
// when the cli runs we need to boostrap the document data by inferring a document format from the html | |
section.select("table#docs>tbody").selectChildren("tr").data( | |
documents | |
).join( | |
enter => enter.append((d, i) => { | |
let doc = select(self.templates.new_docs_row()).datum(d); | |
self.updateDocumentSummary(doc) | |
return doc.node() | |
}), | |
update => self.updateDocumentSummary(update) | |
) | |
// iterate over each document and build all the cells | |
section.select("table.cells").selectChildren("tbody").data( | |
self.order | |
).join( | |
enter => self.enterDocument(enter), | |
update => self.updateDocument(update), | |
exit => exit.remove() | |
) | |
let foot = section.select("table#docs>tfoot"); | |
foot.select(".all.cells>output").text(this.document.selectAll("tbody.cells>tr.cell").size()) | |
foot.select(".markdown.cells>output").text(this.document.selectAll("tbody.cells>tr.cell.markdown:not(.code)").size()) | |
foot.select(".code.cells>output").text(this.document.selectAll("tbody.cells>tr.cell.code").size()) | |
this.exitDocuments() | |
return this | |
} | |
enterDocument(selection) { | |
let self = this; | |
selection.insert((d, i) => { | |
let selection = select(self.templates.new_cells_body()).datum(d); | |
self.updateDocument(selection) | |
return selection.node() | |
}, "tfoot") | |
} | |
updateDocument(selection) { | |
let self = this; | |
if (!selection.size()) { return } | |
selection.selectChildren("tr").data( | |
selection.datum().map(self.cells.get.bind(self.cells)).map( | |
d => { return { doc: selection.datum().id, ...d } } | |
), this.cellsKey | |
).join( | |
enter => self.enterCells(enter), | |
update => self.updateCell(update), | |
exit => exit.remove() | |
) | |
} | |
enterDocumentMeta(selection) { | |
let self = this; | |
selection.append((d, i) => { | |
let document = select(self.templates.new_docs_row()).datum(d); | |
}) | |
} | |
exitDocuments() { | |
let document = this.document; | |
let self = this; | |
document.select("table.cells>tfoot.cells").selectAll("tr").data( | |
Object.values(this.appendix) | |
).join( | |
enter => self.enterCells(enter), | |
update => self.updateCell(update), | |
exit => exit.remove() | |
) | |
Array.from(document.selectAll(HEADINGS)).forEach(node => headingToId(select(node), document.node())) | |
this.enterToc() | |
this.enterSummary() | |
document.select("a.perma").attr("id", "h1") | |
} | |
enterCells(selection) { | |
let self = this; | |
selection.append( | |
(cell, i) => { | |
let selection = select(self.templates.new_cell_row()) | |
.datum(Object.assign({}, cell, { position: i })); | |
self.enterCell(selection); | |
return selection.node() | |
} | |
); | |
} | |
enterCell(cell_row) { | |
let cell = cell_row.datum(); | |
cell_row.classed(cell.doc, true) | |
cell_row.select("th.id>input").attr("value", cell.id); | |
this.enterCellIds(cell_row) | |
this.updateCell(cell_row) | |
if (cell.cell_type == "markdown") { | |
cell.outputs = [{ output_type: "display_data", data: { "text/markdown": cell.source } }] | |
} | |
cell.outputs?.length > 0 && this.enterOutputs(cell_row) | |
} | |
updateCell(cell_row) { | |
console.error(22, cell_row.node()) | |
if (!cell_row.node()) { return } | |
let cell = cell_row.datum(); | |
let i = cell.position; | |
cell_row.attr("aria-posinset", i + 1) | |
cell_row.select("th.doc").select("a").text(cell.doc).attr("href", `#${cell.doc}`); | |
cell_row.select("th.pos").select("a").text(i + 1).attr("href", `#c${i + 1}`).attr("id", `c${i + 1}`); | |
if (!cell_row.classed(cell.cell_type)) { | |
CELL_TYPES.forEach(t => cell_row.classed(t, cell.cell_type == t)); | |
cell_row.select("td.cell_type>label").text(cell.cell_type) | |
cell_row.select(`option[value="${cell.cell_type}"]`).attr("selected", "") | |
} | |
cell.execution_count && cell_row.select("td.execution_count>label>output").text(cell.execution_count); | |
let source = cell.source.join("") | |
let loc = cell.source.filter(Boolean).length | |
cell_row.classed("markdown", cell.cell_type == "markdown" || Boolean(source.match(LITERATE))) | |
cell_row.select(".loc>output").text(loc); | |
cell_row.attr("data-loc", loc) | |
cell_row.select("td.source>textarea").text(source) | |
cell_row.classed("empty", !Boolean(source.trimRight())) | |
this.updateOutputs(cell_row) | |
} | |
enterOutputs(selection) { | |
let self = this; | |
selection.select("td.outputs").append( | |
d => self.templates.new_cell_output() | |
) | |
self.updateOutputs(selection) | |
} | |
updateOutputs(selection) { | |
let cell = selection.datum(); | |
let self = this; | |
selection.select("td.outputs table.outputs").selectChildren("tbody").data( | |
selection.datum().outputs || [] | |
).join("tbody").selectChildren("tr").data( | |
d => d.data ? Object.entries(d.data).map(x => x.concat([d.metadata])) : [d], | |
(d, i) => self.outputKey(cell, d, i) | |
).join( | |
(enter) => enter.append( | |
(output, i) => { | |
let selection = select(self.templates.new_cell_output_row()) | |
.datum(output); | |
this.enterOutput(selection); | |
return selection.node() | |
}), | |
(update) => self.enterOutput(update), // updateOutput method that will be used with markdown cells to avoid extra work | |
exit => exit.remove() | |
) | |
} | |
enterOutput(output_row) { | |
if (!output_row.node()) { return } | |
let output = output_row.datum(); | |
if (!output.output_type) { | |
this.enterOutputBundle(output_row) | |
} | |
} | |
updateOutput(output_row) { } | |
enterOutputBundle(output_row) { | |
let [type, bundle, metadata] = output_row.datum(); | |
output_row.select("td.output_type>label").text(type); | |
if (type == "text/html") { | |
output_row.select("td.data").html(bundle.join("")) | |
} else if (type == "text/markdown") { | |
let data = output_row.select("td.data"); | |
let body = bundle.join(""); | |
data.html("") | |
data.html(this.markdownify(body)).call(replaceAttachments) | |
// this lets us retrieve the source | |
// data.append("script").attr("type", type).text(body) | |
} else if (type == "text/plain") { | |
output_row.select("td.data").append("samp").text(bundle.join("")) | |
} else if (type.startsWith("image")) { | |
output_row.select("td.data").append("img") | |
.attr("src", `data:${type};base64,${bundle.join("")}`); | |
} | |
} | |
enterCellIds(cell_row) { | |
let cell_id = cell_row.datum().id; | |
[cell_row.node()].concat(Array.from(cell_row.selectAll(IDREF_SELECTOR))).map(select).forEach( | |
(node) => { | |
for (const ref of IDREF_SELECTORS) { | |
let refs = node.attr(ref); | |
if (!refs) { continue } | |
refs = refs.split(" "); | |
let idref = "" | |
for (let [j, label] of refs.entries()) { | |
idref += idref ? " " : ""; | |
if (label.startsWith(":")) { idref += cell_id } | |
else if (label == "#") { label = cell_id } | |
else if (label == ":") { label = cell_id } | |
idref += label; | |
} | |
node.attr(ref, idref) | |
} | |
}) | |
} | |
enterToc() { | |
let self = this; | |
self.document.select("section table.toc").selectAll("tbody").data( | |
Array.from(self.document.node().querySelectorAll("tbody.cells")) | |
).join( | |
enter => { | |
enter.append( | |
(document, i) => { | |
let body = select(self.templates.new_toc_body()); | |
body.selectAll("tr").data( | |
document.querySelectorAll(HEADINGS).map( | |
(element) => { | |
return { | |
heading: element.innerText, | |
cell: Number(element.closest("tr.cell[aria-posinset]").getAttribute("aria-posinset")), | |
level: Number(element.tagName[1]), | |
id: element.getAttribute("id") | |
} | |
} | |
) | |
).join( | |
(enter) => { | |
enter.append( | |
(entry, i) => { | |
let toc_row = select(self.templates.toc_row.cloneNode(true)); | |
toc_row.select(".level").text(entry.level) | |
toc_row.select(".heading > a").text(entry.heading).attr("href", `#${entry.id}:`) | |
toc_row.select(".cell > a").text(entry.cell).attr("href", `#c${entry.cell}`); | |
return toc_row.node() | |
} | |
); | |
} | |
) | |
return body.node() | |
} | |
) | |
} | |
) | |
} | |
enterSummary() { | |
let document = this.document; | |
let nb = document.select("section").datum(); | |
// let loc = Array.from(document.selectAll(".cell[data-loc]")).map( | |
// (element) => { | |
// return Number(element.dataset.loc) | |
// } | |
// ).reduce((a, b) => a + b) | |
// document.select("#cells\\:loc").text(loc) | |
// document.select("#cells\\:total").text(nb.cells.length) | |
// document.select("#cells\\:md").text(nb.cells.filter(x => x.cell_type == "markdown").length) | |
// document.select("#cells\\:code").text(nb.cells.filter(x => x.cell_type == "code").length) | |
// document.select("#cells\\:outputs").text( | |
// nb.cells.filter(x => x.outputs).reduce((p, n) => p + n.outputs.length, 0) | |
// ) | |
// let current_execution_count = 0, monotonic = true, partial = false; | |
// for (const cell of nb.cells) { | |
// let trivial = cell.source.map(x => x.trim()).filter(Boolean).length; | |
// if (trivial) { | |
// if (cell.execution_count) { monotonic = false; break } | |
// continue | |
// } | |
// if (cell.cell_type == "code" && cell.execution_count) { | |
// if (cell.execution_count == current_execution_count + 1) { | |
// current_execution_count += 1 | |
// } else { | |
// monotonic = false | |
// break | |
// } | |
// } else { | |
// partial = true | |
// } | |
// } | |
// let state = monotonic ? "executed in order" : "executed out of order"; | |
// state = partial ? "partially " + state : state; | |
// document.select("#cells\\:state").text(state) | |
} | |
cellsKey(d, i) { return d == undefined ? i : d.id } | |
outputKey(cell, d, i) { return [cell.execution_count, i] } | |
markdownify(body) { | |
let md = markdownit({ | |
html: true, | |
linkify: true, | |
highlight: function (str, lang) { | |
if (lang && hljs.getLanguage(lang)) { | |
try { | |
return hljs.highlight(str, { language: lang }).value; | |
} catch (__) { } | |
} | |
return ''; // use external default escaping | |
} | |
}).use(footnote_plugin).use(deflist_plugin).render(body, self.md_env) | |
return md | |
} | |
} | |
function replaceAttachments(selection) { | |
let cell = selection.node()?.closest("tr.cell")?.getAttribute("__data__"); | |
if (!cell) { return } | |
select.selectAll("img").each( | |
(_, j, nodes) => { | |
let img = select(nodes[j]); | |
if (cell.attachments && img.attr("src").startsWith("attachment:")) { | |
let [_, attachment] = img.attr("src").split(":", 2); | |
let bundle = cell.attachments[attachment]; | |
if (bundle) { | |
for ([type, bundle] of Object.entries(bundle)) { | |
img.attr("src", `data:${type};base64,${bundle}`) | |
break | |
} | |
} | |
} | |
} | |
) | |
} | |
function headingToId(heading, document) { | |
let id = heading.attr("id") || slugify(heading.text()); | |
heading.attr("id", id) | |
let a = document.createElement("a") | |
select(a).attr("href", `#${id}:`).attr("id", `${id}:`).attr("aria-labelledby", id).text("🔗").classed("perma", true) | |
let h = heading.node(); | |
// it makes sense for the link to be before the header. | |
// mvoing forward int eh document confirms the location. | |
h.insertBefore(a, h) | |
} | |
globalThis.d3 = { select: select, drag: drag }; | |
globalThis.Notebook = Notebook | |
function onSubmit(event) { | |
event.preventDefault(); | |
let cell_row = event.target.closest("tr.cell"); | |
let cell = Notebook.fromDomCell(select(cell_row)) | |
if (cell.cell_type == "markdown") { | |
cell.outputs = [{ output_type: "display_data", data: { "text/markdown": cell.source } }] | |
console.log("md", cell) | |
nb.updateCell(select(cell_row).datum(cell)) | |
} else if (document.getElementById("BODY").dataset.runtime == "html") { | |
cell.outputs = [{ | |
output_type: "display_data", data: { | |
"text/html": cell.source | |
}, metadata: {} | |
}] | |
nb.updateCell(select(event.target).datum(cell)) | |
} | |
} | |
function getNextCell(cell) { | |
if (cell.nextElementSibling) { | |
return cell.nextElementSibling | |
} else if (cell.parentElement.nextElementSibling) { | |
let next = cell.parentElement.nextElementSibling.querySelector("tr"); | |
if (next) { return next } | |
} | |
return cell | |
} | |
function textKeyboard(event) { | |
console.log("ev") | |
if ((event.keyCode == 10 || event.keyCode == 13) && event.ctrlKey) { | |
event.target.form.requestSubmit(); | |
} else if ((event.keyCode == 10 || event.keyCode == 13) && event.shiftKey) { | |
getNextCell(event.target.form.closest("tr.cell")).querySelector("textarea").focus() | |
event.target.form.requestSubmit(); | |
} else if (event.keyCode == 27) { | |
event.target.blur() | |
} | |
} | |
try { | |
globalThis.nb = new Notebook(document); | |
document.getElementById("BODY").querySelectorAll("tr.cell").forEach( | |
(element) => { | |
element.querySelector("form").onsubmit = onSubmit | |
element.querySelector("textarea").onkeyup = textKeyboard | |
} | |
) | |
} catch (error) { } | |
try { | |
document; | |
const ydoc = new Y.Doc() | |
const provider = new WebrtcProvider('codemirror-demo-room', ydoc) | |
document.querySelectorAll("tr.cell>td.source").forEach( | |
(parent) => { | |
let textarea = parent.querySelector("textarea"); | |
let updateListenerExtension = EditorView.updateListener.of((update) => { | |
let textarea = parent.querySelector("textarea"); | |
if (update.docChanged) { | |
textarea.innerHTML = update.state.doc.toString(); | |
} | |
}); | |
textarea.style.display = "none"; | |
let yText = ydoc.getText(textarea.value); | |
let yUndoManager = new Y.UndoManager(yText) | |
let editor = new EditorView({ | |
doc: textarea.value, | |
extensions: [ | |
// basicSetup, | |
updateListenerExtension, | |
keymap.of([indentWithTab]), | |
yCollab(yText, provider.awareness, { yUndoManager }) | |
], | |
parent: parent | |
}) | |
} | |
) | |
} catch (error) { console.error(error) } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment