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 (
` 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": "طرحچه",