跳至主要内容

狀態管理

如果您希望將編輯過的代碼塊存儲到資料庫而不是瀏覽器的本地存儲中,您可以通過提供自己的狀態管理來同步到 docusaurus-live-brython。這可以通過交換 CodeEditor/hooks/useScriptCodeEditor/WithScript/ScriptContext 來完成。要在 docusaurus-live-brython 和您的狀態管理之間進行同步,您可以使用 React 的 useSyncExternalStore 鉤子。

Mobx

👉 演示倉庫: lebalz/docusaurus-mobx-live-code

假設您有一個 DocumentStore 來存儲 Document(其中包含代碼塊),並且您希望將 DocumentStore 的更改同步到 docusaurus-live-brython。這使您可以將代碼塊存儲在資料庫中。

src/stores/DocumentStore
src/stores/documentStore.ts
import { action, computed, makeObservable, observable, override } from 'mobx';
import { RootStore } from './rootStore';
import { computedFn } from 'mobx-utils';
import Document from '../models/Document';
import { type RouterType } from '@docusaurus/types';

export class DocumentStore {
readonly root: RootStore;
static accessor libDir: string = '/bry-libs/';
static syncMaxOnceEvery: number = 1000;
static router: RouterType = 'browser';


documents = observable.array<Document>([]);

constructor(root: RootStore) {
this.root = root;
}

@action
addDocument(document: Document) {
this.documents.push(document);
}

find = computedFn(
function (this: DocumentStore, id?: string): Document | undefined {
if (!id) {
return;
}
return this.documents.find((d) => d.id === id) as Document | undefined;
},
{ keepAlive: true }
);

}
src/models/Document.ts
src/models/Document.ts
import { action, computed, observable, reaction } from 'mobx';
import { DocumentStore } from '../stores/documentStore';
import { v4 as uuidv4 } from 'uuid';
import { sanitizePyScript, splitPreCode } from 'docusaurus-live-brython/theme/CodeEditor/WithScript/helpers';
import throttle from 'lodash/throttle';
import {
CANVAS_OUTPUT_TESTER,
DOM_ELEMENT_IDS,
GRAPHICS_OUTPUT_TESTER,
GRID_IMPORTS_TESTER,
TURTLE_IMPORTS_TESTER
} from 'docusaurus-live-brython/theme/CodeEditor/constants';
import {
type InitState,
type LogMessage,
type Version,
Status
} from 'docusaurus-live-brython/theme/CodeEditor/WithScript/Types';
import { runCode } from 'docusaurus-live-brython/theme/CodeEditor/WithScript/bryRunner';


