apexcharts
Version:
A JavaScript Chart Library
542 lines (489 loc) • 18.7 kB
JavaScript
// @ts-check
import Utils from '../../utils/Utils'
import { Environment } from '../../utils/Environment.js'
import { captureViewState, applyViewInteraction } from '../state/ViewState'
/**
* Rewind (#3): in-memory undo/redo history.
*
* A checkpoint stack over the mutable `w` engine. Every committed change
* produces one checkpoint = { view: ViewState, config: <COW clone>, ... }
* appended after the current pointer (truncating any redo tail). undo() applies
* the previous checkpoint; redo() the next.
*
* Design properties:
* - **In-memory, function-preserving.** Snapshots use `Utils.clone` (which
* passes functions through by reference), NOT JSON, so formatters,
* event callbacks and function-colors survive an undo. (Perspectives, by
* contrast, must strip functions to serialise; that is the one real
* difference between the two features.)
* - **Event-driven, near-zero core intrusion.** Capture is triggered purely
* by subscribing to the existing emitter ('mounted' for the baseline,
* 'updated'/'scrolled' for changes, 'dataPointSelection' for selection).
* There are NO edits to _updateOptions / _updateSeries.
* - **Copy-on-write series.** Cloning the (large) series data on every
* zoom/toggle is wasteful, so a checkpoint shares the previous one's cloned
* series unless w.config.series identity changed.
* - **Robust against async 'updated'.** 'updated' fires inside mount().then()
* (async), so a restore's own re-renders emit 'updated' after the call
* returns. A settle-timer holds `applying` true across the whole restore
* burst, and a signature dedup at commit time makes any leaked capture a
* no-op (the state equals the just-restored checkpoint).
*
* Eager module (like drilldown): constructed once in InitCtxVariables so the
* stack survives update() and is dropped on destroy(). Opt-in via
* chart.history.enabled; the config (enabled/keyboard/maxDepth/coalesceMs) is
* re-read on every mounted/updated event, so it can be turned on or off at
* runtime via updateOptions (the stack is kept across a disable for a later
* re-enable).
*
* Public API (namespaced under chart.history):
* chart.history.undo() / redo() / canUndo() / canRedo()
* chart.history.jump(id) / clear() / entries()
* chart.history.transaction(fn, { label }) // group multiple edits
* chart.on('historyChange', (ctx, state) => {})
*
* @module History
*/
/**
* True when the node is a text-editing target whose own undo must win over the
* chart's (a form control or a contenteditable, e.g. the ink inline editor).
* @param {any} node
* @returns {boolean}
*/
function isEditableTarget(node) {
if (!node) return false
const tag = node.tagName
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
node.isContentEditable === true
)
}
export default class History {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
/**
* Checkpoint stack; index 0 is the baseline (initial render).
* @type {Array<any>}
*/
this.stack = []
/** Current position in the stack (-1 before the baseline is captured). */
this.pointer = -1
/** Suppress capture while a restore (undo/redo/jump) is applying. */
this.applying = false
/** Suppress intermediate captures inside a transaction. */
this._batching = false
this._counter = 0
/** @type {any} */ this._coalesceTimer = null
/** @type {any} */ this._settleTimer = null
/** @type {string|undefined} */ this._pendingLabel = undefined
/** Keydown target (kept so teardown can detach it). @type {any} */
this._keydownTarget = null
/** Pointer-tracking target for engagement (teardown detaches it). @type {any} */
this._pointerTarget = null
/**
* Whether the chart is the current keyboard-shortcut target: true once a
* pointer goes down inside it, false once one goes down elsewhere. Lets
* Ctrl+Z reach this chart after a drag/zoom even though those gestures
* preventDefault focus (so document.activeElement stays on <body>).
*/
this._engaged = false
this._wired = false
this._readConfig()
this._onMounted = this._onMounted.bind(this)
this._onUpdated = this._onUpdated.bind(this)
this._onSelection = this._onSelection.bind(this)
this._onKeyDown = this._onKeyDown.bind(this)
this._onPointerDown = this._onPointerDown.bind(this)
// Self-wire. w.globals.events outlives updates, so listeners persist.
this.init()
}
/**
* (Re)read chart.history config. Called at construction and again on every
* mounted/updated event so `updateOptions({ chart: { history: {...} } })`
* takes effect at runtime: enabling wires the keyboard + starts capturing,
* disabling stops capturing (the stack is kept for a later re-enable).
*/
_readConfig() {
const w = this.w
const cfg = (w.config.chart && w.config.chart.history) || {}
this.enabled = !!cfg.enabled
this.maxDepth = cfg.maxDepth > 0 ? cfg.maxDepth : 100
this.coalesceMs = cfg.coalesceMs != null ? cfg.coalesceMs : 250
this.keyboard = cfg.keyboard !== false
}
/** Re-sync config, then wire/unwire the keyboard to match. */
_syncConfig() {
this._readConfig()
if (this.enabled && this.keyboard) this._wireKeyboard()
else this._unwireKeyboard()
}
init() {
if (this._wired) return
this._wired = true
// The bus listeners are ALWAYS wired (cheap no-ops while disabled) so a
// runtime enable via updateOptions is picked up by the next 'updated'.
this.ctx.addEventListener('mounted', this._onMounted)
this.ctx.addEventListener('updated', this._onUpdated)
this.ctx.addEventListener('scrolled', this._onUpdated)
this.ctx.addEventListener('dataPointSelection', this._onSelection)
if (this.enabled && this.keyboard) this._wireKeyboard()
}
/**
* Keyboard: Cmd/Ctrl+Z = undo, Shift+Cmd/Ctrl+Z or Ctrl+Y = redo. Bound on
* the document (not the chart element) because pointer gestures that create
* an undo step (annotation drag, zoom, pan) call preventDefault and so never
* move focus into the chart, leaving an el-scoped listener unreachable. To
* stay non-intrusive it acts only when this chart is "engaged" (see
* _onKeyDown): a capture-phase pointerdown marks engagement so the shortcut
* follows the chart the user last touched, and defers to text editing.
*/
_wireKeyboard() {
if (this._keydownTarget) return
const el = /** @type {any} */ (this.ctx).el
const doc = el && el.ownerDocument
if (!Environment.isBrowser() || !doc) return
doc.addEventListener('keydown', this._onKeyDown)
// pointerdown covers real mouse/touch/pen; mousedown also covers
// environments / synthetic events that do not emit pointer events. Both
// capture-phase so a gesture that stopPropagation still marks engagement.
doc.addEventListener('pointerdown', this._onPointerDown, true)
doc.addEventListener('mousedown', this._onPointerDown, true)
this._keydownTarget = doc
this._pointerTarget = doc
}
_unwireKeyboard() {
if (this._keydownTarget) {
this._keydownTarget.removeEventListener('keydown', this._onKeyDown)
this._keydownTarget = null
}
if (this._pointerTarget) {
this._pointerTarget.removeEventListener('pointerdown', this._onPointerDown, true)
this._pointerTarget.removeEventListener('mousedown', this._onPointerDown, true)
this._pointerTarget = null
}
this._engaged = false
}
// ─── Event handlers ─────────────────────────────────────────────────────
_onMounted() {
this._syncConfig()
if (!this.enabled) return
// Capture the baseline immediately (not coalesced) so undo can return to
// the initial state. Guarded so it only happens once.
if (this.stack.length === 0) this._commit('initial', true)
}
_onUpdated() {
this._syncConfig()
if (!this.enabled) return
if (this.applying) {
this._refreshSettle()
return
}
this._schedule('update')
}
_onSelection() {
if (!this.enabled || this.applying) return
this._schedule('selection')
}
/**
* Mark whether the chart is engaged: a capture-phase pointerdown inside `el`
* engages it (runs before feature handlers stopPropagation), one elsewhere
* releases it. Capture phase so an annotation/zoom gesture that stops
* propagation still registers.
* @param {any} e
*/
_onPointerDown(e) {
const el = /** @type {any} */ (this.ctx).el
this._engaged = !!(el && e.target && el.contains(e.target))
}
/**
* @param {KeyboardEvent} e
*/
_onKeyDown(e) {
if (!(e.metaKey || e.ctrlKey)) return
const key = (e.key || '').toLowerCase()
if (key !== 'z' && key !== 'y') return
const el = /** @type {any} */ (this.ctx).el
if (!el) return
const doc = el.ownerDocument
const active = doc && doc.activeElement
// Never hijack an editable field's own undo (host inputs, or the ink inline
// label editor while it is open).
if (isEditableTarget(active)) return
// Act only when this chart owns the shortcut: focus is inside it, or it is
// the chart the pointer last engaged.
if (!(el.contains(active) || this._engaged)) return
const redo = key === 'y' || e.shiftKey
e.preventDefault()
if (redo) this.redo()
else this.undo()
}
// ─── Capture (coalesced) ────────────────────────────────────────────────
/**
* @param {string} label
*/
_schedule(label) {
if (this.applying || this._batching || !this.enabled) return
this._pendingLabel = label
if (this.coalesceMs > 0 && Environment.isBrowser()) {
clearTimeout(this._coalesceTimer)
this._coalesceTimer = setTimeout(
() => this._commit(this._pendingLabel),
this.coalesceMs,
)
} else {
this._commit(label)
}
}
/**
* @param {string} [label]
* @param {boolean} [force] bypass the applying/batching guard (baseline / transaction)
*/
_commit(label, force) {
clearTimeout(this._coalesceTimer)
this._coalesceTimer = null
if (!force && (this.applying || this._batching)) return
const cp = this._capture(label)
const current = this.stack[this.pointer]
// Dedup: identical state contributes no checkpoint (also neutralises any
// 'updated' that leaks past `applying` after a restore).
if (current && current.sig === cp.sig) return
// Truncate the redo tail, then append.
if (this.pointer < this.stack.length - 1) {
this.stack.splice(this.pointer + 1)
}
this.stack.push(cp)
this.pointer = this.stack.length - 1
// Ring buffer: bound memory.
while (this.stack.length > this.maxDepth) {
this.stack.shift()
this.pointer--
}
this._emitChange()
}
/**
* @param {string} [label]
*/
_capture(label) {
const view = captureViewState(this.w, this.ctx)
const { config, seriesSig } = this._cloneConfigCOW()
return {
id: `hist-${++this._counter}`,
view,
config,
seriesSig,
label: label || 'change',
at: Environment.isBrowser() ? Date.now() : 0,
origin: 'local', // reserved for per-user scoping (Live Rooms)
sig: this._signature(view, config),
}
}
/**
* Clone w.config, sharing the previous checkpoint's cloned series when the
* live series CONTENT is unchanged (copy-on-write). Sharing is decided by a
* value signature, not reference identity: callers commonly mutate a kept
* series array in place and pass the same reference back to updateSeries, and
* an identity check would share a stale clone for exactly that case. The
* stringify is not extra cost: _signature already serialises the series as
* part of dedup.
* @returns {{ config: any, seriesSig: string|null }}
*/
_cloneConfigCOW() {
const w = this.w
const prev = this.stack[this.pointer]
/** @type {string|null} */
let seriesSig = null
try {
seriesSig = JSON.stringify(w.config.series)
} catch (e) {
seriesSig = null // cyclic/unserialisable: never share
}
let cloned
if (prev && seriesSig !== null && prev.seriesSig === seriesSig) {
const { series: _series, ...rest } = w.config
cloned = Utils.clone(rest)
cloned.series = prev.config.series
} else {
cloned = Utils.clone(w.config)
}
return { config: cloned, seriesSig }
}
/**
* Data-level signature for dedup. Functions are dropped by JSON (fine: a
* checkpoint whose only change is a function reference is not a meaningful
* undo step). Runs once per committed checkpoint, not per raw event.
* @param {any} view
* @param {any} config
* @returns {string}
*/
_signature(view, config) {
try {
return JSON.stringify(view) + '|' + JSON.stringify(config)
} catch (e) {
// Never dedup on a serialisation failure (e.g. a cycle).
return `nosig-${this._counter}`
}
}
// ─── Restore ────────────────────────────────────────────────────────────
/**
* @param {any} cp
* @param {boolean} animate
*/
_restore(cp, animate) {
if (!cp) return
this.applying = true
// Purge dynamic annotations first so the config re-render's mount replay
// does not resurrect stale ones. The full config carries window / theme /
// static annotations / series / title in a single re-render. The
// synchronous part is guarded: a throw here must not leave `applying`
// stuck true (that would permanently disable capture AND restore).
let p
try {
this.ctx.clearAnnotations()
p = this.ctx.updateOptions(Utils.clone(cp.config), false, animate, false, false)
} catch (e) {
this.applying = false
throw e
}
Promise.resolve(p)
.then(() => {
// Non-config bits (zoom flag, collapsed set, dynamic annotations,
// selection, locale). Any trailing async 'updated' from collapse
// re-renders keeps `applying` true (settle-timer) and would dedup
// anyway.
applyViewInteraction(this.ctx, cp.view)
this._refreshSettle()
this._emitChange()
})
.catch(() => {
this.applying = false
})
}
/**
* Hold `applying` true until the restore's burst of async 'updated' events
* has drained (one macrotask after the last one). Refreshed by _onUpdated.
*/
_refreshSettle() {
if (!Environment.isBrowser()) {
this.applying = false
return
}
clearTimeout(this._settleTimer)
this._settleTimer = setTimeout(() => {
this.applying = false
}, 0)
}
// ─── Public API ─────────────────────────────────────────────────────────
/**
* Commit a checkpoint of the current state now (a discrete undo step). Used by
* callers that mutate `w.config` without going through a full re-render (e.g.
* Ink Layer's targeted annotation redraws, which fire no 'updated' event).
* No-op when disabled or while a restore is applying.
* @param {string} [label]
*/
snapshot(label) {
if (!this.enabled || this.applying) return
this._commit(label || 'change')
}
/**
* @param {boolean} [animate]
*/
undo(animate = true) {
if (!this.canUndo()) return
this.pointer--
this._restore(this.stack[this.pointer], animate)
}
/**
* @param {boolean} [animate]
*/
redo(animate = true) {
if (!this.canRedo()) return
this.pointer++
this._restore(this.stack[this.pointer], animate)
}
canUndo() {
return this.pointer > 0
}
canRedo() {
return this.pointer > -1 && this.pointer < this.stack.length - 1
}
/**
* @param {string} id
* @param {boolean} [animate]
*/
jump(id, animate = true) {
const idx = this.stack.findIndex((c) => c.id === id)
if (idx === -1 || idx === this.pointer) return
this.pointer = idx
this._restore(this.stack[idx], animate)
}
/** Clear the history, keeping the current state as the new baseline. */
clear() {
clearTimeout(this._coalesceTimer)
this._coalesceTimer = null
const current = this.stack[this.pointer]
this.stack = current ? [current] : []
this.pointer = this.stack.length - 1
this._emitChange()
}
/**
* Group multiple edits into a single undo step. `fn` may be async; await your
* updateOptions/updateSeries calls inside it so the intermediate 'updated'
* events are suppressed and only one checkpoint is committed afterwards.
* @param {() => (void | Promise<any>)} fn
* @param {{ label?: string }} [opts]
* @returns {Promise<void>}
*/
transaction(fn, opts = {}) {
if (typeof fn !== 'function') return Promise.resolve()
const wasBatching = this._batching
this._batching = true
return Promise.resolve()
.then(() => fn())
.finally(() => {
this._batching = wasBatching
if (!wasBatching) this._commit(opts.label || 'transaction', true)
})
}
/**
* @returns {{ id: string, label: string, at: number }[]}
*/
entries() {
return this.stack.map((c) => ({ id: c.id, label: c.label, at: c.at }))
}
/** Lightweight state for the historyChange event / a history-rail UI. */
state() {
return {
canUndo: this.canUndo(),
canRedo: this.canRedo(),
index: this.pointer,
length: this.stack.length,
}
}
_emitChange() {
this.ctx.events.fireEvent('historyChange', [this.ctx, this.state()])
}
/** Drop the stack + detach listeners (called on full destroy). */
teardown() {
clearTimeout(this._coalesceTimer)
clearTimeout(this._settleTimer)
this._coalesceTimer = null
this._settleTimer = null
this._unwireKeyboard()
if (this._wired) {
this.ctx.removeEventListener?.('mounted', this._onMounted)
this.ctx.removeEventListener?.('updated', this._onUpdated)
this.ctx.removeEventListener?.('scrolled', this._onUpdated)
this.ctx.removeEventListener?.('dataPointSelection', this._onSelection)
}
this.stack = []
this.pointer = -1
this._wired = false
}
}