mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-04 03:54:24 +08:00
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:
parent
ca36e22af0
commit
936d0af5fb
29 changed files with 1054 additions and 143 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
93
assets/js/cell/index.js
Normal 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;
|
103
assets/js/cell/live_editor.js
Normal file
103
assets/js/cell/live_editor.js
Normal 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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
[
|
|
@ -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,
|
67
assets/js/cell/live_editor/monaco.js
Normal file
67
assets/js/cell/live_editor/monaco.js
Normal 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;
|
|
@ -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.
|
44
assets/js/cell/markdown.js
Normal file
44
assets/js/cell/markdown.js
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
35
assets/js/lib/attribute.js
Normal file
35
assets/js/lib/attribute.js
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
69
assets/js/session/index.js
Normal file
69
assets/js/session/index.js
Normal 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;
|
10
assets/package-lock.json
generated
10
assets/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`.
|
||||
"""
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
48
test/live_book/notebook_test.exs
Normal file
48
test/live_book/notebook_test.exs
Normal 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
|
108
test/live_book_web/live/session_live_test.exs
Normal file
108
test/live_book_web/live/session_live_test.exs
Normal 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
|
Loading…
Add table
Reference in a new issue