export default class Document {
readonly store: DocumentStore;
readonly isVersioned: boolean;
readonly _pristineCode: string;
readonly id: string;
readonly codeId: string;
readonly source: 'local' | 'remote';
readonly _lang: 'py' | string;
readonly preCode: string;
readonly postCode: string;
@observable accessor createdAt: Date;
@observable accessor updatedAt: Date;
@observable accessor code: string;
@observable accessor isExecuting: boolean;
@observable accessor showRaw: boolean;
@observable accessor isLoaded: boolean;
@observable accessor status: Status = Status.IDLE;
@observable accessor graphicsModalExecutionNr: number; /* 0 = closed, >0 = open */
@observable accessor isPasted: boolean = false;
versions = observable.array<Version>([], {deep: false});
logs = observable.array<LogMessage>([], {deep: false});


constructor(props: InitState, store: DocumentStore) {
this.store = store;
this.id = props.id || uuidv4();
this.source = props.id ? 'remote' : 'local';
this._lang = props.lang;
this.isExecuting = false;
this.showRaw = false;
this.isLoaded = true;
this.isVersioned = props.versioned && this.source === 'remote';
this._pristineCode = props.code;
this.code = props.code;
if (this.isVersioned) {
this.versions.push({code: props.code, createdAt: new Date(), version: 1});
}
this.preCode = props.preCode;
this.postCode = props.postCode;
this.codeId = `code.${props.title || props.lang}.${this.id}`.replace(/(-|\.)/g, '_');
this.updatedAt = new Date();
this.createdAt = new Date();
}

@action
clearLogMessages() {
this.logs.clear();
}

@action
setExecuting(isExecuting: boolean) {
this.isExecuting = isExecuting;
}

@action
addLogMessage(message: LogMessage) {
this.logs.push({output: message.output, timeStamp: Date.now(), type: message.type});
}

@action
setCode(code: string, action?: 'insert' | 'remove' | string) {
if (this.isPasted && action === 'remove') {
return;
}
this.code = code;
const updatedAt = new Date();
this.updatedAt = updatedAt;
if (this.isVersioned) {
this.addVersion({
code: code,
createdAt: updatedAt,
version: this.versions.length + 1,
pasted: this.isPasted
});
}
if (this.isPasted) {
this.isPasted = false;
}

/**
* call the api to save the code...
*/
}

@action
loadVersions() {
// nop
}


@action
_addVersion(version: Version) {
if (!this.isVersioned) {
return;
}
this.versions.push(version);
}

addVersion = throttle(
this._addVersion,
DocumentStore.syncMaxOnceEvery,
{leading: false, trailing: true}
);

@computed
get _codeToExecute() {
return `${this.preCode}\n${this.code}\n${this.postCode}`;
}

@action
execScript() {
if (this.hasGraphicsOutput) {
this.graphicsModalExecutionNr = this.graphicsModalExecutionNr + 1;
}
this.isExecuting = true;
runCode(this.code, this.preCode, this.postCode, this.codeId, DocumentStore.libDir, DocumentStore.router);
}

@action
saveNow() {
/**
* call the api to save the code...
*/
}

/**
* stop the script from running
* wheter the script is running or not is derived from the
* `data--start-time` attribute on the communicator element.
* This is used in combination with the game loop
*/
@action
stopScript() {
const code = document?.getElementById(DOM_ELEMENT_IDS.communicator(this.codeId));
if (code) {
code.removeAttribute('data--start-time');
}
}

@computed
get hasGraphicsOutput() {
return this.hasTurtleOutput || this.hasCanvasOutput || GRAPHICS_OUTPUT_TESTER.test(this._codeToExecute);
}

@computed
get hasTurtleOutput() {
return TURTLE_IMPORTS_TESTER.test(this._codeToExecute);
}


@computed
get hasCanvasOutput() {
return CANVAS_OUTPUT_TESTER.test(this._codeToExecute) || GRID_IMPORTS_TESTER.test(this._codeToExecute);
}

@computed
get hasEdits() {
return this.code !== this.pristineCode;
}

@computed
get versionsLoaded() {
return true;
}


@action
closeGraphicsModal() {
this.graphicsModalExecutionNr = 0;
}

subscribe(listener: () => void, selector: keyof Document) {
if (Array.isArray(this[selector])) {
return reaction(
() => (this[selector] as Array<any>).slice().length,
(curr, prev) => {
listener();
}
);
}
return reaction(
() => this[selector],
listener
);
}

@computed
get pristineCode() {
return this._pristineCode;
}

@action
setIsPasted(isPasted: boolean) {
this.isPasted = isPasted;
};
@action
setShowRaw(showRaw: boolean) {
this.showRaw = showRaw;
};
@action
setStatus(status: Status) {
this.status = status;
};

get lang() {
if (this._lang === 'py') {
return 'python';
}
return this._lang;
}
}

使用 useSyncExternalStore 鉤子,您可以設置 docusaurus-live-brython 和由 mobx 跟蹤的 Document 模型之間的同步。

