diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f2939706..2baf95f5 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -8,7 +8,15 @@ module.exports = { 'prettier' ], plugins: ['svelte3', 'tailwindcss', '@typescript-eslint'], - ignorePatterns: ['docs/*', '*.cjs', '*.md', 'snapshots.js', 'svelte.config.js', 'package.json'], + ignorePatterns: [ + 'docs/*', + '*.cjs', + '*.md', + 'snapshots.js', + 'svelte.config.js', + 'package.json', + 'tsconfig.json' + ], overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], settings: { 'svelte3/typescript': () => require('typescript') diff --git a/cypress/integration/actions.spec.ts b/cypress/integration/actions.spec.ts index 6a2a8268..bcef5906 100644 --- a/cypress/integration/actions.spec.ts +++ b/cypress/integration/actions.spec.ts @@ -17,4 +17,10 @@ describe('Check actions', () => { }); }); }); + + it('should load gists from URL', () => { + cy.get('#gist').type('https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a'); + cy.contains('Load Gist').click(); + cy.contains('Go shopping!!'); + }); }); diff --git a/cypress/integration/loadSite.spec.ts b/cypress/integration/loadSite.spec.ts index 10b8efa8..82c39f91 100644 --- a/cypress/integration/loadSite.spec.ts +++ b/cypress/integration/loadSite.spec.ts @@ -32,6 +32,33 @@ describe('Site Loads', () => { cy.contains('classDiagram'); }); + it('should load diagram from gist', () => { + cy.visit(`/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a`); + cy.contains('Go shopping!!'); + cy.contains('Revisions'); + cy.contains('sidharthv96 v8f8f1e2'); + cy.contains('sidharthv96 v7851e19'); + cy.getLocalStorage('codeStore').snapshot(); + }); + + it('should load diagram from gist revision', () => { + cy.visit( + '/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19' + ); + cy.contains('Party'); + cy.contains('Revisions'); + cy.contains('sidharthv96 v7851e19'); + cy.getLocalStorage('codeStore').snapshot(); + }); + + it('should load diagram from raw files', () => { + cy.visit( + '/edit?code=https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/code.mmd&config=https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/config.json' + ); + cy.contains('Party'); + cy.getLocalStorage('codeStore').snapshot(); + }); + it('should prevent setting the "securityLevel" option via URL', () => { const b64State = toBase64( `{"code":"graph TD\\nA[\\"\\"]","mermaid":"{\\"securityLevel\\": \\"loose\\", \\"theme\\": \\"forest\\"}","updateEditor":true,"autoSync":true,"updateDiagram":true}`, diff --git a/cypress/snapshots.js b/cypress/snapshots.js index 0162dd5c..dc023fad 100644 --- a/cypress/snapshots.js +++ b/cypress/snapshots.js @@ -4,7 +4,16 @@ module.exports = { "1": "{\"code\":\"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"updateEditor\":true,\"autoSync\":true,\"updateDiagram\":true}" }, "Check Redirect from old URL": { - "1": "{\"code\":\"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"updateEditor\":false,\"autoSync\":true,\"updateDiagram\":false}" + "1": "{\"code\":\"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"updateEditor\":false,\"autoSync\":true,\"updateDiagram\":true}" + }, + "should load diagram from gist": { + "1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping!!)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello world\\\"\\n}\",\"updateEditor\":false,\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a\"}}}" + }, + "should load diagram from gist revision": { + "1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello\\\"\\n}\",\"updateEditor\":false,\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19\"}}}" + }, + "should load diagram from raw files": { + "1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping!!)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello world\\\"\\n}\",\"updateEditor\":false,\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"files\",\"config\":{\"codeURL\":\"https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/code.mmd\",\"configURL\":\"https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/config.json\"}}}" } }, "__version": "8.2.0", diff --git a/src/app.postcss b/src/app.postcss index ce6711bb..edf491bf 100644 --- a/src/app.postcss +++ b/src/app.postcss @@ -12,3 +12,7 @@ .action-btn { @apply rounded p-2 bg-indigo-400 shadow flex-auto text-white hover:bg-indigo-500; } + +.input { + @apply flex-1 border-indigo-400 border-solid border-2 rounded; +} diff --git a/src/lib/components/actions.svelte b/src/lib/components/actions.svelte index 9fd61467..110fd2b3 100644 --- a/src/lib/components/actions.svelte +++ b/src/lib/components/actions.svelte @@ -105,11 +105,26 @@ ); }; - const onCopyMarkdown = (event: Event) => { - event.target.select(); + const onCopyMarkdown = () => { + document.getElementById('markdown').select(); document.execCommand('Copy'); }; + let gistURL = ''; + codeStore.subscribe((state) => { + if (state.loader?.type === 'gist') { + // @ts-ignore Gist will have url + gistURL = state.loader.config.url; + } + }); + + const loadGist = () => { + if (!gistURL) { + alert('Please enter a Gist URL first'); + } + window.location.href = `${window.location.pathname}?gist=${gistURL}`; + }; + let iUrl: string; let svgUrl: string; let mdCode: string; @@ -162,8 +177,22 @@
- - + + +
+ +
+ +
diff --git a/src/lib/components/card/tabs.svelte b/src/lib/components/card/tabs.svelte index fc11b338..dc479b6f 100644 --- a/src/lib/components/card/tabs.svelte +++ b/src/lib/components/card/tabs.svelte @@ -7,7 +7,7 @@ export let title: string; export let isOpen = false; - let activeTabID: string = tabs[0]?.id; + $: activeTabID = tabs[0]?.id; const dispatch = createEventDispatcher(); const toggleTabs = (tab: Tab) => { diff --git a/src/lib/components/history/history.svelte b/src/lib/components/history/history.svelte index 1010d40d..96350cfd 100644 --- a/src/lib/components/history/history.svelte +++ b/src/lib/components/history/history.svelte @@ -3,29 +3,30 @@ import { codeStore, getStateString } from '$lib/util/state'; import { addHistoryEntry, - autoHistoryMode, + historyModeStore, clearHistoryData, getPreviousState, - historyStore + historyStore, + loaderHistoryStore } from './history'; import { notify, prompt } from '$lib/util/notify'; import { onMount } from 'svelte'; import moment from 'moment'; - import type { State, Tab } from '$lib/types'; + import type { HistoryType, State, Tab } from '$lib/types'; const HISTORY_SAVE_INTERVAL = 60000; const tabSelectHandler = (message: CustomEvent) => { - autoHistoryMode.set('timeline' === message.detail.id); + historyModeStore.set(message.detail.id as HistoryType); }; - const tabs: Tab[] = [ + let tabs: Tab[] = [ { - id: 'saved', + id: 'manual', title: 'Saved', icon: 'far fa-bookmark' }, { - id: 'timeline', + id: 'auto', title: 'Timeline', icon: 'fas fa-history' } @@ -38,7 +39,7 @@ addHistoryEntry({ state: $codeStore, time: Date.now(), - auto + type: auto ? 'auto' : 'manual' }); } else if (!auto) { notify('State already saved.'); @@ -62,12 +63,26 @@ }; onMount(() => { - autoHistoryMode.set(false); + historyModeStore.set('manual'); setInterval(() => { saveHistory(true); }, HISTORY_SAVE_INTERVAL); }); + loaderHistoryStore.subscribe((entries) => { + if (entries.length > 0 && tabs.length === 2) { + tabs = [ + { + id: 'loader', + title: 'Revisions', + icon: 'fab fa-git-alt' + }, + ...tabs + ]; + historyModeStore.set('loader'); + } + }); + let isOpen = true; @@ -78,20 +93,30 @@ class="btn" on:click|stopPropagation={() => saveHistory()} title="Save current state"> - + {#if $historyModeStore !== 'loader'} + + {/if}
    {#if $historyStore.length > 0} - {#each $historyStore as { state, time, name }} + {#each $historyStore as { state, time, name, url, type }}
  • - {name} + {#if url} + {name} + {:else} + {name} + {/if} {relativeTime(time)}
    @@ -99,9 +124,11 @@ - + {#if type !== 'loader'} + + {/if}
  • diff --git a/src/lib/components/history/history.ts b/src/lib/components/history/history.ts index 4b7677a6..8dc0809b 100644 --- a/src/lib/components/history/history.ts +++ b/src/lib/components/history/history.ts @@ -1,12 +1,12 @@ import { derived, Readable, Writable, writable, get } from 'svelte/store'; import { persist, localStorage } from '@macfja/svelte-persistent-store'; import { generateSlug } from 'random-word-slugs'; -import type { HistoryEntry } from '../../types'; +import type { HistoryEntry, HistoryType } from '$lib/types'; const MAX_AUTO_HISTORY_LENGTH = 30; -export const autoHistoryMode: Writable = persist( - writable(true), +export const historyModeStore: Writable = persist( + writable('manual'), localStorage(), 'autoHistoryMode' ); @@ -23,17 +23,30 @@ const manualHistoryStore: Writable = persist( 'manualHistoryStore' ); +export const loaderHistoryStore: Writable = writable([] as HistoryEntry[]); + export const historyStore: Readable = derived( - [autoHistoryMode, autoHistoryStore, manualHistoryStore], - ([autoMode, autoHistories, manualHistories], set) => { - set(autoMode ? autoHistories : manualHistories); + [historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore], + ([historyMode, autoHistories, manualHistories, loadedHistories], set) => { + if (historyMode === 'auto') { + set(autoHistories); + } else if (historyMode === 'manual') { + set(manualHistories); + } else if (historyMode === 'loader') { + set(loadedHistories); + } else { + set(autoHistories); + } } ); export const addHistoryEntry = (entry: HistoryEntry): void => { + if (entry.type === 'loader') { + loaderHistoryStore.update((entries) => [entry, ...entries]); + return; + } entry.name = generateSlug(2); - - if (!entry.auto) { + if (entry.type !== 'auto') { manualHistoryStore.update((entries) => [entry, ...entries]); return; } @@ -46,9 +59,12 @@ export const addHistoryEntry = (entry: HistoryEntry): void => { }; export const clearHistoryData = (time?: number): void => { - (get(autoHistoryMode) ? autoHistoryStore : manualHistoryStore).update((entries) => - entries.filter((entry) => time && entry.time != time) - ); + (get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => { + if (get(historyModeStore) !== 'loader') { + entries = entries.filter((entry) => time && entry.time != time); + } + return entries; + }); }; export const getPreviousState = (auto: boolean): string => { diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 3f121d93..e0a139b3 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -29,11 +29,32 @@ export interface State { updateEditor: boolean; updateDiagram: boolean; autoSync: boolean; + loader?: LoaderConfig; } +export interface GistLoaderConfig { + url: string; +} + +export interface LoadingState { + loading: boolean; + message?: string; +} +export interface FileLoaderConfig { + codeURL: string; + configURL?: string; +} +export interface LoaderConfig { + type: 'gist' | 'files'; + config: GistLoaderConfig | FileLoaderConfig; +} +export type HistoryType = 'auto' | 'manual' | 'loader'; export interface HistoryEntry { state: State; time: number; name?: string; - auto: boolean; + type: HistoryType; + url?: string; } + +type Loader = (url: string) => Promise; diff --git a/src/lib/util/fileLoaders/gist.ts b/src/lib/util/fileLoaders/gist.ts new file mode 100644 index 00000000..cd9a2a75 --- /dev/null +++ b/src/lib/util/fileLoaders/gist.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { State } from '$lib/types'; +import { defaultState } from '../state'; +import { addHistoryEntry } from '../../components/history/history'; + +const codeFileName = 'code.mmd'; +const configFileName = 'config.json'; + +const isValidGist = (files: any): boolean => { + return codeFileName in files; +}; + +const getFileContent = async (file: any): Promise => { + if (file.truncated) { + return await (await fetch(file.raw_url)).text(); + } + return file.content; +}; + +interface GistData { + code: string; + config?: string; + author: string; + time: number; + version: string; + url: string; +} + +const getGistData = async (gistURL: string): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, __, gistID, revisionID] = gistURL.split('github.com').pop().split('/'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { html_url, files, history } = await ( + await fetch(`https://api.github.com/gists/${gistID}${revisionID ? '/' + revisionID : ''}`) + ).json(); + if (isValidGist(files)) { + const code = await getFileContent(files[codeFileName]); + let config: string; + if (configFileName in files) { + config = await getFileContent(files[configFileName]); + } + const currentItem = history[0]; + return { + url: `${html_url}/${currentItem.version}`, + code, + config, + author: currentItem.user.login, + time: new Date(currentItem.committed_at).getTime(), + version: (currentItem.version as string).slice(-7) + }; + } else { + throw 'Invalid gist provided'; + } +}; + +const getStateFromGist = (gist: GistData, gistURL: string = gist.url): State => { + const state: State = { + ...defaultState, + code: gist.code, + loader: { + type: 'gist', + config: { + url: gistURL + } + } + }; + gist.config && (state.mermaid = gist.config); + return state; +}; + +export const loadGistData = async (gistURL: string): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, __, gistID, revisionID] = gistURL.split('github.com').pop().split('/'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { history } = await ( + await fetch(`https://api.github.com/gists/${gistID}${revisionID ? '/' + revisionID : ''}`) + ).json(); + const gistHistory: GistData[] = []; + for (const entry of history) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const data: GistData = await getGistData(entry.url).catch(() => undefined); + data && gistHistory.push(data); + } + if (gistHistory.length === 0) { + throw 'Invalid gist provided'; + } + gistHistory.reverse(); + const state = getStateFromGist(gistHistory.slice(-1).pop(), gistURL); + for (const gist of gistHistory) { + addHistoryEntry({ + state: getStateFromGist(gist), + time: gist.time, + type: 'loader', + url: gist.url, + name: `${gist.author} v${gist.version}` + }); + } + return state; + } catch (err) { + console.error(err); + } +}; diff --git a/src/lib/util/fileLoaders/loader.ts b/src/lib/util/fileLoaders/loader.ts new file mode 100644 index 00000000..02b73aa2 --- /dev/null +++ b/src/lib/util/fileLoaders/loader.ts @@ -0,0 +1,58 @@ +import { loadGistData } from './gist'; +import { updateCodeStore, defaultState } from '../state'; +import type { Loader, State } from '$lib/types'; +const loaders: Record = { + gist: loadGistData +}; + +export const loadDataFromUrl = async (): Promise => { + const searchParams = new URLSearchParams(window.location.search); + let state: State = defaultState; + let code: string, config: string; + let loaded = false; + const codeURL: string = searchParams.get('code'); + const configURL: string = searchParams.get('config'); + + if (codeURL) { + code = await (await fetch(codeURL)).text(); + loaded = true; + } + if (configURL) { + config = await (await fetch(configURL)).text(); + } else { + config = defaultState.mermaid; + } + if (!code) { + for (const [key, value] of searchParams.entries()) { + if (key in loaders) { + try { + state = await loaders[key](value); + loaded = true; + break; + } catch (err) { + console.error(err); + } + } + } + } else { + state = { + code, + mermaid: config, + loader: { + type: 'files', + config: { + codeURL, + configURL + } + } + } as State; + } + loaded && + updateCodeStore({ + ...state, + autoSync: true, + updateDiagram: true, + updateEditor: true + }); + // window.location.search = ''; +}; diff --git a/src/lib/util/loading.ts b/src/lib/util/loading.ts new file mode 100644 index 00000000..84205ebf --- /dev/null +++ b/src/lib/util/loading.ts @@ -0,0 +1,19 @@ +import { writable, Writable } from 'svelte/store'; +import type { LoadingState } from '$lib/types'; + +const defaultLoading: LoadingState = { + loading: false +}; + +export const loadingStateStore: Writable = writable(defaultLoading); +export const initLoading = async (message: string, task: Promise): Promise => { + loadingStateStore.set({ + loading: true, + message + }); + const result: T = await task; + loadingStateStore.set({ + loading: false + }); + return result; +}; diff --git a/src/lib/util/state.ts b/src/lib/util/state.ts index 3669b6ac..643ea9d8 100644 --- a/src/lib/util/state.ts +++ b/src/lib/util/state.ts @@ -4,7 +4,7 @@ import { persist, localStorage } from '@macfja/svelte-persistent-store'; import type { State } from '$lib/types'; import { saveStatistcs } from './stats'; -const defaultState: State = { +export const defaultState: State = { code: `graph TD A[Christmas] -->|Get money| B(Go shopping) B --> C{Let me think} diff --git a/src/lib/util/util.ts b/src/lib/util/util.ts index 482dab1c..84e02992 100644 --- a/src/lib/util/util.ts +++ b/src/lib/util/util.ts @@ -1,6 +1,8 @@ import type { State } from '$lib/types'; import { initURLSubscription, loadState, updateCodeStore } from './state'; import { analytics, initAnalytics } from './stats'; +import { loadDataFromUrl } from './fileLoaders/loader'; +import { initLoading } from './loading'; export const loadStateFromURL = (): void => { loadState(window.location.hash.slice(1)); @@ -14,6 +16,7 @@ export const syncDiagram = (): void => { export const initHandler = async (): Promise => { loadStateFromURL(); + await initLoading('Loading Gist...', loadDataFromUrl().catch(console.error)); syncDiagram(); initURLSubscription(); await initAnalytics(); diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte index f17b2f6e..92cf8996 100644 --- a/src/routes/__layout.svelte +++ b/src/routes/__layout.svelte @@ -2,6 +2,7 @@ import '../app.postcss'; import { base } from '$app/paths'; import { onMount } from 'svelte'; + import { loadingStateStore } from '$lib/util/loading'; // This can be removed once https://github.com/sveltejs/kit/issues/1612 is fixed. // Then move it into src and vite will bundle it automatically. onMount(() => { @@ -23,3 +24,44 @@
    + +{#if $loadingStateStore.loading} +
    +
    +
    +
    {$loadingStateStore.message}
    +
    +
    +{/if} + + diff --git a/tsconfig.json b/tsconfig.json index 975f193c..e82fd297 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "moduleResolution": "node", "module": "es2020", - "lib": ["es2020", "ESNext"], + "lib": ["es2020", "ESNext", "DOM.Iterable"], "target": "es2019", /** svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript