258 lines
8.4 KiB
TypeScript
258 lines
8.4 KiB
TypeScript
import { C } from '$/constants';
|
|
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
|
|
import { debounce } from 'lodash-es';
|
|
import type { MermaidConfig } from 'mermaid';
|
|
import { derived, get, writable, type Readable } from 'svelte/store';
|
|
import { env } from './env';
|
|
import {
|
|
extractErrorLineText,
|
|
findMostRelevantLineNumber,
|
|
replaceLineNumberInErrorMessage
|
|
} from './errorHandling';
|
|
import { parse } from './mermaid';
|
|
import { localStorage, persist } from './persist';
|
|
import { deserializeState, pakoSerde, serializeState } from './serde';
|
|
import { errorDebug, formatJSON, MCBaseURL } from './util';
|
|
|
|
export const defaultState: State = {
|
|
code: `flowchart TD
|
|
A[Christmas] -->|Get money| B(Go shopping)
|
|
B --> C{Let me think}
|
|
C -->|One| D[Laptop]
|
|
C -->|Two| E[iPhone]
|
|
C -->|Three| F[fa:fa-car Car]
|
|
`,
|
|
grid: true,
|
|
mermaid: formatJSON({
|
|
theme: 'default'
|
|
}),
|
|
panZoom: true,
|
|
rough: false,
|
|
updateDiagram: true
|
|
};
|
|
|
|
const urlParseFailedState = `flowchart TD
|
|
A[Loading URL failed. We can try to figure out why.] -->|Decode JSON| B(Please check the console to see the JSON and error details.)
|
|
B --> C{Is the JSON correct?}
|
|
C -->|Yes| D(Please Click here to Raise an issue in github.<br/>Including the broken link in the issue <br/> will speed up the fix.)
|
|
C -->|No| E{Did someone <br/>send you this link?}
|
|
E -->|Yes| F[Ask them to send <br/>you the complete link]
|
|
E -->|No| G{Did you copy <br/> the complete URL?}
|
|
G --> |Yes| D
|
|
G --> |"No :("| H(Try using the Timeline tab in History <br/>from same browser you used to create the diagram.)
|
|
click D href "https://github.com/mermaid-js/mermaid-live-editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link" "Raise issue"`;
|
|
|
|
// inputStateStore handles all updates and is shared externally when exporting via URL, History, etc.
|
|
export const inputStateStore = persist(writable(defaultState), localStorage(), 'codeStore');
|
|
|
|
export const currentState: ValidatedState = (() => {
|
|
const state = get(inputStateStore);
|
|
return {
|
|
...state,
|
|
editorMode: state.editorMode ?? 'code',
|
|
error: undefined,
|
|
errorMarkers: [],
|
|
serialized: serializeState(state)
|
|
};
|
|
})();
|
|
|
|
let lastDiagramType = '';
|
|
|
|
const processState = async (state: State) => {
|
|
const processed: ValidatedState = {
|
|
...state,
|
|
editorMode: state.editorMode ?? 'code',
|
|
error: undefined,
|
|
errorMarkers: [],
|
|
serialized: ''
|
|
};
|
|
// No changes should be done to fields part of `state`.
|
|
try {
|
|
processed.serialized = serializeState(state);
|
|
const { diagramType } = await parse(state.code);
|
|
processed.diagramType = diagramType;
|
|
if (lastDiagramType === 'zenuml' && diagramType !== lastDiagramType) {
|
|
// Temp Hack to refresh page after displaying ZenUML.
|
|
setTimeout(() => window.location.reload(), 500);
|
|
}
|
|
lastDiagramType = diagramType;
|
|
JSON.parse(state.mermaid);
|
|
} catch (error) {
|
|
processed.error = error as Error;
|
|
errorDebug();
|
|
console.error(error);
|
|
if ('hash' in error) {
|
|
try {
|
|
let errorString = processed.error.toString();
|
|
const errorLineText = extractErrorLineText(errorString);
|
|
const realLineNumber = findMostRelevantLineNumber(errorLineText, state.code);
|
|
|
|
let first_line: number, last_line: number, first_column: number, last_column: number;
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
({ first_line, last_line, first_column, last_column } = (error.hash as ErrorHash).loc);
|
|
} catch {
|
|
const lineNo = findMostRelevantLineNumber(errorString, state.code);
|
|
first_line = lineNo;
|
|
last_line = lineNo + 1;
|
|
first_column = 0;
|
|
last_column = 0;
|
|
}
|
|
|
|
if (realLineNumber !== -1) {
|
|
errorString = replaceLineNumberInErrorMessage(errorString, realLineNumber);
|
|
}
|
|
|
|
processed.error = new Error(errorString);
|
|
const marker: MarkerData = {
|
|
endColumn: last_column + (first_column === last_column ? 0 : 5),
|
|
endLineNumber: last_line + (realLineNumber - first_line),
|
|
message: errorString || 'Syntax error',
|
|
severity: 8, // Error
|
|
startColumn: first_column,
|
|
startLineNumber: realLineNumber
|
|
};
|
|
processed.errorMarkers = [marker];
|
|
} catch (error) {
|
|
console.error('Error without line helper', error);
|
|
}
|
|
}
|
|
}
|
|
return processed;
|
|
};
|
|
|
|
// All internal reads should be done via stateStore, but it should not be persisted/shared externally.
|
|
export const stateStore: Readable<ValidatedState> = derived(
|
|
[inputStateStore],
|
|
([state], set) => {
|
|
void processState(state).then(set);
|
|
},
|
|
currentState
|
|
);
|
|
|
|
export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
|
|
const { krokiRendererUrl, rendererUrl } = env;
|
|
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
|
|
return {
|
|
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
|
|
mdCode: png
|
|
? `[](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`
|
|
: '',
|
|
mermaidChart: ({
|
|
medium
|
|
}: {
|
|
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share' | 'toggle';
|
|
}) => {
|
|
const params = new URLSearchParams({
|
|
utm_source: C.utmSource,
|
|
utm_medium: medium
|
|
}).toString();
|
|
return {
|
|
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
|
|
playground: `${MCBaseURL}/play?${params}#${serialized}`,
|
|
plugins: `${MCBaseURL}/plugins?${params}`,
|
|
home: `${MCBaseURL}/?${params}`
|
|
};
|
|
},
|
|
new: `${window.location.protocol}//${window.location.host}${window.location.pathname}#${serializeState(defaultState)}`,
|
|
png,
|
|
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
|
|
view: `/view#${serialized}`
|
|
};
|
|
});
|
|
|
|
export const loadState = (data: string): void => {
|
|
let state: State;
|
|
console.log(`Loading '${data}'`);
|
|
try {
|
|
state = deserializeState(data);
|
|
if (!state.mermaid) {
|
|
state.mermaid = defaultState.mermaid;
|
|
}
|
|
const mermaidConfig: MermaidConfig =
|
|
typeof state.mermaid === 'string'
|
|
? (JSON.parse(state.mermaid) as MermaidConfig)
|
|
: state.mermaid;
|
|
if (
|
|
mermaidConfig.securityLevel &&
|
|
mermaidConfig.securityLevel !== 'strict' &&
|
|
confirm(
|
|
`Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
|
|
)
|
|
) {
|
|
delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack
|
|
}
|
|
state.mermaid = formatJSON(mermaidConfig);
|
|
} catch (error) {
|
|
state = get(inputStateStore);
|
|
if (data) {
|
|
console.error('Init error', error);
|
|
state.code = urlParseFailedState;
|
|
state.mermaid = defaultState.mermaid;
|
|
}
|
|
}
|
|
updateCodeStore(state);
|
|
};
|
|
|
|
let renderCount = 0;
|
|
export const updateCodeStore = (newState: Partial<State>): void => {
|
|
inputStateStore.update((state) => {
|
|
renderCount++;
|
|
return { ...state, ...newState, renderCount };
|
|
});
|
|
};
|
|
|
|
export const updateCode = (
|
|
code: string,
|
|
{
|
|
updateDiagram = false,
|
|
resetPanZoom = false
|
|
}: { updateDiagram?: boolean; resetPanZoom?: boolean } = {}
|
|
): void => {
|
|
errorDebug();
|
|
|
|
inputStateStore.update((state) => {
|
|
if (resetPanZoom) {
|
|
state.pan = undefined;
|
|
state.zoom = undefined;
|
|
}
|
|
return { ...state, code, updateDiagram };
|
|
});
|
|
};
|
|
|
|
export const updateConfig = (config: string): void => {
|
|
updateCodeStore({ mermaid: config });
|
|
};
|
|
|
|
export const toggleDarkTheme = (dark: boolean): void => {
|
|
inputStateStore.update((state) => {
|
|
const config = JSON.parse(state.mermaid) as MermaidConfig;
|
|
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
|
|
config.theme = dark ? 'dark' : 'default';
|
|
}
|
|
return { ...state, mermaid: formatJSON(config) };
|
|
});
|
|
};
|
|
|
|
export const initURLSubscription = (): void => {
|
|
const updateHash = debounce((hash) => {
|
|
history.replaceState(undefined, '', `#${hash}`);
|
|
}, 250);
|
|
|
|
stateStore.subscribe(({ serialized }) => {
|
|
updateHash(serialized);
|
|
});
|
|
};
|
|
|
|
export const getStateString = (): string => {
|
|
return JSON.stringify(get(inputStateStore));
|
|
};
|
|
|
|
export const verifyState = (): void => {
|
|
const state = get(inputStateStore);
|
|
if (!state.panZoom) {
|
|
state.panZoom = true;
|
|
}
|
|
updateCodeStore(state);
|
|
};
|