diff --git a/src/features/articles/components/article-editor/article-editor.css b/src/features/articles/components/article-editor/article-editor.css index d773bb49..055b3715 100644 --- a/src/features/articles/components/article-editor/article-editor.css +++ b/src/features/articles/components/article-editor/article-editor.css @@ -299,6 +299,193 @@ background-color: var(--mantine-color-dark-6); } -.ck-button_fit_content .ck-button__label { - width: fit-content !important; +/* Runnable code blocks show their runtime right next to the language label, by + extending the label CKEditor already pins to the corner of the block. */ +.ck.ck-editor__editable pre[data-language][data-runtime]::after { + content: attr(data-language) " \25B6 " attr(data-runtime); + background: var(--mantine-color-teal-7, #0c8599); + direction: ltr; + unicode-bidi: isolate; +} + +/* Same badge for blocks whose language carries no label of its own. */ +.ck.ck-editor__editable pre[data-runtime]:not([data-language])::after { + content: "\25B6 " attr(data-runtime); + position: absolute; + top: -1px; + right: 10px; + direction: ltr; + unicode-bidi: isolate; + background: var(--mantine-color-teal-7, #0c8599); + color: #fff; + font-family: var(--ck-font-face); + font-size: 10px; + line-height: 16px; + padding: var(--ck-spacing-tiny) var(--ck-spacing-medium); + white-space: nowrap; +} + +/* Code block settings balloon. */ +.ck.ck-code-block-settings { + /* Scales every icon of the panel down to match its compact controls. */ + --ck-icon-size: 14px; + /* The floating field labels sit on the border, so they take the panel colour. */ + --ck-color-labeled-field-label-background: var(--ck-color-panel-background); + + display: flex; + flex-direction: column; + gap: 10px; + min-width: 300px; + max-width: 380px; + padding: 12px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__fields { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Both dropdowns share the row evenly and shrink instead of overflowing. */ +.ck.ck-code-block-settings .ck-labeled-field-view { + flex: 1 1 132px; + min-width: 0; +} + +.ck.ck-code-block-settings .ck-labeled-field-view .ck-label { + font-size: 11px; +} + +.ck.ck-code-block-settings .ck-labeled-field-view .ck-dropdown, +.ck.ck-code-block-settings .ck-labeled-field-view .ck-dropdown > .ck-button { + width: 100%; +} + +/* The dropdowns read as input fields: a hairline box holding the current value. + The selectors are this long on purpose — they have to outweigh the CKEditor + rule that strips the background off dropdowns inside a labeled field. */ +.ck.ck-code-block-settings + .ck.ck-labeled-field-view + > .ck.ck-labeled-field-view__input-wrapper + > .ck-dropdown + > .ck.ck-button { + padding: 4px 8px; + min-height: 28px; + border: 1px solid var(--ck-color-input-border); + border-radius: var(--ck-border-radius); + background: var(--ck-color-input-background); + color: var(--ck-color-input-text); +} + +.ck.ck-code-block-settings + .ck.ck-labeled-field-view + > .ck.ck-labeled-field-view__input-wrapper + > .ck-dropdown + > .ck.ck-button:hover:not(.ck-disabled) { + border-color: var(--ck-color-base-active); +} + +.ck.ck-code-block-settings + .ck.ck-labeled-field-view + > .ck.ck-labeled-field-view__input-wrapper + > .ck-dropdown.ck-dropdown_open + > .ck.ck-button, +.ck.ck-code-block-settings + .ck.ck-labeled-field-view + > .ck.ck-labeled-field-view__input-wrapper + > .ck-dropdown + > .ck.ck-button:focus { + border-color: var(--ck-color-focus-border); +} + +.ck.ck-code-block-settings + .ck.ck-labeled-field-view.ck-disabled + > .ck.ck-labeled-field-view__input-wrapper + > .ck-dropdown + > .ck.ck-button { + border-color: var(--ck-color-input-disabled-border); + background: var(--ck-color-input-disabled-background); + color: var(--ck-color-input-disabled-text); +} + +/* Long language and runtime names are truncated rather than widening the panel. */ +.ck.ck-code-block-settings .ck-dropdown > .ck-button > .ck-button__label { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: start; +} + +.ck.ck-code-block-settings .ck-dropdown__panel { + max-height: 250px; + overflow: hidden auto; +} + +.ck.ck-code-block-settings .ck-code-block-settings__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__switch .ck-button__label { + font-size: 12px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__run { + /* Sticks to the end of the row, whichever side that is. */ + margin-inline-start: auto; + padding: 4px 10px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__run .ck-button__label { + width: fit-content; + font-size: 12px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__output { + display: flex; + flex-direction: column; + gap: 4px; + border-top: 1px solid var(--ck-color-base-border); + padding-top: 8px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__output-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: var(--ck-color-text); +} + +.ck.ck-code-block-settings .ck-code-block-settings__clear { + min-width: auto; + min-height: auto; + padding: 4px; +} + +.ck.ck-code-block-settings .ck-code-block-settings__output-text { + /* Program output is code: it reads left to right regardless of the UI language. */ + direction: ltr; + text-align: left; + margin: 0; + padding: 8px; + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.5; + color: var(--ck-color-text); + background: var(--ck-color-base-foreground); + border: 1px solid var(--ck-color-base-border); + border-radius: var(--ck-border-radius); +} + +.ck.ck-code-block-settings + .ck-code-block-settings__output_error + .ck-code-block-settings__output-text { + color: var(--mantine-color-red-7, #c92a2a); } diff --git a/src/features/articles/components/article-editor/article-editor.tsx b/src/features/articles/components/article-editor/article-editor.tsx index 3f57b61e..6460cf76 100644 --- a/src/features/articles/components/article-editor/article-editor.tsx +++ b/src/features/articles/components/article-editor/article-editor.tsx @@ -4,9 +4,11 @@ import {useState, useMemo, type RefObject} from "react"; import {ClassicEditor, EditorConfig} from "ckeditor5"; import {CKEditor} from "@ckeditor/ckeditor5-react"; import {Modal} from "@mantine/core"; +import {decode} from "js-base64"; import {FilesExplorer} from "@/components/files-explorer"; import {FILES_PUBLIC_URL} from "@/constants/envs"; -import {useTranslations} from "@/i18n/provider"; +import {useWsPublish} from "@/hooks/use-ws-publish"; +import {useI18n} from "@/i18n/provider"; import {getEditorConfig} from "./editor-config"; import "ckeditor5/ckeditor5.css"; import "./article-editor.css"; @@ -19,7 +21,8 @@ type Props = { }; export function ArticleEditor({initialData, editorRef}: Props) { - const t = useTranslations(); + const {t, direction} = useI18n(); + const publish = useWsPublish(); const [isFileExplorerOpen, setIsFileExplorerOpen] = useState(false); const config: EditorConfig = useMemo(() => { @@ -28,9 +31,23 @@ export function ArticleEditor({initialData, editorRef}: Props) { fileExplorer: { onOpen: setIsFileExplorerOpen.bind(null, true), }, + runnableCodeBlock: { + // Runs snippets through the same channel the published article uses. + onRun: async ({runtime, code}: {runtime: string; code: string}) => { + const response = await publish< + {runner: string; code: string}, + {logs: string} + >("runCode", {runner: runtime, code}); + + return decode(response.logs); + }, + translate: t, + // The panel is translated by the app, so it follows the app direction. + direction, + }, initialData: initialData || "", }; - }, [initialData, t]); + }, [direction, initialData, publish, t]); return (
diff --git a/src/features/articles/components/article-editor/editor-config.ts b/src/features/articles/components/article-editor/editor-config.ts index 4404e8d0..4d97d815 100644 --- a/src/features/articles/components/article-editor/editor-config.ts +++ b/src/features/articles/components/article-editor/editor-config.ts @@ -11,7 +11,6 @@ import { Bold, Bookmark, Code, - CodeBlock, Essentials, FindAndReplace, FontBackgroundColor, @@ -72,7 +71,7 @@ import { WordCount, } from "ckeditor5"; import {FileExplorerPlugin} from "./plugins/file-explorer-plugin"; -import {ExecutableCodeBlockPlugin} from "./plugins/executable-code-block-plugin"; +import {RunnableCodeBlockPlugin} from "./plugins/runnable-code-block"; import {type TFunction} from "@/i18n/dictionary"; export const getEditorConfig = (t: TFunction): EditorConfig => ({ @@ -103,7 +102,6 @@ export const getEditorConfig = (t: TFunction): EditorConfig => ({ "highlight", "blockQuote", "codeBlock", - "executable", "|", "alignment", "|", @@ -128,7 +126,6 @@ export const getEditorConfig = (t: TFunction): EditorConfig => ({ Bold, Bookmark, Code, - CodeBlock, Essentials, FindAndReplace, FontBackgroundColor, @@ -187,7 +184,8 @@ export const getEditorConfig = (t: TFunction): EditorConfig => ({ TodoList, Underline, WordCount, - ExecutableCodeBlockPlugin, + // Brings the code block feature along with runtimes, so `CodeBlock` is not needed. + RunnableCodeBlockPlugin, ], balloonToolbar: [ "bold", diff --git a/src/features/articles/components/article-editor/plugins/executable-code-block-plugin.ts b/src/features/articles/components/article-editor/plugins/executable-code-block-plugin.ts deleted file mode 100644 index 96cef8ad..00000000 --- a/src/features/articles/components/article-editor/plugins/executable-code-block-plugin.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - addListToDropdown, - Collection, - Command, - createDropdown, - ModelElement, - Plugin, - ViewModel as Model, -} from "ckeditor5"; -import {RUNTIMES} from "@/constants"; - -const EXECUTABLE = "executable"; - -// Special entry to clear the runtime attribute -const CLEAR_RUNTIME = {value: null, label: "Clear"}; - -export class ExecutableCodeBlockPlugin extends Plugin { - public static get requires() { - return ["CodeBlock"] as const; - } - public static get pluginName() { - return "ExecutableCodeBlock" as const; - } - - init() { - const {editor} = this; - - /* 1. schema ---------------------------------------------------- */ - editor.model.schema.extend("codeBlock", {allowAttributes: [EXECUTABLE]}); - - /* 2. conversion ------------------------------------------------- */ - editor.conversion.for("upcast").attributeToAttribute({ - view: {name: "pre", key: `data-${EXECUTABLE}`}, - model: EXECUTABLE, - }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - editor.conversion.for("downcast").attributeToAttribute({ - model: EXECUTABLE, - view: (v) => (v ? {key: `data-${EXECUTABLE}`, value: v} : null), - }); - - editor.commands.add(EXECUTABLE, new SetExecutableCommand(editor)); - - this._createDropdown(); - } - - private _createDropdown() { - const {editor} = this; - const dropdown = createDropdown(editor.locale); - const command = editor.commands.get(EXECUTABLE) as SetExecutableCommand; - - dropdown.buttonView.set({ - label: "Runtime", - withText: true, - tooltip: "Choose runtime for this code block", - }); - - dropdown.buttonView - .bind("label") - .to(command, "value", (v: any) => - v ? `Run: ${v.toUpperCase()}` : "Runtime", - ); - - dropdown.bind("isEnabled").to(command, "isEnabled"); - - dropdown.buttonView.extendTemplate({ - attributes: { - class: ["ck-button_fit_content"], - }, - }); - - const items = new Collection(); - - const clearModel = new Model({ - label: CLEAR_RUNTIME.label, - withText: true, - commandParam: CLEAR_RUNTIME.value, - }); - clearModel - .bind("isOn") - .to(command, "value", (value) => value === CLEAR_RUNTIME.value); - - items.add({type: "button", model: clearModel}); - - for (const rt of RUNTIMES) { - const itemModel = new Model({ - label: rt.label, - withText: true, - commandParam: rt.value, - }); - - // active condition - itemModel - .bind("isOn") - .to(command, "value", (value) => value === rt.value); - - items.add({type: "button", model: itemModel}); - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - addListToDropdown(dropdown, items); - - // on change - this.listenTo(dropdown, "execute", (evt) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - editor.execute(EXECUTABLE, {value: evt.source.commandParam}); - editor.editing.view.focus(); - }); - - editor.ui.componentFactory.add("executable", () => dropdown); - } -} - -class SetExecutableCommand extends Command { - refresh() { - const block = this._currentCodeBlock(); - this.isEnabled = !!block; - this.value = block ? this.retrieveValueFromBlock(block) : null; - } - - retrieveValueFromBlock(block: ModelElement) { - const explicitAttr = block.getAttribute(EXECUTABLE); - if (explicitAttr) return explicitAttr; - - const entries = Array.from<[string, unknown]>(block.getAttributes()); - const htmlContent = entries.find( - ([key]) => key === "htmlContentAttributes", - )?.[1] as {attributes?: Record} | undefined; - - if (!htmlContent?.attributes) return null; - - return htmlContent.attributes[`data-${EXECUTABLE}`] ?? null; - } - - execute({value}: {value?: string | null} = {}) { - const block = this._currentCodeBlock(); - if (!block) return; - - this.editor.model.change((writer) => { - if (value) { - writer.setAttribute(EXECUTABLE, value, block); - } else { - writer.removeAttribute(EXECUTABLE, block); - const htmlSupport: any = block.getAttribute("htmlContentAttributes"); - if (htmlSupport?.attributes?.[`data-${EXECUTABLE}`]) { - const attrs = {...htmlSupport.attributes}; - delete attrs[`data-${EXECUTABLE}`]; - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - Object.keys(attrs).length - ? writer.setAttribute( - "htmlContentAttributes", - {attributes: attrs}, - block, - ) - : writer.removeAttribute("htmlContentAttributes", block); - } - } - }); - } - - private _currentCodeBlock() { - const {selection} = this.editor.model.document; - return Array.from(selection.getSelectedBlocks()).find((el) => - el.is("element", "codeBlock"), - ); - } -} diff --git a/src/features/articles/components/article-editor/plugins/runnable-code-block/code-block-settings-view.ts b/src/features/articles/components/article-editor/plugins/runnable-code-block/code-block-settings-view.ts new file mode 100644 index 00000000..85aeb932 --- /dev/null +++ b/src/features/articles/components/article-editor/plugins/runnable-code-block/code-block-settings-view.ts @@ -0,0 +1,374 @@ +import { + ButtonView, + Collection, + FocusCycler, + FocusTracker, + IconCancel, + IconPlay, + KeystrokeHandler, + LabeledFieldView, + SwitchButtonView, + UIModel, + View, + ViewCollection, + addListToDropdown, + createLabeledDropdown, + type DropdownView, + type FocusableView, + type ListDropdownItemDefinition, + type Locale, +} from "ckeditor5"; +import {dropdownItemValue} from "./utils"; + +export type CodeBlockLanguageOption = {language: string; label: string}; +export type CodeBlockRuntimeOption = {value: string; label: string}; + +export type CodeBlockSettingsLabels = { + language: string; + runtime: string; + noRuntime: string; + editableCode: string; + run: string; + running: string; + programOutput: string; + clearOutput: string; +}; + +type Options = { + languages: Array; + runtimes: Array; + labels: CodeBlockSettingsLabels; + /** Reading direction of the panel. Its layout is written with logical properties. */ + direction: "ltr" | "rtl"; +}; + +/** + * The settings panel of a code block: its language, the runtime it is executed with, + * whether readers may edit it — plus running it right in the editor. + * + * The view is intentionally "dumb": it exposes its state as observable properties and + * announces user intent through the `languageChange`, `runtimeChange`, `editableChange`, + * `run` and `clearOutput` events. Wiring those to commands is the plugin's job. + */ +export class CodeBlockSettingsView extends View { + public readonly focusTracker = new FocusTracker(); + public readonly keystrokes = new KeystrokeHandler(); + + public readonly languageInput: LabeledFieldView; + public readonly runtimeInput: LabeledFieldView; + public readonly editableSwitch: SwitchButtonView; + public readonly runButton: ButtonView; + public readonly clearOutputButton: ButtonView; + + /** The language of the code block the panel is attached to. */ + declare public language: string | null; + /** The runtime of the code block the panel is attached to, `null` when not runnable. */ + declare public runtime: string | null; + /** Whether readers may edit the code before running it. */ + declare public isEditable: boolean; + /** Output of the last run, `null` when the code has not been run yet. */ + declare public output: string | null; + declare public isRunning: boolean; + declare public hasError: boolean; + /** Whether the integration is able to execute code at all (see the `onRun` config). */ + declare public canRun: boolean; + + private readonly _focusables = new ViewCollection(); + private readonly _focusCycler: FocusCycler; + + constructor( + locale: Locale, + {languages, runtimes, labels, direction}: Options, + ) { + super(locale); + + this.set({ + language: null, + runtime: null, + isEditable: false, + output: null, + isRunning: false, + hasError: false, + canRun: false, + }); + + this.languageInput = this._createLanguageInput(languages, labels); + this.runtimeInput = this._createRuntimeInput(runtimes, labels); + this.editableSwitch = this._createEditableSwitch(labels); + this.runButton = this._createRunButton(labels); + this.clearOutputButton = this._createClearOutputButton(labels); + + this._focusCycler = new FocusCycler({ + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + focusPrevious: "shift + tab", + focusNext: "tab", + }, + }); + + const bind = this.bindTemplate; + + this.setTemplate({ + tag: "div", + attributes: { + class: ["ck", "ck-reset_all", "ck-code-block-settings"], + dir: direction, + tabindex: "-1", + }, + children: [ + { + tag: "div", + attributes: {class: ["ck", "ck-code-block-settings__fields"]}, + children: [this.languageInput, this.runtimeInput], + }, + { + // Editing and running only apply to blocks that have a runtime. + tag: "div", + attributes: { + class: [ + "ck", + "ck-code-block-settings__actions", + bind.if("runtime", "ck-hidden", (value) => !value), + ], + }, + children: [this.editableSwitch, this.runButton], + }, + { + tag: "div", + attributes: { + class: [ + "ck", + "ck-code-block-settings__output", + bind.if("output", "ck-hidden", (value) => !value), + bind.if("hasError", "ck-code-block-settings__output_error"), + ], + }, + children: [ + { + tag: "div", + attributes: {class: ["ck", "ck-code-block-settings__output-bar"]}, + children: [ + { + tag: "span", + children: [{text: labels.programOutput}], + }, + this.clearOutputButton, + ], + }, + { + tag: "pre", + attributes: { + class: ["ck", "ck-code-block-settings__output-text"], + dir: "ltr", + }, + children: [{text: bind.to("output", (value) => value ?? "")}], + }, + ], + }, + ], + }); + } + + public override render(): void { + super.render(); + + for (const view of this._focusables) { + this.focusTracker.add(view.element!); + } + + this.keystrokes.listenTo(this.element!); + } + + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + public focus(): void { + this._focusCycler.focusFirst(); + } + + private _createLanguageInput( + languages: Array, + labels: CodeBlockSettingsLabels, + ): LabeledFieldView { + const labeledDropdown = new LabeledFieldView( + this.locale, + createLabeledDropdown, + ); + + labeledDropdown.label = labels.language; + // Labeled dropdowns start out "empty", which hides the value of the field. + // A code block always has a language, so the value is always there to show. + labeledDropdown.isEmpty = false; + + const dropdown = labeledDropdown.fieldView; + const items = new Collection(); + + for (const {language, label} of languages) { + const model = new UIModel({ + _value: language, + label, + role: "menuitemradio", + withText: true, + }); + + model.bind("isOn").to(this, "language", (value) => value === language); + + items.add({type: "button", model}); + } + + addListToDropdown(dropdown, items, { + role: "menu", + ariaLabel: labels.language, + }); + + dropdown.buttonView.set({withText: true, tooltip: labels.language}); + dropdown.buttonView.bind("label").to(this, "language", (value) => { + return ( + languages.find((item) => item.language === value)?.label ?? + labels.language + ); + }); + + dropdown.on("execute", (evt) => { + this.fire("languageChange", dropdownItemValue(evt.source) as string); + }); + + this._focusables.add(labeledDropdown); + + return labeledDropdown; + } + + private _createRuntimeInput( + runtimes: Array, + labels: CodeBlockSettingsLabels, + ): LabeledFieldView { + const labeledDropdown = new LabeledFieldView( + this.locale, + createLabeledDropdown, + ); + + labeledDropdown.label = labels.runtime; + // Without a runtime the field shows its label as a placeholder; once one is + // picked, the label floats onto the border and the runtime itself shows. + labeledDropdown.bind("isEmpty").to(this, "runtime", (value) => !value); + + const dropdown = labeledDropdown.fieldView; + const items = new Collection(); + + // `null` clears the runtime and turns the block back into a plain snippet. + const options: Array< + CodeBlockRuntimeOption | {value: null; label: string} + > = [{value: null, label: labels.noRuntime}, ...runtimes]; + + for (const {value, label} of options) { + const model = new UIModel({ + _value: value, + label, + role: "menuitemradio", + withText: true, + }); + + model + .bind("isOn") + .to(this, "runtime", (runtime) => (runtime ?? null) === value); + + items.add({type: "button", model}); + } + + addListToDropdown(dropdown, items, { + role: "menu", + ariaLabel: labels.runtime, + }); + + dropdown.buttonView.set({withText: true, tooltip: labels.runtime}); + dropdown.buttonView.bind("label").to(this, "runtime", (value) => { + return ( + runtimes.find((item) => item.value === value)?.label ?? labels.noRuntime + ); + }); + + dropdown.on("execute", (evt) => { + this.fire("runtimeChange", dropdownItemValue(evt.source)); + }); + + this._focusables.add(labeledDropdown); + + return labeledDropdown; + } + + private _createEditableSwitch( + labels: CodeBlockSettingsLabels, + ): SwitchButtonView { + const view = new SwitchButtonView(this.locale); + + view.set({ + label: labels.editableCode, + withText: true, + class: "ck-code-block-settings__switch", + }); + + view.bind("isOn").to(this, "isEditable"); + + view.on("execute", () => { + this.fire("editableChange", !this.isEditable); + }); + + this._focusables.add(view); + + return view; + } + + private _createRunButton(labels: CodeBlockSettingsLabels): ButtonView { + const view = new ButtonView(this.locale); + + view.set({ + icon: IconPlay, + withText: true, + class: "ck-code-block-settings__run", + }); + + // Running additionally requires an integration able to execute the code. + view.bind("isVisible").to(this, "canRun"); + view + .bind("label") + .to(this, "isRunning", (isRunning) => + isRunning ? labels.running : labels.run, + ); + view.bind("isEnabled").to(this, "isRunning", (isRunning) => !isRunning); + + view.on("execute", () => { + this.fire("run"); + }); + + this._focusables.add(view); + + return view; + } + + private _createClearOutputButton( + labels: CodeBlockSettingsLabels, + ): ButtonView { + const view = new ButtonView(this.locale); + + view.set({ + icon: IconCancel, + label: labels.clearOutput, + tooltip: true, + class: "ck-code-block-settings__clear", + }); + + view.on("execute", () => { + this.fire("clearOutput"); + }); + + this._focusables.add(view); + + return view; + } +} diff --git a/src/features/articles/components/article-editor/plugins/runnable-code-block/commands.ts b/src/features/articles/components/article-editor/plugins/runnable-code-block/commands.ts new file mode 100644 index 00000000..fad8343c --- /dev/null +++ b/src/features/articles/components/article-editor/plugins/runnable-code-block/commands.ts @@ -0,0 +1,79 @@ +import {Command} from "ckeditor5"; +import { + EDITABLE_MODEL_ATTRIBUTE, + RUNTIME_MODEL_ATTRIBUTE, + findCodeBlock, +} from "./utils"; + +/** + * Sets (or clears) the runtime the current code block is executed with. + * + * The command value is the runtime identifier (e.g. `go-1.24`) or `null` when the + * block is not executable. + */ +export class CodeBlockRuntimeCommand extends Command { + declare public value: string | null; + + public override refresh(): void { + const block = findCodeBlock(this.editor); + + this.isEnabled = !!block; + this.value = block + ? ((block.getAttribute(RUNTIME_MODEL_ATTRIBUTE) as string) ?? null) + : null; + } + + public override execute({value}: {value?: string | null} = {}): void { + const block = findCodeBlock(this.editor); + + if (!block) { + return; + } + + this.editor.model.change((writer) => { + if (value) { + writer.setAttribute(RUNTIME_MODEL_ATTRIBUTE, value, block); + } else { + writer.removeAttribute(RUNTIME_MODEL_ATTRIBUTE, block); + // An editable snippet only makes sense when there is a runtime to re-run it with. + writer.removeAttribute(EDITABLE_MODEL_ATTRIBUTE, block); + } + }); + } +} + +/** + * Toggles whether readers may edit the code of a runnable block before running it. + * + * Disabled (and always `false`) for blocks without a runtime. + */ +export class CodeBlockEditableCommand extends Command { + declare public value: boolean; + + public override refresh(): void { + const block = findCodeBlock(this.editor); + const hasRuntime = !!block?.getAttribute(RUNTIME_MODEL_ATTRIBUTE); + + this.isEnabled = hasRuntime; + this.value = + hasRuntime && block!.getAttribute(EDITABLE_MODEL_ATTRIBUTE) === true; + } + + public override execute({value}: {value?: boolean} = {}): void { + const block = findCodeBlock(this.editor); + + if (!block || !block.getAttribute(RUNTIME_MODEL_ATTRIBUTE)) { + return; + } + + const newValue = value === undefined ? !this.value : value; + + this.editor.model.change((writer) => { + if (newValue) { + writer.setAttribute(EDITABLE_MODEL_ATTRIBUTE, true, block); + } else { + writer.removeAttribute(EDITABLE_MODEL_ATTRIBUTE, block); + } + }); + } +} diff --git a/src/features/articles/components/article-editor/plugins/runnable-code-block/index.ts b/src/features/articles/components/article-editor/plugins/runnable-code-block/index.ts new file mode 100644 index 00000000..aacedefc --- /dev/null +++ b/src/features/articles/components/article-editor/plugins/runnable-code-block/index.ts @@ -0,0 +1,14 @@ +export { + RunnableCodeBlockPlugin, + type RunCodeCallback, + type RunnableCodeBlockConfig, +} from "./runnable-code-block-plugin"; +export {CodeBlockRuntimeCommand, CodeBlockEditableCommand} from "./commands"; +export { + EDITABLE_COMMAND, + EDITABLE_DATA_ATTRIBUTE, + EDITABLE_MODEL_ATTRIBUTE, + RUNTIME_COMMAND, + RUNTIME_DATA_ATTRIBUTE, + RUNTIME_MODEL_ATTRIBUTE, +} from "./utils"; diff --git a/src/features/articles/components/article-editor/plugins/runnable-code-block/runnable-code-block-plugin.ts b/src/features/articles/components/article-editor/plugins/runnable-code-block/runnable-code-block-plugin.ts new file mode 100644 index 00000000..e754b53c --- /dev/null +++ b/src/features/articles/components/article-editor/plugins/runnable-code-block/runnable-code-block-plugin.ts @@ -0,0 +1,611 @@ +import { + CodeBlockEditing, + Collection, + ContextualBalloon, + IconCodeBlock, + IconCog, + Plugin, + SplitButtonView, + UIModel, + addListToDropdown, + createDropdown, + _getNormalizedAndLocalizedCodeBlockLanguageDefinitions as getLanguageDefinitions, + type DowncastAttributeEvent, + type ListDropdownItemDefinition, + type ModelElement, + type UpcastElementEvent, + type ModelWriter, +} from "ckeditor5"; +import {RUNTIMES} from "@/constants"; +import {CodeBlockEditableCommand, CodeBlockRuntimeCommand} from "./commands"; +import { + CodeBlockSettingsView, + type CodeBlockRuntimeOption, +} from "./code-block-settings-view"; +import { + EDITABLE_COMMAND, + EDITABLE_DATA_ATTRIBUTE, + EDITABLE_MODEL_ATTRIBUTE, + RUNTIME_BADGE_ATTRIBUTE, + RUNTIME_COMMAND, + RUNTIME_DATA_ATTRIBUTE, + RUNTIME_MODEL_ATTRIBUTE, + dropdownItemValue, + findCodeBlock, + getCodeBlockText, +} from "./utils"; + +/** Executes a snippet and resolves with whatever the runtime printed. */ +export type RunCodeCallback = (params: { + runtime: string; + code: string; +}) => Promise | string; + +export type RunnableCodeBlockConfig = { + /** + * Called when the author runs a snippet from the editor. Without it the editor + * still lets authors pick a runtime, it just cannot execute anything. + */ + onRun?: RunCodeCallback; + /** Runtimes available in the settings panel. Defaults to the app-wide `RUNTIMES`. */ + runtimes?: Array; + /** Translation function used for the plugin UI. Falls back to English. */ + translate?: (key: string) => string; + /** + * Reading direction of the plugin UI. Defaults to the editor UI direction, which + * is what a monolingual editor wants; pass the app direction when the panel is + * translated into a language the editor UI itself is not. + */ + direction?: "ltr" | "rtl"; +}; + +type Labels = { + language: string; + runtime: string; + noRuntime: string; + editableCode: string; + run: string; + running: string; + programOutput: string; + clearOutput: string; + noOutput: string; + runFailed: string; + insertCodeBlock: string; + codeBlockSettings: string; +}; + +const SETTINGS_ITEM = "__settings__"; + +/** + * A single code block feature: inserting code blocks, picking their language, the + * runtime they are executed with, whether readers may edit them — and running them + * right in the editor, the same way the published article does. + * + * The runtime is stored as `data-executable` and the editable flag as + * `data-executable-editable` on the `` element, which is what the article body + * parser reads when rendering a published article. + */ +export class RunnableCodeBlockPlugin extends Plugin { + public static get requires() { + return [CodeBlockEditing, ContextualBalloon] as const; + } + + public static get pluginName() { + return "RunnableCodeBlock" as const; + } + + private _settingsView: CodeBlockSettingsView | null = null; + private _activeBlock: ModelElement | null = null; + /** Set when the author closes the panel so it does not pop up again right away. */ + private _isDismissed = false; + private _labels!: Labels; + + public init(): void { + this._labels = this._createLabels(); + + this._defineSchema(); + this._defineConverters(); + + const {editor} = this; + + editor.commands.add(RUNTIME_COMMAND, new CodeBlockRuntimeCommand(editor)); + editor.commands.add(EDITABLE_COMMAND, new CodeBlockEditableCommand(editor)); + + this._createToolbarDropdown(); + this._enableBalloonInteractions(); + } + + public override destroy(): void { + super.destroy(); + + this._settingsView?.destroy(); + this._settingsView = null; + } + + private get _config(): RunnableCodeBlockConfig { + return (this.editor.config.get("runnableCodeBlock") ?? + {}) as RunnableCodeBlockConfig; + } + + private get _balloon(): ContextualBalloon { + return this.editor.plugins.get(ContextualBalloon); + } + + private _createLabels(): Labels { + const translate = this._config.translate; + const label = (key: string, fallback: string) => { + const translated = translate?.(key); + + // The app dictionary falls back to the key itself for missing translations. + return translated && translated !== key ? translated : fallback; + }; + + return { + language: label("editor.language", "Language"), + runtime: label("editor.runtime", "Runtime"), + noRuntime: label("editor.noRuntime", "No runtime"), + editableCode: label("editor.editableCode", "Editable code"), + run: label("editor.run", "Run"), + running: label("editor.running", "Running…"), + programOutput: label("editor.programOutput", "Program output:"), + clearOutput: label("editor.clearOutput", "Clear output"), + noOutput: label("editor.noOutput", ""), + runFailed: label("editor.runFailed", "Running the code failed."), + insertCodeBlock: label("editor.insertCodeBlock", "Insert code block"), + codeBlockSettings: label( + "editor.codeBlockSettings", + "Code block settings", + ), + }; + } + + private _defineSchema(): void { + this.editor.model.schema.extend("codeBlock", { + allowAttributes: [RUNTIME_MODEL_ATTRIBUTE, EDITABLE_MODEL_ATTRIBUTE], + }); + } + + private _defineConverters(): void { + const {editor} = this; + + // The model `codeBlock` element is mapped to the `` element, so both + // attributes end up on `` — where the article body parser looks for them. + editor.conversion.for("downcast").attributeToAttribute({ + model: {name: "codeBlock", key: RUNTIME_MODEL_ATTRIBUTE}, + view: (value) => + value ? {key: RUNTIME_DATA_ATTRIBUTE, value: String(value)} : null, + }); + + editor.conversion.for("downcast").attributeToAttribute({ + model: {name: "codeBlock", key: EDITABLE_MODEL_ATTRIBUTE}, + view: (value) => + value ? {key: EDITABLE_DATA_ATTRIBUTE, value: "true"} : null, + }); + + // Editing-only: label runnable blocks with their runtime, next to the language label. + editor.editing.downcastDispatcher.on( + `attribute:${RUNTIME_MODEL_ATTRIBUTE}:codeBlock`, + (evt, data, conversionApi) => { + const viewCode = conversionApi.mapper.toViewElement( + data.item as ModelElement, + ); + const viewPre = viewCode?.parent; + + if (!viewPre || !viewPre.is("element", "pre")) { + return; + } + + if (data.attributeNewValue) { + conversionApi.writer.setAttribute( + RUNTIME_BADGE_ATTRIBUTE, + String(data.attributeNewValue), + viewPre, + ); + } else { + conversionApi.writer.removeAttribute( + RUNTIME_BADGE_ATTRIBUTE, + viewPre, + ); + } + }, + {priority: "low"}, + ); + + editor.conversion.for("upcast").add((dispatcher) => { + dispatcher.on( + "element:code", + (evt, data, conversionApi) => { + const viewCode = data.viewItem; + const viewPre = viewCode.parent; + + if (!viewPre || !viewPre.is("element", "pre") || !data.modelRange) { + return; + } + + const codeBlock = Array.from(data.modelRange.getItems()).find( + (item): item is ModelElement => item.is("element", "codeBlock"), + ); + + if (!codeBlock) { + return; + } + + // Older content carries the attributes on `
` instead of ``.
+          const readAttribute = (key: string) =>
+            (viewCode.getAttribute(key) ?? viewPre.getAttribute(key)) as
+              string | undefined;
+
+          const runtime = readAttribute(RUNTIME_DATA_ATTRIBUTE);
+          const {writer} = conversionApi;
+
+          if (runtime) {
+            writer.setAttribute(RUNTIME_MODEL_ATTRIBUTE, runtime, codeBlock);
+
+            if (readAttribute(EDITABLE_DATA_ATTRIBUTE) === "true") {
+              writer.setAttribute(EDITABLE_MODEL_ATTRIBUTE, true, codeBlock);
+            }
+          }
+
+          // General HTML Support preserves unknown attributes of `
`/``.
+          // Drop ours from there so that they are not written out twice.
+          removePreservedAttributes(writer, codeBlock, "htmlContentAttributes");
+          removePreservedAttributes(writer, codeBlock, "htmlPreAttributes");
+        },
+        // Runs after the code block and the General HTML Support converters.
+        {priority: "lowest"},
+      );
+    });
+  }
+
+  private _createToolbarDropdown(): void {
+    const {editor} = this;
+    const codeBlockCommand = editor.commands.get("codeBlock")!;
+    const runtimeCommand = editor.commands.get(RUNTIME_COMMAND)!;
+    const languages = getLanguageDefinitions(editor);
+
+    editor.ui.componentFactory.add("codeBlock", (locale) => {
+      const dropdown = createDropdown(locale, SplitButtonView);
+      const splitButton = dropdown.buttonView;
+
+      splitButton.set({
+        label: this._labels.insertCodeBlock,
+        tooltip: true,
+        icon: IconCodeBlock,
+        isToggleable: true,
+      });
+
+      splitButton
+        .bind("isOn")
+        .to(codeBlockCommand, "value", (value) => !!value);
+
+      splitButton.on("execute", () => {
+        editor.execute("codeBlock", {usePreviousLanguageChoice: true});
+        editor.editing.view.focus();
+      });
+
+      const items = new Collection();
+
+      for (const {language, label} of languages) {
+        const model = new UIModel({
+          _value: language,
+          label,
+          role: "menuitemradio",
+          withText: true,
+        });
+
+        model
+          .bind("isOn")
+          .to(codeBlockCommand, "value", (value) => value === language);
+
+        items.add({type: "button", model});
+      }
+
+      items.add({type: "separator"});
+
+      const settingsModel = new UIModel({
+        _value: SETTINGS_ITEM,
+        label: this._labels.codeBlockSettings,
+        icon: IconCog,
+        withText: true,
+      });
+
+      // The panel configures an existing block, so it needs one under the selection.
+      settingsModel.bind("isEnabled").to(runtimeCommand, "isEnabled");
+
+      items.add({type: "button", model: settingsModel});
+
+      addListToDropdown(dropdown, items, {
+        role: "menu",
+        ariaLabel: this._labels.insertCodeBlock,
+      });
+
+      dropdown.class = "ck-code-block-dropdown";
+      dropdown.bind("isEnabled").to(codeBlockCommand);
+
+      dropdown.on("execute", (evt) => {
+        const value = dropdownItemValue(evt.source);
+
+        if (value === SETTINGS_ITEM) {
+          this._isDismissed = false;
+          this._showSettings();
+
+          return;
+        }
+
+        editor.execute("codeBlock", {
+          language: value ?? undefined,
+          forceValue: true,
+        });
+        editor.editing.view.focus();
+      });
+
+      return dropdown;
+    });
+  }
+
+  private _getSettingsView(): CodeBlockSettingsView {
+    if (this._settingsView) {
+      return this._settingsView;
+    }
+
+    const {editor} = this;
+    const codeBlockCommand = editor.commands.get("codeBlock")!;
+    const runtimeCommand = editor.commands.get(RUNTIME_COMMAND)!;
+    const editableCommand = editor.commands.get(EDITABLE_COMMAND)!;
+
+    const view = new CodeBlockSettingsView(editor.locale, {
+      languages: getLanguageDefinitions(editor),
+      runtimes: this._config.runtimes ?? RUNTIMES,
+      labels: this._labels,
+      direction: this._config.direction ?? editor.locale.uiLanguageDirection,
+    });
+
+    view.canRun = typeof this._config.onRun === "function";
+
+    view
+      .bind("language")
+      .to(codeBlockCommand, "value", (value) =>
+        typeof value === "string" ? value : null,
+      );
+    view.bind("runtime").to(runtimeCommand, "value", asRuntime);
+    view.bind("isEditable").to(editableCommand, "value", Boolean);
+
+    view.languageInput.bind("isEnabled").to(codeBlockCommand, "isEnabled");
+    view.runtimeInput.bind("isEnabled").to(runtimeCommand, "isEnabled");
+    view.editableSwitch.bind("isEnabled").to(editableCommand, "isEnabled");
+
+    this.listenTo(view, "languageChange", (evt, language: string) => {
+      editor.execute("codeBlock", {language, forceValue: true});
+      editor.editing.view.focus();
+    });
+
+    this.listenTo(view, "runtimeChange", (evt, runtime: string | null) => {
+      editor.execute(RUNTIME_COMMAND, {value: runtime});
+      editor.editing.view.focus();
+    });
+
+    this.listenTo(view, "editableChange", (evt, isEditable: boolean) => {
+      editor.execute(EDITABLE_COMMAND, {value: isEditable});
+      editor.editing.view.focus();
+    });
+
+    this.listenTo(view, "run", () => {
+      void this._runCode();
+    });
+
+    this.listenTo(view, "clearOutput", () => this._resetOutput());
+
+    // Closing the panel while the focus is inside it.
+    view.keystrokes.set("Esc", (data, cancel) => {
+      this._hideSettings();
+      cancel();
+    });
+
+    view.render();
+
+    this._settingsView = view;
+
+    return view;
+  }
+
+  private _enableBalloonInteractions(): void {
+    const {editor} = this;
+
+    this.listenTo(editor.ui, "update", () => this._updateSettingsVisibility());
+
+    editor.keystrokes.set("Esc", (data, cancel) => {
+      if (this._isSettingsVisible) {
+        this._hideSettings();
+        cancel();
+      }
+    });
+  }
+
+  private get _isSettingsVisible(): boolean {
+    return !!this._settingsView && this._balloon.hasView(this._settingsView);
+  }
+
+  /**
+   * Keeps the settings panel attached to the code block the selection is in and
+   * away from everything else.
+   */
+  private _updateSettingsVisibility(): void {
+    const block = findCodeBlock(this.editor);
+
+    if (!block) {
+      this._activeBlock = null;
+      this._isDismissed = false;
+      this._removeSettings();
+
+      return;
+    }
+
+    if (block !== this._activeBlock) {
+      this._activeBlock = block;
+      this._isDismissed = false;
+      this._resetOutput();
+    }
+
+    if (this._isDismissed) {
+      return;
+    }
+
+    this._showSettings();
+  }
+
+  private _showSettings(): void {
+    const target = this._getBlockDomElement();
+
+    if (!target) {
+      this._removeSettings();
+
+      return;
+    }
+
+    const view = this._getSettingsView();
+
+    if (this._balloon.hasView(view)) {
+      this._balloon.updatePosition({target});
+
+      return;
+    }
+
+    this._balloon.add({view, position: {target}});
+  }
+
+  private _hideSettings(): void {
+    this._isDismissed = true;
+    this._removeSettings();
+    this.editor.editing.view.focus();
+  }
+
+  private _removeSettings(): void {
+    if (this._isSettingsVisible) {
+      this._balloon.remove(this._settingsView!);
+    }
+  }
+
+  private _getBlockDomElement(): HTMLElement | null {
+    const {editor} = this;
+    const block = findCodeBlock(editor);
+
+    if (!block) {
+      return null;
+    }
+
+    const viewCode = editor.editing.mapper.toViewElement(block);
+    const viewPre = viewCode?.parent;
+    const viewElement =
+      viewPre && viewPre.is("element", "pre") ? viewPre : viewCode;
+
+    if (!viewElement) {
+      return null;
+    }
+
+    const domElement =
+      editor.editing.view.domConverter.mapViewToDom(viewElement);
+
+    return (domElement as HTMLElement | undefined) ?? null;
+  }
+
+  private _resetOutput(): void {
+    if (!this._settingsView) {
+      return;
+    }
+
+    this._settingsView.output = null;
+    this._settingsView.hasError = false;
+  }
+
+  /** Runs the code of the current block, exactly like a reader would. */
+  private async _runCode(): Promise {
+    const view = this._settingsView;
+    const block = findCodeBlock(this.editor);
+    const {onRun} = this._config;
+
+    if (!view || !block || !onRun) {
+      return;
+    }
+
+    const runtime = asRuntime(block.getAttribute(RUNTIME_MODEL_ATTRIBUTE));
+
+    if (!runtime) {
+      return;
+    }
+
+    view.isRunning = true;
+    view.hasError = false;
+    view.output = null;
+
+    try {
+      const output = await onRun({runtime, code: getCodeBlockText(block)});
+
+      // The panel may have been destroyed while the code was running.
+      if (this._settingsView !== view) {
+        return;
+      }
+
+      view.output = output?.trim() ? output : this._labels.noOutput;
+    } catch (error) {
+      if (this._settingsView !== view) {
+        return;
+      }
+
+      view.hasError = true;
+      view.output =
+        error instanceof Error && error.message
+          ? error.message
+          : this._labels.runFailed;
+    } finally {
+      if (this._settingsView === view) {
+        view.isRunning = false;
+      }
+    }
+  }
+}
+
+function asRuntime(value: unknown): string | null {
+  return typeof value === "string" && value ? value : null;
+}
+
+/**
+ * Removes the runtime attributes General HTML Support preserved on the model, so the
+ * plugin stays the single source of truth for them.
+ */
+function removePreservedAttributes(
+  writer: ModelWriter,
+  block: ModelElement,
+  attributeName: string,
+): void {
+  const preserved = block.getAttribute(attributeName) as
+    Record | undefined;
+
+  if (!preserved?.attributes) {
+    return;
+  }
+
+  const attributes = {...(preserved.attributes as Record)};
+  const hadRuntimeAttributes = [
+    RUNTIME_DATA_ATTRIBUTE,
+    EDITABLE_DATA_ATTRIBUTE,
+  ].some((key) => key in attributes);
+
+  if (!hadRuntimeAttributes) {
+    return;
+  }
+
+  delete attributes[RUNTIME_DATA_ATTRIBUTE];
+  delete attributes[EDITABLE_DATA_ATTRIBUTE];
+
+  const rest: Record = {...preserved, attributes};
+
+  if (Object.keys(attributes).length === 0) {
+    delete rest.attributes;
+  }
+
+  if (Object.keys(rest).length === 0) {
+    writer.removeAttribute(attributeName, block);
+  } else {
+    writer.setAttribute(attributeName, rest, block);
+  }
+}
diff --git a/src/features/articles/components/article-editor/plugins/runnable-code-block/utils.ts b/src/features/articles/components/article-editor/plugins/runnable-code-block/utils.ts
new file mode 100644
index 00000000..99c09a4a
--- /dev/null
+++ b/src/features/articles/components/article-editor/plugins/runnable-code-block/utils.ts
@@ -0,0 +1,55 @@
+import {type Editor, type ModelElement} from "ckeditor5";
+
+/** Model attribute holding the runtime a code block is executed with. */
+export const RUNTIME_MODEL_ATTRIBUTE = "executable";
+/** Model attribute marking a runnable code block as editable by the reader. */
+export const EDITABLE_MODEL_ATTRIBUTE = "executableEditable";
+
+/**
+ * Data attributes both are downcasted to. They live on the `` element, which
+ * is what the model `codeBlock` element is mapped to, and what the article body
+ * parser reads when rendering the published article.
+ */
+export const RUNTIME_DATA_ATTRIBUTE = "data-executable";
+export const EDITABLE_DATA_ATTRIBUTE = "data-executable-editable";
+
+/** Editing-only attribute used to render the runtime badge on the `
` element. */
+export const RUNTIME_BADGE_ATTRIBUTE = "data-runtime";
+
+export const RUNTIME_COMMAND = "codeBlockRuntime";
+export const EDITABLE_COMMAND = "codeBlockEditable";
+
+/**
+ * Reads the value carried by a dropdown list item. `addListToDropdown()` copies every
+ * model property onto the button view, so `_value` is available on the event source.
+ */
+export function dropdownItemValue(source: unknown): string | null {
+  return (source as {_value?: string | null})._value ?? null;
+}
+
+/** Returns the code block the selection is currently anchored in (if any). */
+export function findCodeBlock(editor: Editor): ModelElement | undefined {
+  const {selection} = editor.model.document;
+
+  return Array.from(selection.getSelectedBlocks()).find((block) =>
+    block.is("element", "codeBlock"),
+  );
+}
+
+/**
+ * Flattens a code block back to plain text. `softBreak` elements are the model
+ * representation of the new lines inside a code block.
+ */
+export function getCodeBlockText(block: ModelElement): string {
+  let text = "";
+
+  for (const child of block.getChildren()) {
+    if (child.is("$text") || child.is("$textProxy")) {
+      text += child.data;
+    } else if (child.is("element", "softBreak")) {
+      text += "\n";
+    }
+  }
+
+  return text;
+}
diff --git a/src/features/code-highlight/CodeHighlight.tsx b/src/features/code-highlight/CodeHighlight.tsx
index d3ed89d5..9cc3e418 100644
--- a/src/features/code-highlight/CodeHighlight.tsx
+++ b/src/features/code-highlight/CodeHighlight.tsx
@@ -42,7 +42,20 @@ const editorSetup = [
   keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
 ];
 
-function CodeHighlight({code, language, executable}) {
+type Executable = {
+  /** The runtime the snippet is executed with, e.g. `go-1.24`. */
+  value: string;
+  /** Set by the editor when readers are allowed to change the code before running it. */
+  editable?: string | boolean;
+} | null;
+
+type Props = {
+  code: string;
+  language?: string;
+  executable?: Executable;
+};
+
+function CodeHighlight({code, language, executable}: Props) {
   const t = useTranslations();
   const editorRef = useRef(null);
   const containerRef = useRef(null);
@@ -53,6 +66,13 @@ function CodeHighlight({code, language, executable}) {
   const [mounted, setMounted] = useState(false);
   const languageCompartmentRef = useRef(new Compartment());
 
+  const isRunnable = Boolean(executable?.value);
+  // Editing the snippet only makes sense when there is a runtime to re-run it with,
+  // and only when the author enabled it for this block.
+  const isEditable =
+    isRunnable &&
+    (executable?.editable === true || executable?.editable === "true");
+
   useEffect(() => {
     setMounted(true);
     const getScheme = () =>
@@ -83,6 +103,8 @@ function CodeHighlight({code, language, executable}) {
         ...editorSetup,
         languageCompartmentRef.current.of([]),
         themeCompartment.of(colorScheme === "dark" ? monokai : eclipse),
+        EditorState.readOnly.of(!isEditable),
+        EditorView.editable.of(isEditable),
         EditorView.updateListener.of((update) => {
           if (update.docChanged) {
             setEditableCode(update.state.doc.toString());
@@ -103,7 +125,7 @@ function CodeHighlight({code, language, executable}) {
     return () => {
       editor.destroy();
     };
-  }, [code, colorScheme, mounted]);
+  }, [code, colorScheme, isEditable, mounted]);
 
   useEffect(() => {
     // try to find the language description based on the provided language name
@@ -147,7 +169,7 @@ function CodeHighlight({code, language, executable}) {
   const publish = useWsPublish();
 
   const runCode = useCallback(async () => {
-    if (running) return;
+    if (running || !executable?.value) return;
 
     setRunning(true);
     setOutput("");
@@ -215,7 +237,7 @@ function CodeHighlight({code, language, executable}) {
               
             
 
-            {hasChanged && (
+            {isEditable && hasChanged && (
               
                 
             )}
 
-            {executable && (
+            {isRunnable && (
               ",
+    "runFailed": "Running the code failed.",
+    "insertCodeBlock": "Insert code block",
+    "codeBlockSettings": "Code block settings"
   },
   "dashboard": {
     "brand": "Tarhche",
diff --git a/src/i18n/dictionaries/fa.json b/src/i18n/dictionaries/fa.json
index f477c687..8dd5ad94 100644
--- a/src/i18n/dictionaries/fa.json
+++ b/src/i18n/dictionaries/fa.json
@@ -285,7 +285,16 @@
     "unexpectedError": "خطای غیرمنتظره",
     "errorMessage": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
     "programOutput": "خروجی برنامه:",
-    "contentPlaceholder": "محتوا..."
+    "contentPlaceholder": "محتوا...",
+    "language": "زبان",
+    "runtime": "زمان اجرا",
+    "noRuntime": "بدون زمان اجرا",
+    "editableCode": "کد قابل ویرایش",
+    "clearOutput": "پاک کردن خروجی",
+    "noOutput": "<بدون خروجی>",
+    "runFailed": "اجرای کد ناموفق بود.",
+    "insertCodeBlock": "درج بلوک کد",
+    "codeBlockSettings": "تنظیمات بلوک کد"
   },
   "dashboard": {
     "brand": "طرح‌چه",