Set up markdown rendering (#16)

* Set up markdown rendering, update theme.

* Improve focus and handle expanding for markdown cells

* Add keybindings for expanding/navigating cells

* Improve editor autofocus when navigating with shortcuts

* Add tests

* Render markdown on the client

* Don't render cell initial data and make a request instead
This commit is contained in:
Jonatan Kłosko 2021-01-30 00:33:04 +01:00 committed by GitHub
parent ca36e22af0
commit 936d0af5fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1054 additions and 143 deletions

View file

@ -15,3 +15,109 @@ button:focus {
iframe[hidden] {
display: none;
}
/* Markdown rendered content */
.markdown {
@apply text-gray-700;
}
.markdown h1 {
@apply text-gray-900 font-semibold text-3xl my-4;
}
.markdown h2 {
@apply text-gray-900 font-semibold text-2xl my-4;
}
.markdown h3 {
@apply text-gray-900 font-semibold text-xl my-4;
}
.markdown p {
@apply my-4;
}
.markdown ul {
@apply list-disc list-inside my-4;
}
.markdown ol {
@apply list-decimal list-inside my-4;
}
.markdown ul > li,
.markdown ol > li {
@apply my-1;
}
.markdown ul > li ul,
.markdown ol > li ol {
@apply ml-6;
}
.markdown blockquote {
@apply border-l-4 border-gray-200 pl-4 py-2 my-4 text-gray-500;
}
.markdown a {
@apply font-medium underline text-gray-900 hover:no-underline;
}
.markdown table {
@apply w-full my-4;
}
.markdown table thead tr {
@apply border-b border-gray-200;
}
.markdown table tbody tr:not(:last-child) {
@apply border-b border-gray-200;
}
.markdown table th {
@apply p-2 font-bold;
}
.markdown table td {
@apply p-2;
}
.markdown table th:first-child,
.markdown table td:first-child {
@apply pl-0;
}
.markdown table th:last-child,
.markdown table td:last-child {
@apply pr-0;
}
.markdown code {
@apply py-1 px-2 rounded text-sm align-middle;
/* Match the editor colors */
background-color: #282c34;
color: #abb2bf;
}
.markdown pre > code {
@apply block p-4 rounded text-sm align-middle;
/* Match the editor colors */
background-color: #282c34;
color: #abb2bf;
}
.markdown :first-child {
@apply mt-0;
}
.markdown :last-child {
@apply mb-0;
}
/* Other */
.bg-editor {
background-color: #282c34;
}

View file

@ -5,11 +5,13 @@ import { Socket } from "phoenix";
import NProgress from "nprogress";
import { LiveSocket } from "phoenix_live_view";
import ContentEditable from "./content_editable";
import Editor from "./editor";
import Cell from "./cell";
import Session from "./session";
const Hooks = {
ContentEditable,
Editor,
Cell,
Session,
};
const csrfToken = document

93
assets/js/cell/index.js Normal file
View file

@ -0,0 +1,93 @@
import {
getAttributeOrThrow,
parseBoolean,
parseInteger,
} from "../lib/attribute";
import LiveEditor from "./live_editor";
import Markdown from "./markdown";
/**
* A hook managing a single cell.
*
* Mounts and manages the collaborative editor,
* takes care of markdown rendering and focusing the editor when applicable.
*
* Configuration:
*
* * `data-cell-id` - id of the cell being edited
* * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected
* * `data-focused` - whether the cell is currently focused
* * `data-expanded` - whether the cell is currently expanded (relevant for markdown cells)
*/
const Cell = {
mounted() {
this.props = getProps(this);
this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => {
const { source, revision } = payload;
const editorContainer = this.el.querySelector("[data-editor-container]");
// Remove the content placeholder.
editorContainer.firstElementChild.remove();
// Create an empty container for the editor to be mounted in.
const editorElement = document.createElement("div");
editorContainer.appendChild(editorElement);
// Setup the editor instance.
this.liveEditor = new LiveEditor(
this,
editorElement,
this.props.cellId,
this.props.type,
source,
revision
);
// Setup markdown rendering.
if (this.props.type === "markdown") {
const markdownContainer = this.el.querySelector(
"[data-markdown-container]"
);
const markdown = new Markdown(markdownContainer, source);
this.liveEditor.onChange((newSource) => {
markdown.setContent(newSource);
});
}
});
},
updated() {
const prevProps = this.props;
this.props = getProps(this);
if (!isActive(prevProps) && isActive(this.props)) {
this.liveEditor.focus();
}
if (isActive(prevProps) && !isActive(this.props)) {
this.liveEditor.blur();
}
},
};
function getProps(hook) {
return {
cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
type: getAttributeOrThrow(hook.el, "data-type"),
isFocused: getAttributeOrThrow(hook.el, "data-focused", parseBoolean),
isExpanded: getAttributeOrThrow(hook.el, "data-expanded", parseBoolean),
};
}
/**
* Checks if the cell editor is active and should have focus.
*/
function isActive(props) {
if (props.type === "markdown") {
return props.isFocused && props.isExpanded;
} else {
return props.isFocused;
}
}
export default Cell;

View file

@ -0,0 +1,103 @@
import monaco from "./live_editor/monaco";
import EditorClient from "./live_editor/editor_client";
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
import HookServerAdapter from "./live_editor/hook_server_adapter";
/**
* Mounts cell source editor with real-time collaboration mechanism.
*/
class LiveEditor {
constructor(hook, container, cellId, type, source, revision) {
this.container = container;
this.cellId = cellId;
this.type = type;
this.source = source;
this._onChange = null;
this.__mountEditor();
const serverAdapter = new HookServerAdapter(hook, cellId);
const editorAdapter = new MonacoEditorAdapter(this.editor);
this.editorClient = new EditorClient(
serverAdapter,
editorAdapter,
revision
);
this.editorClient.onDelta((delta) => {
this.source = delta.applyToString(this.source);
this._onChange && this._onChange(this.source);
});
}
/**
* Registers a callback called with a new cell content whenever it changes.
*/
onChange(callback) {
this._onChange = callback;
}
focus() {
this.editor.focus();
}
blur() {
if (this.editor.hasTextFocus()) {
document.activeElement.blur();
}
}
__mountEditor() {
this.editor = monaco.editor.create(this.container, {
language: this.type,
value: this.source,
scrollbar: {
vertical: "hidden",
handleMouseWheel: false,
},
minimap: {
enabled: false,
},
overviewRulerLanes: 0,
scrollBeyondLastLine: false,
quickSuggestions: false,
renderIndentGuides: false,
occurrencesHighlight: false,
renderLineHighlight: "none",
theme: "custom",
});
this.editor.getModel().updateOptions({
tabSize: 2,
});
this.editor.updateOptions({
autoIndent: true,
tabSize: 2,
formatOnType: true,
});
// Automatically adjust the editor size to fit the container.
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
// Ignore hidden container.
if (this.container.offsetHeight > 0) {
this.editor.layout();
}
});
});
resizeObserver.observe(this.container);
// Whenever editor content size changes (new line is added/removed)
// update the container height. Thanks to the above observer
// the editor is resized to fill the container.
// Related: https://github.com/microsoft/monaco-editor/issues/794#issuecomment-688959283
this.editor.onDidContentSizeChange(() => {
const contentHeight = this.editor.getContentHeight();
this.container.style.height = `${contentHeight}px`;
});
}
}
export default LiveEditor;

