Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
"katex",
"laynezh",
"marktext",
"mathblock",
"muya",
"Setext",
"snabbdom",
"snapsvg",
"Tkaixiang",
"TOGGLEABLE",
"webfontloader"
]
}
18 changes: 0 additions & 18 deletions packages/muya/src/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,18 +176,6 @@ class History {
this._lastRecorded = 0;
}

/**
* Return a deep, JSON-serializable snapshot of the undo/redo history.
*
* The ot-json1 ops are plain JSON arrays and are deep-cloned as-is.
* Selections drop their live `anchorBlock` / `focusBlock` references and
* keep only the serializable `anchorPath` / `focusPath` + offsets; the
* caret is re-resolved from those paths on restore (see
* `_toSerializableSelection`). The result can be `JSON.stringify`-d, stored
* on a desktop tab, and handed back to `setHistory` to restore the exact
* undo/redo state — `setHistory(getHistory())` followed by `undo()`
* reproduces the prior document state.
*/
getHistory(): ISerializedHistory {
return {
stack: {
Expand All @@ -201,12 +189,6 @@ class History {
};
}

/**
* Restore a snapshot previously produced by `getHistory`. Replaces the
* undo/redo stacks and recording bookkeeping. The restored selections are
* path-only; `Selection.setSelection` / `_setCursor` resolve the live
* block from the path when the op is later applied by `undo` / `redo`.
*/
setHistory(history: ISerializedHistory) {
this._stack = {
undo: history.stack.undo.map(op => this._fromSerializableOperation(op)),
Expand Down
121 changes: 2 additions & 119 deletions packages/muya/src/muya.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
locateSentinelOffsets,
resolveSentinelCursor,
} from './selection/offsetCursor';
import { getTOC } from './state/getTOC';
import { isAnyListState, isAtxHeadingState, isCodeBlockState } from './state/types';
import { Ui } from './ui/ui';
import { deepClone } from './utils';
Expand Down Expand Up @@ -157,10 +156,6 @@ export class Muya {
this._bindFocusBlurEvents();
}

// Expose `focus` / `blur` lifecycle events so external SDK consumers can
// react to editor focus changes. Routed
// through attachDOMEvent so cleanup is automatic via detachAllDomEvents
// in destroy().
private _bindFocusBlurEvents() {
this.eventCenter.attachDOMEvent(this.domNode, 'focus', () => {
this.eventCenter.emit('focus');
Expand All @@ -180,15 +175,6 @@ export class Muya {
}
}

/**
* Switch the editor's UI language at runtime. Swaps the i18n resources, then
* re-renders the block tree so already-mounted blocks pick up the new
* translation. The inline placeholder hints (quick-insert
* "Type / to insert…", code-block language, math, front matter) are DOM
* attributes baked once in each block's constructor; without the re-render
* they would keep the old language until the block was next recreated.
* History and the caret are preserved across the refresh (`_forceRender`).
*/
locale(object: ILocale) {
this.i18n.locale(object);
if (this.editor.scrollPage)
Expand Down Expand Up @@ -224,17 +210,8 @@ export class Muya {
return this.editor.jsonState.getMarkdown();
}

/**
* Return a flat table of contents for the current document.
*
* Only top-level atx / setext headings are surfaced; nested
* headings inside blockquotes / list items are ignored. `content` is the
* raw heading text (inline markdown not
* parsed); `slug` is a stable per-block identifier; `githubSlug` is
* the GitHub-style anchor derived from `content`.
*/
getTOC(): ITocItem[] {
return getTOC(this);
return this.editor.jsonState.getTOC();
}

undo() {
Expand All @@ -245,24 +222,10 @@ export class Muya {
this.editor.history.redo();
}

/**
* Return a JSON-serializable snapshot of the undo/redo history.
*
* Used by the desktop shell to persist each tab's editing history across
* tab switches: read it before deactivating a tab, store it, and hand it
* back to `setHistory` when the tab is re-selected. The ot-json1 ops are
* deep-cloned plain JSON; selections are reduced to their serializable
* paths/offsets (live block references are dropped and re-resolved on
* restore). Lossless round-trip: `setHistory(getHistory())` then `undo()`
* reproduces the prior document state.
*/
getHistory() {
return this.editor.history.getHistory();
}

/**
* Restore a history snapshot previously produced by `getHistory`.
*/
setHistory(history: ReturnType<Muya['getHistory']>) {
this.editor.history.setHistory(history);
}
Expand Down Expand Up @@ -344,17 +307,6 @@ export class Muya {
return true;
}

/**
* Update editor options at runtime: merges `options` into `muya.options`,
* reflects the container-level ones
* (spellcheck, quick-insert hint), and — when `forceRender` is set — fully
* re-renders the document so render-affecting options (superSubScript,
* disableHtml, frontmatterType, codeBlockLineNumbers, …) take effect. When a
* PARSE-affecting option changes (GitLab compatibility, math, footnote,
* frontMatter, …) the document is first re-parsed from markdown, since those
* options decide block structure the cached state cannot reflect. Unlike
* `setContent`, the undo history is preserved; the cursor is restored by path.
*/
setOptions(options: Partial<IMuyaOptions>, forceRender = false) {
Object.assign(this.options, options);

Expand All @@ -378,10 +330,6 @@ export class Muya {
if (!forceRender)
return;

// A parse-affecting option re-classifies block structure, so re-parse the
// current markdown into a fresh state before the render rebuild. This
// updates `jsonState` in place without clearing history (only `setContent`
// clears it), keeping setOptions' history-preserving contract.
if (Object.keys(options).some(key => PARSE_AFFECTING_OPTIONS.has(key as keyof IMuyaOptions))) {
const { jsonState } = this.editor;
jsonState.setContent(jsonState.markdownToState(this.getMarkdown()));
Expand All @@ -390,21 +338,10 @@ export class Muya {
this._forceRender();
}

/**
* Rebuild the whole block tree from its current state, preserving the undo
* history (only `setContent` clears it; `updateState` does not) and
* restoring the caret by path. Re-running every block constructor re-applies
* the i18n-driven DOM attributes (placeholder hints, etc.), so this also
* serves as the locale refresh. Shared by `setOptions(..., forceRender)` and
* `locale()`.
*/
private _forceRender() {
const selection = this.editor.selection.getSelection();
this.editor.scrollPage?.updateState(this.getState());
// Restore the caret on the rebuilt tree by resolving the block at the
// saved path and setting the cursor on it directly. (Passing only a
// path to setSelection does not work — Selection._setCursor needs a
// concrete block's domNode; a bare queryBlock result is not a Node.)

if (selection && selection.isSelectionInSameBlock) {
const begin = Math.min(selection.anchor.offset, selection.focus.offset);
const end = Math.max(selection.anchor.offset, selection.focus.offset);
Expand Down Expand Up @@ -436,12 +373,6 @@ export class Muya {
this.editor.focus();
}

/**
* Toggle focus mode. When enabled,
* every top-level block except the one holding the cursor is dimmed via the
* `mu-focus-mode` class on the editor container; the dimming itself lives in
* the stylesheet (`.mu-focus-mode .mu-container > * { opacity }`).
*/
setFocusMode(focusMode: boolean) {
if (focusMode)
this.domNode.classList.add(CLASS_NAMES.MU_FOCUS_MODE);
Expand All @@ -455,12 +386,6 @@ export class Muya {
this.editor.selection.selectAll();
}

/**
* Toggle an inline format on the current selection.
* @param type One of strong/em/u/del/inline_code/link/image/inline_math/
* sub/sup/mark/clear (and html_tag aliases). No-op when the selection is
* not inside a single formattable block.
*/
format(type: string) {
const { selection } = this.editor;

Expand Down Expand Up @@ -503,12 +428,6 @@ export class Muya {
anchorBlock.format(type);
}

/**
* Apply an inline format to every formattable leaf in a cross-block
* selection, in document order. Non-formattable leaves (code/math/html/
* frontmatter/diagram) are skipped; link/image are no-ops across blocks
* (and the menu disables them). Ported from muyajs's multi-block format.
*/
private _formatAcrossBlocks(type: string) {
if (type === 'link' || type === 'image')
return;
Expand Down Expand Up @@ -731,25 +650,12 @@ export class Muya {
return this.editor.clipboard.pasteImage(src);
}

/**
* The outer-most block at the current cursor — the target for block-level
* operations. Uses the persisted active content block (which survives the
* menu/IPC round-trip), falling back to the selection anchor.
*/
private _outmostBlockAtCursor(): Parent | null {
const content = this.editor.activeContentBlock ?? this.editor.selection.anchorBlock;

return content?.outMostBlock ?? null;
}

/**
* The immediate block-level parent of the active content leaf — the
* paragraph/heading block that directly wraps the cursor. Used by the
* context-menu "Insert Paragraph Before/After" path: a new paragraph lands
* as an inner sibling inside a list item / blockquote rather than jumping out to
* the outermost container. Uses the persisted active content block (which
* survives the menu/IPC round-trip), falling back to the selection anchor.
*/
private _immediateBlockAtCursor(): Parent | null {
const content = this.editor.activeContentBlock ?? this.editor.selection.anchorBlock;

Expand Down Expand Up @@ -1000,34 +906,11 @@ export class Muya {
cursorBlock?.setCursor(0, 0, true);
}

/**
* Insert a GFM table at the current cursor. An EMPTY cursor block is
* replaced in place; a NON-empty block keeps its content and the table is
* inserted directly AFTER it inside its own container (e.g. right after the
* paragraph in a list item, not after the whole list) — matching the
* Paragraph menu's insert-below rule for non-convertible blocks. Pass
* `{ replace: true }` to always
* replace the cursor block — the in-editor grid picker uses this to consume
* its trigger block (a `/table` quick-insert line or the empty paragraph the
* front-menu offers). The table has `rows` rows × `columns` columns with the
* first row as the header; every cell is empty with `align: 'none'`. The
* cursor lands in the first cell. No-op when there is no current block.
* `rows`/`columns` are coerced to integers and clamped to a valid GFM shape
* (`rows >= 2`, `columns >= 1`) so invalid input (e.g. `rows: 0`, non-finite,
* or fractional values) still yields a usable table instead of an invalid
* state.
*/
createTable({ rows, columns }: { rows: number; columns: number }, { replace = false }: { replace?: boolean } = {}) {
const block = this._immediateBlockAtCursor();
if (!block)
return;

// Coerce and clamp to a valid GFM table shape. A GFM table needs a
// header row plus at least one body row (rows >= 2) and at least one
// column (columns >= 1). Garbage input (NaN/Infinity/floats/negatives)
// is normalised rather than producing an invalid state — `rows = 0`
// would otherwise build a table with no rows and crash `columnCount`
// (which reads `firstChild.firstChild`).
const safeRows = Math.max(2, Number.isFinite(rows) ? Math.floor(rows) : 0);
const safeColumns = Math.max(1, Number.isFinite(columns) ? Math.floor(columns) : 0);

Expand Down
13 changes: 0 additions & 13 deletions packages/muya/src/state/getTOC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ interface IHeadingBlock extends Parent {
meta: { level: number };
}

// Stable per-block slug. marktext exposed `block.key` for the same purpose
// (a DOM-id-friendly anchor that survives across `getTOC()` calls). The new
// block tree has no `.key`, so we lazily assign one and cache by block
// instance — same heading → same slug, even across repeated invocations.
// Different muya instances build different blocks, so there is no risk of
// cross-instance collision.
const slugCache = new WeakMap<Parent, string>();

export function stableSlug(block: Parent): string {
Expand All @@ -39,10 +33,6 @@ export function getTOC(muya: Muya): ITocItem[] {

const items: ITocItem[] = [];

// Walk the linked list directly instead of `forEach`, which materialises
// every top-level block into an array via `[...iterator()]` (see
// `LinkedList.forEach`). For large documents the intermediate array is
// pure waste.
for (const node of scrollPage.children.iterator()) {
const { blockName } = node;
if (blockName !== 'atx-heading' && blockName !== 'setext-heading')
Expand All @@ -52,9 +42,6 @@ export function getTOC(muya: Muya): ITocItem[] {
const head = block.children.head as Content | null;
const text = head?.text ?? '';

// 9cb2cbe8: `\s` instead of literal ASCII space so unicode
// whitespace / tabs before or between the `#` markers also strip
// cleanly.
const content = blockName === 'setext-heading'
? text.trim()
: text.replace(/^\s*#{1,6}\s+/, '').trim();
Expand Down
7 changes: 6 additions & 1 deletion packages/muya/src/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import type { TState } from './types';
import * as json1 from 'ot-json1';
import { deepClone } from '../utils';
import logger from '../utils/logger';
import { MarkdownToState } from './markdownToState';
import { getTOC } from './getTOC';

import { MarkdownToState } from './markdownToState';
import StateToMarkdown from './stateToMarkdown';

const debug = logger('jsonState:');
Expand Down Expand Up @@ -212,6 +213,10 @@ class JSONState {
return this.getMarkdownFromState(this.getState());
}

getTOC() {
return getTOC(this._muya);
}

// Serialize an ARBITRARY state array to markdown with the same generator
// `getMarkdown` uses. Used by `Muya.getCursorOffset` to serialize a
// sentinel-bearing state clone WITHOUT mutating the live `_state`.
Expand Down
Loading