src/theme/CodeEditor/hooks/useScript.ts
import Document from "@site/src/models/Document";
import { useCallback, useSyncExternalStore } from "react";
export const useScript = <T extends keyof Document>(model: Document, selector: T): Document[T] => {
const isArray = Array.isArray(model[selector]);
if (isArray) {
// Arrays (logs and versions) are treated differently, see the details below
}
return useSyncExternalStore(
useCallback((callback) => {
return model.subscribe(callback, selector);
}, [model, selector]),
useCallback(
() => {
return model[selector];
},
[model, selector]
)
);
}

model 提供了一個 subscribe 函式,用於設置對指定 selector 變化的反應。

src/models/Document.ts
    subscribe(listener: () => void, selector: keyof Document) {
if (Array.isArray(this[selector])) {
return reaction(
() => (this[selector] as Array<any>).slice().length,
listener
);
}
return reaction(
() => this[selector],
listener
);
}
完整 useScript.ts 來源
src/theme/CodeEditor/hooks/useScript.ts
import Document from "@site/src/models/Document";
import { useCallback, useSyncExternalStore } from "react";
/**
* A utility function to create a stable snapshot wrapper
* it is meant to only track the length of the array and treats
* two arrays with the same length as equal
*/
const useStableSnapshot = (getSnapshot: () => Array<any>) => {
let prevLength: number = -1;
let prevResult: Array<any>;
return () => {
const result = getSnapshot();
if (result.length !== prevLength) {
prevLength = result.length;
prevResult = result.slice();
}
return prevResult;
};
};

export const useScript = <T extends keyof Document>(model: Document, selector: T): Document[T] => {
const isArray = Array.isArray(model[selector]);
if (isArray) {
/**
* arrays are treated differently as they are expected to be
* immutable, so we can use a stable snapshot to track changes
* in the array
*/
return useSyncExternalStore(
useCallback((callback) => {
return model.subscribe(callback, selector);
}, [model, selector]),
useCallback(
useStableSnapshot(() => {
return model[selector] as Array<any>;
}) as () => Document[T],
[model, selector]
)
);
}
return useSyncExternalStore(
useCallback((callback) => {
return model.subscribe(callback, selector);
}, [model, selector]),
useCallback(
() => {
return model[selector];
},
[model, selector]
)
);
}

由於 docusaurus-live-brython 中的狀態通過 ScriptContext 傳遞,您需要交換 ScriptContext 以將 mobx 的 Document 提供給 CodeEditor 及其所有組件。

這裡您可以看到,當 ScriptContext 被掛載時創建了 Document 並將其添加到 DocumentStore

src/theme/CodeEditor/WithScript/ScriptContext.tsx
import React from "react";
import { usePluginData } from "@docusaurus/useGlobalData";
import { observer } from "mobx-react-lite";
import { InitState } from "docusaurus-live-brython/theme/CodeEditor/WithScript/Types";
import Document from "@site/src/models/Document";
import { useStore } from "@site/src/hooks/useStore";
import BrowserOnly from '@docusaurus/BrowserOnly';
import CodeBlock from '@theme/CodeBlock';
export const Context = React.createContext<Document | undefined>(undefined);
import { v4 as uuidv4 } from 'uuid';

const ScriptContext = observer((props: InitState & { children: React.ReactNode; }) => {
const [id, setId] = React.useState<string>(props.id || uuidv4());
const documentStore = useStore('documentStore');
React.useEffect(() => {
const doc = documentStore.find(id);
if (doc) {
return;
}
const document = new Document({...props, id: id}, documentStore);
documentStore.addDocument(document);
}, [props.id, documentStore]);

return (
<BrowserOnly fallback={<CodeBlock language={props.lang}>{props.code}</CodeBlock>}>
{() => {
if (!documentStore.find(id)) {
return (<CodeBlock language={props.lang}>{props.code}</CodeBlock>);
}
return (
<Context.Provider value={documentStore.find(id)}>
{props.children}
</Context.Provider>
);
}}
</BrowserOnly>
);
});

export default ScriptContext;