View file

@ -26,9 +26,12 @@ export default class EditorClient {
this.editorAdapter = editorAdapter;
this.revision = revision;
this.state = new Synchronized(this);
this._onDelta = null;
this.editorAdapter.onDelta((delta) => {
this.__handleClientDelta(delta);
// This delta comes from the editor, so it has already been applied.
this.__emitDelta(delta);
});
this.serverAdapter.onDelta((delta) => {
@ -40,6 +43,20 @@ export default class EditorClient {
});
}
/**
* Registers a callback called with a every delta applied to the editor.
*
* These deltas are already transformed such that applying them
* one by one should eventually lead to the same state as on the server.
*/
onDelta(callback) {
this._onDelta = callback;
}
__emitDelta(delta) {
this._onDelta && this._onDelta(delta);
}
__handleClientDelta(delta) {
this.state = this.state.onClientDelta(delta);
}
@ -50,16 +67,18 @@ export default class EditorClient {
}
__handleServerAcknowledgement() {
this.revision++;
this.state = this.state.onServerAcknowledgement();
}
applyDelta(delta) {
this.editorAdapter.applyDelta(delta);
// This delta comes from the server and we have just applied it to the editor.
this.__emitDelta(delta);
}
sendDelta(delta) {
this.revision++;
this.serverAdapter.sendDelta(delta, this.revision);
this.serverAdapter.sendDelta(delta, this.revision + 1);
}
}

View file

@ -192,27 +192,34 @@ const ElixirMonarchLanguage = {
[
// In-scope call - an identifier followed by ( or .(
/(@variableName)(?=\s*\.?\s*\()/,
['function.call']
["function.call"],
],
[
// Referencing function in a module
/(@moduleName)(\s*)(\.)(\s*)(@variableName)/,
['type.identifier', 'white', 'operator', 'white', 'function.call']
["type.identifier", "white", "operator", "white", "function.call"],
],
[
// Referencing function in an Erlang module
/(:)(@atomName)(\s*)(\.)(\s*)(@variableName)/,
["constant.punctuation", "constant", 'white', 'operator', 'white', 'function.call']
[
"constant.punctuation",
"constant",
"white",
"operator",
"white",
"function.call",
],
],
[
// Piping into a function (tokenized separately as it may not have parentheses)
/(\|>)(\s*)(@variableName)/,
['operator', 'white', 'function.call']
["operator", "white", "function.call"],
],
[
// Function reference passed to another function
/(&)(\s*)(@variableName)/,
['operator', 'white', 'function.call']
["operator", "white", "function.call"],
],
// Language keywords, builtins, constants and variables
[

View file

@ -1,4 +1,4 @@
import Delta from "../lib/delta";
import Delta from "../../lib/delta";
/**
* Encapsulates logic related to sending/receiving messages from the server.
@ -39,7 +39,7 @@ export default class HookServerAdapter {
* Sends the given delta to the server.
*/
sendDelta(delta, revision) {
this.hook.pushEvent("cell_delta", {
this.hook.pushEvent("apply_cell_delta", {
cell_id: this.cellId,
delta: delta.toCompressed(),
revision,

View file

@ -0,0 +1,67 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import ElixirLanguageConfiguration from "./elixir/language_configuration";
import ElixirMonarchLanguage from "./elixir/monarch_language";
import ElixirOnTypeFormattingEditProvider from "./elixir/on_type_formatting_edit_provider";
// Register the Elixir language and add relevant configuration
monaco.languages.register({ id: "elixir" });
monaco.languages.setLanguageConfiguration(
"elixir",
ElixirLanguageConfiguration
);
monaco.languages.registerOnTypeFormattingEditProvider(
"elixir",
ElixirOnTypeFormattingEditProvider
);
monaco.languages.setMonarchTokensProvider("elixir", ElixirMonarchLanguage);
// Define custom theme
monaco.editor.defineTheme("custom", {
base: "vs-dark",
inherit: false,
rules: [
{ token: "", foreground: "#abb2bf" },
{ token: "variable", foreground: "#abb2bf" },
{ token: "constant", foreground: "#61afef" },
{ token: "constant.character.escape", foreground: "#61afef" },
{ token: "comment", foreground: "#5c6370" },
{ token: "number", foreground: "#61afef" },
{ token: "regexp", foreground: "#e06c75" },
{ token: "type", foreground: "#e06c75" },
{ token: "string", foreground: "#98c379" },
{ token: "keyword", foreground: "#c678dd" },
{ token: "operator", foreground: "#d19a66" },
{ token: "delimiter.bracket.embed", foreground: "#be5046" },
{ token: "sigil", foreground: "#56b6c2" },
{ token: "function", foreground: "#61afef" },
{ token: "function.call", foreground: "#abb2bf" },
// Markdown specific
{ token: "emphasis", fontStyle: "italic" },
{ token: "strong", fontStyle: "bold" },
{ token: "keyword.md", foreground: "#e06c75" },
{ token: "keyword.table", foreground: "#e06c75" },
{ token: "string.link.md", foreground: "#61afef" },
{ token: "variable.md", foreground: "#56b6c2" },
],
colors: {
"editor.background": "#282c34",
"editor.foreground": "#abb2bf",
"editorLineNumber.foreground": "#636d83",
"editorCursor.foreground": "#636d83",
"editor.selectionBackground": "#3e4451",
"editor.findMatchHighlightBackground": "#528bff3D",
"editorSuggestWidget.background": "#21252b",
"editorSuggestWidget.border": "#181a1f",
"editorSuggestWidget.selectedBackground": "#2c313a",
"input.background": "#1b1d23",
"input.border": "#181a1f",
},
});
export default monaco;

View file

@ -1,5 +1,5 @@
import monaco from "./monaco";
import Delta, { isDelete, isInsert, isRetain } from "../lib/delta";
import Delta, { isDelete, isInsert, isRetain } from "../../lib/delta";
/**
* Encapsulates logic related to getting/applying changes to the editor.

View file

@ -0,0 +1,44 @@
import marked from "marked";
import morphdom from "morphdom";
/**
* Renders markdown content in the given container.
*/
class Markdown {
constructor(container, content) {
this.container = container;
this.content = content;
this.__render();
}
setContent(content) {
this.content = content;
this.__render();
}
__render() {
const html = this.__getHtml();
// Wrap the HTML in another element, so that we
// can use morphdom's childrenOnly option.
const wrappedHtml = `<div>${html}</div>`;
morphdom(this.container, wrappedHtml, { childrenOnly: true });
}
__getHtml() {
const html = marked(this.content);
if (html) {
return html;
} else {
return `
<div class="text-gray-300">
Empty markdown cell
</div>
`;
}
}
}
export default Markdown;

View file

@ -1,3 +1,5 @@
import { getAttributeOrThrow } from "../lib/attribute";
/**
* A hook used on [contenteditable] elements to update the specified
* attribute with the element text.
@ -8,7 +10,7 @@
*/
const ContentEditable = {
mounted() {
this.attribute = this.el.dataset.updateAttribute;
this.props = getProps(this);
this.__updateAttribute();
@ -26,14 +28,22 @@ const ContentEditable = {
},
updated() {
this.props = getProps(this);
// The element has been re-rendered so we have to add the attribute back
this.__updateAttribute();
},
__updateAttribute() {
const value = this.el.innerText.trim();
this.el.setAttribute(this.attribute, value);
this.el.setAttribute(this.props.attribute, value);
},
};
function getProps(hook) {
return {
attribute: getAttributeOrThrow(hook.el, "data-update-attribute"),
};
}
export default ContentEditable;

View file

@ -1,7 +1,12 @@
import monaco from "./monaco";
import EditorClient from "./editor_client";
import MonacoEditorAdapter from "./monaco_editor_adapter";
import monaco from "../cell/live_editor/monaco";
import EditorClient from "../cell/live_editor/editor_client";
import MonacoEditorAdapter from "../cell/live_editor/monaco_editor_adapter";
import HookServerAdapter from "./hook_server_adapter";
import {
getAttributeOrThrow,
parseBoolean,
parseInteger,
} from "../lib/attribute";
/**
* A hook managing an editable cell.
@ -14,38 +19,67 @@ import HookServerAdapter from "./hook_server_adapter";
*
* * `data-cell-id` - id of the cell being edited
* * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected
* * `data-hidden` - whether this editor is currently hidden
* * `data-active` - whether this editor is currently the active one
*
* Additionally the root element should have a direct `div` child
* with `data-source` and `data-revision` providing the initial values.
*/
const Editor = {
mounted() {
this.cellId = this.el.dataset.cellId;
this.type = this.el.dataset.type;
this.props = getProps(this);
const editorContainer = this.el.querySelector("div");
this.editorContainer = this.el.querySelector("div");
if (!editorContainer) {
if (!this.editorContainer) {
throw new Error("Editor Hook root element should have a div child");
}
const source = editorContainer.dataset.source;
const revision = +editorContainer.dataset.revision;
// Remove the content placeholder
this.editorContainer.firstElementChild.remove();
this.editor = this.__mountEditor(editorContainer);
this.__mountEditor();
const source = getAttributeOrThrow(this.editorContainer, "data-source");
const revision = getAttributeOrThrow(
this.editorContainer,
"data-revision",
parseInteger
);
this.editor.getModel().setValue(source);
new EditorClient(
new HookServerAdapter(this, this.cellId),
new HookServerAdapter(this, this.props.cellId),
new MonacoEditorAdapter(this.editor),
revision
);
},
__mountEditor(editorContainer) {
const editor = monaco.editor.create(editorContainer, {
language: this.type,
updated() {
const prevProps = this.props;
this.props = getProps(this);
if (prevProps.isHidden && !this.props.isHidden) {
// If the editor was created as hidden it didn't get the chance
// to properly adjust to the available space, so trigger it now.
this.__adjustEditorLayout();
}
if (!prevProps.isActive && this.props.isActive) {
this.editor.focus();
}
if (prevProps.isActive && !this.props.isActive) {
if (this.editor.hasTextFocus()) {
document.activeElement.blur();
}
}
},
__mountEditor() {
this.editor = monaco.editor.create(this.editorContainer, {
language: this.props.type,
value: "",
scrollbar: {
vertical: "hidden",
@ -63,32 +97,39 @@ const Editor = {
theme: "custom",
});
editor.getModel().updateOptions({
this.editor.getModel().updateOptions({
tabSize: 2,
});
editor.updateOptions({
this.editor.updateOptions({
autoIndent: true,
tabSize: 2,
formatOnType: true,
});
// Dynamically adjust editor height to the content, see https://github.com/microsoft/monaco-editor/issues/794
function adjustEditorLayout() {
const contentHeight = editor.getContentHeight();
editorContainer.style.height = `${contentHeight}px`;
editor.layout();
}
editor.onDidContentSizeChange(adjustEditorLayout);
adjustEditorLayout();
this.editor.onDidContentSizeChange(() => this.__adjustEditorLayout());
this.__adjustEditorLayout();
window.addEventListener("resize", (event) => {
editor.layout();
this.editor.layout();
});
},
return editor;
__adjustEditorLayout() {
// Dynamically adjust editor height to the content, see https://github.com/microsoft/monaco-editor/issues/794
const contentHeight = this.editor.getContentHeight();
this.editorContainer.style.height = `${contentHeight}px`;
this.editor.layout();
},
};
function getProps(hook) {
return {
cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
type: getAttributeOrThrow(hook.el, "data-type"),
isHidden: getAttributeOrThrow(hook.el, "data-hidden", parseBoolean),
isActive: getAttributeOrThrow(hook.el, "data-active", parseBoolean),
};
}
export default Editor;

View file

@ -1,59 +0,0 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import ElixirLanguageConfiguration from "./elixir/language_configuration";
import ElixirMonarchLanguage from "./elixir/monarch_language";
import ElixirOnTypeFormattingEditProvider from "./elixir/on_type_formatting_edit_provider";
// Register the Elixir language and add relevant configuration
monaco.languages.register({ id: "elixir" });
monaco.languages.setLanguageConfiguration(
"elixir",
ElixirLanguageConfiguration
);
monaco.languages.registerOnTypeFormattingEditProvider(
"elixir",
ElixirOnTypeFormattingEditProvider
);
monaco.languages.setMonarchTokensProvider("elixir", ElixirMonarchLanguage);
// Define custom theme
monaco.editor.defineTheme("custom", {
base: "vs",
inherit: false,
rules: [
{ token: "", foreground: "#444444" },
{ token: "variable", foreground: "#ca4956" },
{ token: "constant", foreground: "#3c91cf" },
{ token: "constant.character.escape", foreground: "#3c91cf" },
{ token: "comment", foreground: "#9e9e9e" },
{ token: "number", foreground: "#bf8b56" },
{ token: "regexp", foreground: "#ca4956" },
{ token: "type", foreground: "#ca4956" },
{ token: "string", foreground: "#50a14f" },
{ token: "keyword", foreground: "#9c00b0" },
{ token: "operator", foreground: "#cc5c52" },
{ token: "delimiter.bracket.embed", foreground: "#204a87" },
{ token: "sigil", foreground: "#bf8b56" },
{ token: "function", foreground: "#3c91cf" },
{ token: "function.call", foreground: "#444444" },
// Markdown specific
{ token: "emphasis", fontStyle: "italic" },
{ token: "strong", fontStyle: "bold" },
{ token: "keyword.md", foreground: "#ca4956" },
{ token: "keyword.table", foreground: "#ca4956" },
{ token: "string.link.md", foreground: "#3c91cf" },
{ token: "variable.md", foreground: "#204a87" },
],
colors: {
"editor.background": "#fafafa",
"editorLineNumber.foreground": "#cfd8dc",
"editorCursor.foreground": "#666666",
"editor.selectionBackground": "#eeeeee",
},
});
export default monaco;

View file

@ -0,0 +1,35 @@
export function getAttributeOrThrow(element, attr, transform = null) {
if (!element.hasAttribute(attr)) {
throw new Error(
`Missing attribute '${attr}' on element <${element.tagName}:${element.id}>`
);
}
const value = element.getAttribute(attr);
return transform ? transform(value) : value;
}
export function parseBoolean(value) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(
`Invalid boolean attribute ${value}, should be either "true" or "false"`
);
}
export function parseInteger(value) {
const number = parseInt(value, 10);
if (Number.isNaN(number)) {
throw new Error(`Invalid integer value ${value}`);
}
return number;
}

View file

@ -173,7 +173,7 @@ export default class Delta {
}
/**
* Converts the given delta to a compact representation, suitable for sending over the network.
* Converts the delta to a compact representation, suitable for sending over the network.
*/
toCompressed() {
return this.ops.map((op) => {
@ -205,6 +205,33 @@ export default class Delta {
throw new Error(`Invalid compressed operation ${compressedOp}`);
}, new this());
}
/**
* Returns the result of applying the delta to the given string.
*/
applyToString(string) {
let newString = "";
let index = 0;
this.ops.forEach((op) => {
if (isRetain(op)) {
newString += string.slice(index, index + op.retain);
index += op.retain;
}
if (isInsert(op)) {
newString += op.insert;
}
if (isDelete(op)) {
index += op.delete;
}
});
newString += string.slice(index);
return newString;
}
}
/**

View file

@ -0,0 +1,69 @@
import { getAttributeOrThrow } from "../lib/attribute";
/**
* A hook managing the whole session.
*
* Registers event listeners to handle keybindings and focus events.
*
* Configuration:
*
* * `data-focused-cell-id` - id of the cell currently being focused
*/
const Session = {
mounted() {
this.props = getProps(this);
// Keybindings
document.addEventListener("keydown", (event) => {
if (event.shiftKey && event.key === "Enter" && !event.repeat) {
if (this.props.focusedCellId !== null) {
// If the editor is focused we don't want it to receive the input
event.preventDefault();
this.pushEvent("toggle_cell_expanded", {});
}
} else if (event.altKey && event.key === "j") {
event.preventDefault();
this.pushEvent("move_cell_focus", { offset: 1 });
} else if (event.altKey && event.key === "k") {
event.preventDefault();
this.pushEvent("move_cell_focus", { offset: -1 });
}
});
// Focus/unfocus a cell when the user clicks somewhere
document.addEventListener("click", (event) => {
// Find the parent with cell id info, if there is one
const cell = event.target.closest("[data-cell-id]");
const cellId = cell ? cell.dataset.cellId : null;
if (cellId !== this.props.focusedCellId) {
this.pushEvent("focus_cell", { cell_id: cellId });
}
});
},
updated() {
const prevProps = this.props;
this.props = getProps(this);
// When a new cell gets focus, center it nicely on the page
if (
this.props.focusedCellId &&
this.props.focusedCellId !== prevProps.focusedCellId
) {
const cell = this.el.querySelector(`#cell-${this.props.focusedCellId}`);
cell.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
};
function getProps(hook) {
return {
focusedCellId: getAttributeOrThrow(
hook.el,
"data-focused-cell-id",
(value) => value || null
),
};
}
export default Session;

View file

@ -7954,6 +7954,11 @@
"object-visit": "^1.0.0"
}
},
"marked": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/marked/-/marked-1.2.8.tgz",
"integrity": "sha512-lzmFjGnzWHkmbk85q/ILZjFoHHJIQGF+SxGEfIdGk/XhiTPhqGs37gbru6Kkd48diJnEyYwnG67nru0Z2gQtuQ=="
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -8224,6 +8229,11 @@
}
}
},
"morphdom": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz",
"integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",

View file

@ -9,7 +9,9 @@
"test": "jest --watch"
},
"dependencies": {
"marked": "^1.2.8",
"monaco-editor": "^0.21.2",
"morphdom": "^2.6.1",
"nprogress": "^0.2.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",

View file

@ -252,4 +252,34 @@ describe("Delta", () => {
expect(b).toEqual(new Delta().insert("B"));
});
});
describe("applyToString", () => {
test("prepend", () => {
const string = "cats";
const delta = new Delta().insert("fat ");
const result = delta.applyToString(string);
expect(result).toEqual("fat cats");
});
test("insert in the middle", () => {
const string = "cats";
const delta = new Delta().retain(3).insert("'");
const result = delta.applyToString(string);
expect(result).toEqual("cat's");
});
test("delete", () => {
const string = "cats";
const delta = new Delta().retain(1).delete(2);
const result = delta.applyToString(string);
expect(result).toEqual("cs");
});
test("replace", () => {
const string = "cats";
const delta = new Delta().retain(1).delete(2).insert("ar");
const result = delta.applyToString(string);
expect(result).toEqual("cars");
});
});
});

View file

@ -50,7 +50,7 @@ defmodule LiveBook.Notebook do
@doc """
Finds notebook cell by `id` and the corresponding section.
"""
@spec fetch_cell_and_section(t(), Cell.section_id()) :: {:ok, Cell.t(), Section.t()} | :error
@spec fetch_cell_and_section(t(), Cell.id()) :: {:ok, Cell.t(), Section.t()} | :error
def fetch_cell_and_section(notebook, cell_id) do
for(
section <- notebook.sections,
@ -64,6 +64,23 @@ defmodule LiveBook.Notebook do
end
end
@doc """
Finds a cell being `offset` from the given cell within the same section.
"""
@spec fetch_cell_sibling(t(), Cell.id(), integer()) :: {:ok, Cell.t()} | :error
def fetch_cell_sibling(notebook, cell_id, offset) do
with {:ok, cell, section} <- fetch_cell_and_section(notebook, cell_id) do
idx = Enum.find_index(section.cells, &(&1 == cell))
sibling_idx = idx + offset
if sibling_idx >= 0 and sibling_idx < length(section.cells) do
{:ok, Enum.at(section.cells, sibling_idx)}
else
:error
end
end
end
@doc """
Inserts `section` at the given `index`.
"""

View file

@ -3,26 +3,97 @@ defmodule LiveBookWeb.Cell do
def render(assigns) do
~L"""
<div phx-click="focus_cell"
phx-value-cell_id="<%= @cell.id %>"
class="flex flex-col relative mr-10 border-2 border-gray-200 rounded border-opacity-0 <%= if @focused, do: "border-opacity-100"%>">
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<button class="text-gray-500 hover:text-current">
<%= Icons.svg(:play, class: "h-6") %>
</button>
<button phx-click="delete_cell" phx-value-cell_id="<%= @cell.id %>" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
<div id="cell-<%= @cell.id %>"
phx-hook="Cell"
data-cell-id="<%= @cell.id %>"
data-type="<%= @cell.type %>"
data-focused="<%= @focused %>"
data-expanded="<%= @expanded %>"
class="flex flex-col relative mr-10 border-l-4 pl-4 -ml-4 border-blue-100 border-opacity-0 hover:border-opacity-100 <%= if @focused, do: "border-blue-300 border-opacity-100"%>">
<%= render_cell_content(assigns) %>
</div>
"""
end
def render_cell_content(%{cell: %{type: :markdown}} = assigns) do
~L"""
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<button phx-click="delete_cell" phx-value-cell_id="<%= @cell.id %>" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
<div class="<%= if @expanded, do: "mb-4", else: "hidden" %>">
<%= render_editor(@cell) %>
</div>
<div class="markdown" data-markdown-container id="markdown-container-<%= @cell.id %>" phx-update="ignore">
<%= render_markdown_content_placeholder(@cell.source) %>
</div>
"""
end
def render_cell_content(%{cell: %{type: :elixir}} = assigns) do
~L"""
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<button class="text-gray-500 hover:text-current">
<%= Icons.svg(:play, class: "h-6") %>
</button>
<button phx-click="delete_cell" phx-value-cell_id="<%= @cell.id %>" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
<%= render_editor(@cell) %>
"""
end
defp render_editor(cell) do
~E"""
<div class="py-3 rounded-md overflow-hidden bg-editor"
data-editor-container
id="editor-container-<%= cell.id %>"
phx-update="ignore">
<%= render_editor_content_placeholder(cell.source) %>
</div>
"""
end
# The whole page has to load and then hooks are mounded.
# There may be a tiny delay before the markdown is rendered
# or and editors are mounted, so show neat placeholders immediately.
defp render_markdown_content_placeholder("" = _content) do
~E"""
<div class="h-4"></div>
"""
end
defp render_markdown_content_placeholder(_content) do
~E"""
<div class="max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded"></div>
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
<div
id="cell-<%= @cell.id %>-editor"
phx-hook="Editor"
phx-update="ignore"
data-cell-id="<%= @cell.id %>"
data-type="<%= @cell.type %>">
<div data-source="<%= @cell.source %>"
data-revision="<%= @cell_info.revision %>">
</div>
</div>
"""
end
defp render_editor_content_placeholder("" = _content) do
~E"""
<div class="h-4"></div>
"""
end
defp render_editor_content_placeholder(_content) do
~E"""
<div class="px-8 max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4 py-1">
<div class="h-4 bg-gray-500 rounded w-3/4"></div>
<div class="h-4 bg-gray-500 rounded"></div>
<div class="h-4 bg-gray-500 rounded w-5/6"></div>
</div>
</div>
"""

View file

@ -23,7 +23,7 @@ defmodule LiveBookWeb.Section do
</div>
</div>
<div class="container py-4">
<div class="flex flex-col space-y-2">
<div class="flex flex-col space-y-2 pb-80">
<%= live_component @socket, LiveBookWeb.InsertCellActions,
section_id: @section.id,
index: 0 %>
@ -31,7 +31,8 @@ defmodule LiveBookWeb.Section do
<%= live_component @socket, LiveBookWeb.Cell,
cell: cell,
cell_info: @cell_infos[cell.id],
focused: cell.id == @focused_cell_id %>
focused: @selected and cell.id == @focused_cell_id,
expanded: @selected and cell.id == @focused_cell_id and @focused_cell_expanded %>
<%= live_component @socket, LiveBookWeb.InsertCellActions,
section_id: @section.id,
index: index + 1 %>

View file

@ -1,7 +1,7 @@
defmodule LiveBookWeb.SessionLive do
use LiveBookWeb, :live_view
alias LiveBook.{SessionSupervisor, Session, Delta}
alias LiveBook.{SessionSupervisor, Session, Delta, Notebook}
@impl true
def mount(%{"id" => session_id}, _session, socket) do
@ -33,14 +33,18 @@ defmodule LiveBookWeb.SessionLive do
session_id: session_id,
data: data,
selected_section_id: first_section_id,
focused_cell_id: nil
focused_cell_id: nil,
focused_cell_expanded: false
}
end
@impl true
def render(assigns) do
~L"""
<div class="flex flex-grow max-h-full">
<div class="flex flex-grow max-h-full"
id="session"
phx-hook="Session"
data-focused-cell-id="<%= @focused_cell_id %>">
<div class="w-1/5 bg-gray-100 border-r-2 border-gray-200">
<h1 id="notebook-name"
contenteditable
@ -59,12 +63,12 @@ defmodule LiveBookWeb.SessionLive do
</span>
</div>
<% end %>
<div phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
<button phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
<div class="flex items-center space-x-2">
<%= Icons.svg(:plus, class: "h-6") %>
<span>New section</span>
</div>
</div>
</button>
</div>
</div>
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
@ -74,7 +78,8 @@ defmodule LiveBookWeb.SessionLive do
section: section,
selected: section.id == @selected_section_id,
cell_infos: @data.cell_infos,
focused_cell_id: @focused_cell_id %>
focused_cell_id: @focused_cell_id,
focused_cell_expanded: @focused_cell_expanded %>
<% end %>
</div>
</div>
@ -83,6 +88,23 @@ defmodule LiveBookWeb.SessionLive do
end
@impl true
def handle_event("cell_init", %{"cell_id" => cell_id}, socket) do
data = socket.assigns.data
case Notebook.fetch_cell_and_section(data.notebook, cell_id) do
{:ok, cell, _section} ->
payload = %{
source: cell.source,
revision: data.cell_infos[cell.id].revision
}
{:reply, payload, socket}
:error ->
{:noreply, socket}
end
end
def handle_event("add_section", _params, socket) do
end_index = length(socket.assigns.data.notebook.sections)
Session.insert_section(socket.assigns.session_id, end_index)
@ -118,10 +140,6 @@ defmodule LiveBookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do
{:noreply, assign(socket, focused_cell_id: cell_id)}
end
def handle_event("set_notebook_name", %{"name" => name}, socket) do
name = normalize_name(name)
Session.set_notebook_name(socket.assigns.session_id, name)
@ -137,7 +155,7 @@ defmodule LiveBookWeb.SessionLive do
end
def handle_event(
"cell_delta",
"apply_cell_delta",
%{"cell_id" => cell_id, "delta" => delta, "revision" => revision},
socket
) do
@ -147,13 +165,25 @@ defmodule LiveBookWeb.SessionLive do
{:noreply, socket}
end
defp normalize_name(name) do
name
|> String.trim()
|> String.replace(~r/\s+/, " ")
|> case do
"" -> "Untitled"
name -> name
def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do
{:noreply, assign(socket, focused_cell_id: cell_id, focused_cell_expanded: false)}
end
def handle_event("move_cell_focus", %{"offset" => offset}, socket) do
case new_focused_cell_from_offset(socket.assigns, offset) do
{:ok, cell} ->
{:noreply, assign(socket, focused_cell_id: cell.id, focused_cell_expanded: false)}
:error ->
{:noreply, socket}
end
end
def handle_event("toggle_cell_expanded", %{}, socket) do
if socket.assigns.focused_cell_id do
{:noreply, assign(socket, focused_cell_expanded: !socket.assigns.focused_cell_expanded)}
else
{:noreply, socket}
end
end
@ -182,4 +212,32 @@ defmodule LiveBookWeb.SessionLive do
end
defp handle_action(socket, _action), do: socket
defp normalize_name(name) do
name
|> String.trim()
|> String.replace(~r/\s+/, " ")
|> case do
"" -> "Untitled"
name -> name
end
end
defp new_focused_cell_from_offset(assigns, offset) do
cond do
assigns.focused_cell_id ->
# If a cell is focused, look up the appropriate sibling
Notebook.fetch_cell_sibling(assigns.data.notebook, assigns.focused_cell_id, offset)
assigns.selected_section_id ->
# If no cell is focused, focus the first one for easier keyboard navigation.
{:ok, section} =
Notebook.fetch_section(assigns.data.notebook, assigns.selected_section_id)
Enum.fetch(section.cells, 0)
true ->
:error
end
end
end

View file

@ -9,7 +9,7 @@
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body class="bg-gray-50">
<body>
<%= @inner_content %>
</body>
</html>

View file

@ -99,7 +99,9 @@ defmodule LiveBook.EvaluatorTest do
]
# Note: evaluating module definitions is relatively slow, so we use a higher wait timeout.
assert_receive {:evaluator_response, :code_1, {:error, _kind, _error, ^expected_stacktrace}}, 1000
assert_receive {:evaluator_response, :code_1,
{:error, _kind, _error, ^expected_stacktrace}},
1000
end
end

View file

@ -0,0 +1,48 @@
defmodule LiveBook.NotebookTest do
use ExUnit.Case, async: true
alias LiveBook.Notebook
alias LiveBook.Notebook.{Section, Cell}
describe "fetch_cell_sibling/3" do
test "returns error given invalid cell id" do
notebook = Notebook.new()
assert :error == Notebook.fetch_cell_sibling(notebook, "1", 0)
end
test "returns sibling cell if there is one at the given offset" do
cell1 = %{Cell.new(:markdown) | id: "1"}
cell2 = %{Cell.new(:markdown) | id: "2"}
cell3 = %{Cell.new(:markdown) | id: "3"}
cell4 = %{Cell.new(:markdown) | id: "4"}
notebook = %{
Notebook.new()
| sections: [
%{Section.new() | cells: [cell1, cell2, cell3, cell4]}
]
}
assert {:ok, cell1} == Notebook.fetch_cell_sibling(notebook, cell2.id, -1)
assert {:ok, cell3} == Notebook.fetch_cell_sibling(notebook, cell2.id, 1)
assert {:ok, cell4} == Notebook.fetch_cell_sibling(notebook, cell2.id, 2)
end
test "returns error if the offset is out of range" do
cell1 = %{Cell.new(:markdown) | id: "1"}
cell2 = %{Cell.new(:markdown) | id: "2"}
notebook = %{
Notebook.new()
| sections: [
%{Section.new() | cells: [cell1, cell2]}
]
}
assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, -2)
assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 1)
assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 2)
end
end
end

View file

@ -0,0 +1,108 @@
defmodule LiveBookWeb.SessionLiveTest do
use LiveBookWeb.ConnCase
import Phoenix.LiveViewTest
alias LiveBook.{SessionSupervisor, Session}
setup do
{:ok, session_id} = SessionSupervisor.create_session()
%{session_id: session_id}
end
test "disconnected and connected render", %{conn: conn, session_id: session_id} do
{:ok, view, disconnected_html} = live(conn, "/sessions/#{session_id}")
assert disconnected_html =~ "Untitled notebook"
assert render(view) =~ "Untitled notebook"
end
describe "asynchronous updates" do
test "renders an updated notebook name", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.set_notebook_name(session_id, "My notebook")
wait_for_session_update(session_id)
assert render(view) =~ "My notebook"
end
test "renders a newly inserted section", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
assert render(view) =~ section.id
end
test "renders an updated section name", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.set_section_name(session_id, section.id, "My section")
wait_for_session_update(session_id)
assert render(view) =~ "My section"
end
test "renders a newly inserted cell", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.insert_cell(session_id, section.id, 0, :markdown)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
assert render(view) =~ cell.id
end
test "un-renders a deleted cell", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
Session.insert_cell(session_id, section.id, 0, :markdown)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.delete_cell(session_id, cell.id)
wait_for_session_update(session_id)
refute render(view) =~ cell.id
end
end
describe "UI-triggered updates" do
test "adding a new session updates the shared state", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
view
|> element("button", "New section")
|> render_click()
assert %{notebook: %{sections: [_section]}} = Session.get_data(session_id)
end
test "adding a new cell updates the shared state", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
view
|> element("button", "+ Markdown")
|> render_click()
assert %{notebook: %{sections: [%{cells: [%{type: :markdown}]}]}} =
Session.get_data(session_id)
end
end
defp wait_for_session_update(session_id) do
# This call is synchronous, so it gives the session time
# for handling the previously sent change messages.
Session.get_data(session_id)
:ok
end
end