├── .eslintrc.cjs
├── .github
└── FUNDING.yml
├── .gitignore
├── .vscode
└── extensions.json
├── Dockerfile
├── LICENSE
├── README.md
├── env.d.ts
├── index.html
├── package-lock.json
├── package.json
├── playwright-report
└── index.html
├── playwright.config.ts
├── postcss.config.js
├── public
├── favicon.ico
├── favicon.png
├── favicon.svg
├── icon_192.png
├── icon_512.png
└── images
│ ├── calendar.png
│ ├── map.png
│ ├── markwhen.png
│ ├── recurring_syntax.png
│ ├── screenshot.png
│ └── timeline.png
├── src
├── App
│ ├── App.vue
│ ├── appStore.ts
│ └── composables
│ │ ├── useAppHead.ts
│ │ └── useIsTouchscreen.ts
├── AppSettings
│ ├── Settings.vue
│ └── appSettingsStore.ts
├── Dialog
│ └── Dialog.vue
├── Dialogs
│ ├── Dialogs.vue
│ └── VisualizationOptionRow.vue
├── Drawer
│ ├── Drawer.vue
│ ├── HoverHint.vue
│ ├── HoverMenu.vue
│ ├── PageButtons
│ │ ├── PageButton.vue
│ │ ├── PageButtons.vue
│ │ └── composables
│ │ │ └── usePageButtonMove.ts
│ ├── ShortcutKey.vue
│ ├── Spacer.vue
│ ├── VerticalSpacer.vue
│ ├── ViewSettings
│ │ ├── Sort.vue
│ │ └── Tags
│ │ │ ├── Filter.vue
│ │ │ ├── FilterDialog.vue
│ │ │ ├── Tag.vue
│ │ │ ├── TagChip.vue
│ │ │ ├── TagRow.vue
│ │ │ ├── Tags.vue
│ │ │ └── composables
│ │ │ └── useTagColor.ts
│ ├── ViewSwitcher.vue
│ ├── ViewSwitcherButton.vue
│ └── VisualizationSwitcher
│ │ ├── VisualizationIndicator.vue
│ │ └── VisualizationSwitcherMenu.vue
├── EditorOrchestrator
│ ├── editorOrchestratorStore.ts
│ └── usePageAdjustedRanges.ts
├── EventDetail
│ ├── DatePicker
│ │ └── DateAdjuster.vue
│ ├── EventDetail.vue
│ ├── EventDetailMarkdown.vue
│ ├── EventDetailPaneTop.vue
│ ├── EventDetailPanel.vue
│ ├── EventDetailTags.vue
│ ├── EventDetailWhen.vue
│ ├── EventGroupDetail.vue
│ └── eventDetailStore.ts
├── Jump
│ ├── DateRangeDisplay.vue
│ ├── JumpButton.vue
│ ├── JumpResultList.vue
│ ├── JumpResultListItem.vue
│ ├── JumpResultListItemMeta.vue
│ ├── JumpToRangeDialog.vue
│ ├── SearchResultItem.vue
│ ├── dateRangeString.ts
│ ├── jumpStore.ts
│ └── search.ts
├── Keyboard
│ └── keyboardStore.ts
├── Markwhen
│ ├── EventMarkdown.vue
│ ├── composables
│ │ ├── useEventRefs.ts
│ │ ├── usePageEffect.ts
│ │ ├── usePageEffects.ts
│ │ ├── useParserWorker.ts
│ │ └── useTransform.ts
│ ├── eventMapStore.ts
│ ├── markwhenStore.ts
│ ├── pageStore.ts
│ ├── transformStore.ts
│ └── utilities
│ │ ├── DateTimeDisplay.ts
│ │ ├── dateRangeToString2.ts
│ │ ├── dateTimeUtilities.ts
│ │ ├── eventComparator.ts
│ │ ├── innerHtml.ts
│ │ └── weekdayCache.ts
├── MarkwhenPreview
│ ├── MarkwhenPreview.vue
│ ├── PagePreviewRow.vue
│ └── PreviewTableHeader.vue
├── NewEvent
│ ├── NewEvent.vue
│ ├── NewEventDialog.vue
│ └── newEventStore.ts
├── Panels
│ ├── PanelViewButtons.vue
│ ├── Panels.vue
│ ├── ResizeBar.vue
│ ├── Visualizations.vue
│ ├── composables
│ │ └── usePanelMove.ts
│ └── panelStore.ts
├── QuickEditor
│ └── QuickEditor.vue
├── RangePicker
│ └── RangePicker.vue
├── Settings
│ └── SettingsButton.vue
├── Sidebar
│ ├── DarkModeButton.vue
│ ├── Sidebar.vue
│ ├── SidebarLinks.vue
│ ├── ToggleSidebarButton.vue
│ ├── composables
│ │ └── usePanelResize.ts
│ └── sidebarStore.ts
├── Transitions
│ └── Fade.vue
├── Views
│ ├── ViewOrchestrator
│ │ ├── useEventFinder.ts
│ │ ├── useLpc.ts
│ │ ├── useStateSerializer.ts
│ │ └── useViewOrchestrator.ts
│ ├── mobileViewStore.ts
│ ├── useViewProviders.ts
│ └── visualizationStore.ts
├── WelcomeViewPicker
│ ├── CustomViewOption.vue
│ ├── ViewPicker.vue
│ ├── VisualizationOption.vue
│ ├── WelcomeBanner.vue
│ └── WelcomeViewPicker.vue
├── exampleTimeline.ts
├── index.css
├── injectionKeys.ts
├── main.ts
├── router
│ ├── index.ts
│ ├── useQuerySetter.ts
│ └── useRouteWatcherStore.ts
├── utilities
│ ├── Spinner.vue
│ ├── colorUtils.ts
│ ├── composables
│ │ └── useIsActive.ts
│ └── ranges.ts
├── viewProvider.ts
└── workers
│ └── parse.worker.ts
├── tailwind.config.js
├── tests-examples
└── demo-todo-app.spec.ts
├── tests
└── pages.spec.ts
├── tsconfig.app.json
├── tsconfig.config.json
├── tsconfig.json
├── tsconfig.vitest.json
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | require("@rushstack/eslint-patch/modern-module-resolution");
3 |
4 | module.exports = {
5 | root: true,
6 | extends: [
7 | "plugin:vue/vue3-essential",
8 | "eslint:recommended",
9 | "@vue/eslint-config-typescript/recommended",
10 | "@vue/eslint-config-prettier",
11 | ],
12 | };
13 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 |
2 | github: kochrt
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | coverage
15 | *.local
16 |
17 | /cypress/videos/
18 | /cypress/screenshots/
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 | dev-dist
30 | /test-results/
31 | /playwright-report/
32 | /playwright/.cache/
33 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
3 | }
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine
2 |
3 | WORKDIR /app
4 | COPY package*.json ./
5 | RUN npm install
6 | COPY . .
7 |
8 | ENV PORT=8080
9 | EXPOSE $PORT
10 | CMD [ "npm", "run", "dev" ]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2022 Rob Koch
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
9 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 |
13 |
17 | Markwhen
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "renderer",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "vite --port ${PORT:-5173}",
7 | "build": "run-p type-check build-only",
8 | "preview": "vite preview --port 4173",
9 | "test:unit": "vitest --environment jsdom",
10 | "build-only": "vite build",
11 | "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
12 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
13 | "link:parser": "yalc add @markwhen/parser && yalc link @markwhen/parser && npm install",
14 | "unlink:parser": "yalc remove @markwhen/parser && npm install",
15 | "test": "npx playwright test"
16 | },
17 | "dependencies": {
18 | "@markwhen/parser": "^0.6.4",
19 | "@vuepic/vue-datepicker": "^3.6.6",
20 | "@vueuse/core": "^9.12.0",
21 | "@vueuse/integrations": "^9.12.0",
22 | "chrono-node": "^2.5.0",
23 | "lru-cache": "^7.14.1",
24 | "lunr": "^2.3.9",
25 | "luxon": "^3.2.1",
26 | "pinia": "^2.0.30",
27 | "stripe": "^10.15.0",
28 | "throttle-debounce": "^5.0.0",
29 | "vue": "^3.2.47",
30 | "vue-router": "^4.1.6"
31 | },
32 | "devDependencies": {
33 | "@playwright/test": "^1.30.0",
34 | "@rushstack/eslint-patch": "^1.1.0",
35 | "@tailwindcss/container-queries": "^0.1.0",
36 | "@types/jsdom": "^20.0.1",
37 | "@types/lunr": "^2.3.4",
38 | "@types/luxon": "^3.2.0",
39 | "@types/node": "^16.11.45",
40 | "@types/throttle-debounce": "^5.0.0",
41 | "@vitejs/plugin-vue": "^4.0.0",
42 | "@vue/eslint-config-prettier": "^7.0.0",
43 | "@vue/eslint-config-typescript": "^11.0.0",
44 | "@vue/test-utils": "^2.2.6",
45 | "@vue/tsconfig": "^0.1.3",
46 | "@vueuse/head": "^1.0.23",
47 | "autoprefixer": "^10.4.13",
48 | "eslint": "^8.29.0",
49 | "eslint-plugin-vue": "^9.8.0",
50 | "jsdom": "^20.0.3",
51 | "npm-run-all": "^4.1.5",
52 | "postcss": "^8.4.18",
53 | "prettier": "^2.8.1",
54 | "sass": "^1.56.2",
55 | "tailwindcss": "^3.2.4",
56 | "typescript": "^4.9.5",
57 | "vite": "^4.0.4",
58 | "vitest": "^0.28.3",
59 | "vue-tsc": "^1.0.24"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import type { PlaywrightTestConfig } from '@playwright/test';
2 | import { devices } from '@playwright/test';
3 |
4 | /**
5 | * Read environment variables from file.
6 | * https://github.com/motdotla/dotenv
7 | */
8 | // require('dotenv').config();
9 |
10 | /**
11 | * See https://playwright.dev/docs/test-configuration.
12 | */
13 | const config: PlaywrightTestConfig = {
14 | testDir: './tests',
15 | /* Maximum time one test can run for. */
16 | timeout: 30 * 1000,
17 | expect: {
18 | /**
19 | * Maximum time expect() should wait for the condition to be met.
20 | * For example in `await expect(locator).toHaveText();`
21 | */
22 | timeout: 5000
23 | },
24 | /* Run tests in files in parallel */
25 | fullyParallel: true,
26 | /* Fail the build on CI if you accidentally left test.only in the source code. */
27 | forbidOnly: !!process.env.CI,
28 | /* Retry on CI only */
29 | retries: process.env.CI ? 2 : 0,
30 | /* Opt out of parallel tests on CI. */
31 | workers: process.env.CI ? 1 : undefined,
32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
33 | reporter: 'html',
34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
35 | use: {
36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
37 | actionTimeout: 0,
38 | /* Base URL to use in actions like `await page.goto('/')`. */
39 | // baseURL: 'http://localhost:3000',
40 |
41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
42 | trace: 'on-first-retry',
43 | },
44 |
45 | /* Configure projects for major browsers */
46 | projects: [
47 | {
48 | name: 'chromium',
49 | use: {
50 | ...devices['Desktop Chrome'],
51 | },
52 | },
53 |
54 | {
55 | name: 'firefox',
56 | use: {
57 | ...devices['Desktop Firefox'],
58 | },
59 | },
60 |
61 | // {
62 | // name: 'webkit',
63 | // use: {
64 | // ...devices['Desktop Safari'],
65 | // },
66 | // },
67 |
68 | /* Test against mobile viewports. */
69 | // {
70 | // name: 'Mobile Chrome',
71 | // use: {
72 | // ...devices['Pixel 5'],
73 | // },
74 | // },
75 | // {
76 | // name: 'Mobile Safari',
77 | // use: {
78 | // ...devices['iPhone 12'],
79 | // },
80 | // },
81 |
82 | /* Test against branded browsers. */
83 | // {
84 | // name: 'Microsoft Edge',
85 | // use: {
86 | // channel: 'msedge',
87 | // },
88 | // },
89 | // {
90 | // name: 'Google Chrome',
91 | // use: {
92 | // channel: 'chrome',
93 | // },
94 | // },
95 | ],
96 |
97 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */
98 | // outputDir: 'test-results/',
99 |
100 | /* Run your local dev server before starting the tests */
101 | // webServer: {
102 | // command: 'npm run start',
103 | // port: 3000,
104 | // },
105 | };
106 |
107 | export default config;
108 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/favicon.png
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/public/icon_192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/icon_192.png
--------------------------------------------------------------------------------
/public/icon_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/icon_512.png
--------------------------------------------------------------------------------
/public/images/calendar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/images/calendar.png
--------------------------------------------------------------------------------
/public/images/map.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/images/map.png
--------------------------------------------------------------------------------
/public/images/markwhen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/images/markwhen.png
--------------------------------------------------------------------------------
/public/images/recurring_syntax.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/images/recurring_syntax.png
--------------------------------------------------------------------------------
/public/images/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/images/screenshot.png
--------------------------------------------------------------------------------
/public/images/timeline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mark-when/markwhen/b0b2b9992ed6a2f834e205c1e35b5d46f450b27f/public/images/timeline.png
--------------------------------------------------------------------------------
/src/App/App.vue:
--------------------------------------------------------------------------------
1 |
36 |
37 |
38 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/src/App/appStore.ts:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from "@vueuse/core";
2 | import { defineStore } from "pinia";
3 | import { computed, ref } from "vue";
4 |
5 | export const useAppStore = defineStore("app", () => {
6 | const globalClass = ref("");
7 |
8 | function setGlobalClass(gc: string) {
9 | globalClass.value = gc;
10 | }
11 |
12 | function clearGlobalClass() {
13 | globalClass.value = "";
14 | }
15 |
16 | return {
17 | // state
18 | globalClass,
19 |
20 | // actions
21 | setGlobalClass,
22 | clearGlobalClass,
23 | };
24 | });
25 |
--------------------------------------------------------------------------------
/src/App/composables/useAppHead.ts:
--------------------------------------------------------------------------------
1 | import { usePageStore } from "@/Markwhen/pageStore";
2 | import { computed } from "@vue/reactivity";
3 | import { useHead } from "@vueuse/head";
4 |
5 | export const useAppHead = () => {
6 | const pageStore = usePageStore();
7 |
8 | const title = computed(() => {
9 | const pageTitle = pageStore.header.title;
10 | if (pageTitle) {
11 | return `${pageTitle} - Markwhen`;
12 | }
13 | return "Markwhen";
14 | });
15 | useHead({
16 | title,
17 | link: [
18 | { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
19 | { rel: "icon", type: "image/png", href: "/favicon.png" },
20 | ],
21 | meta: [
22 | {
23 | name: "viewport",
24 | content:
25 | "width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no",
26 | },
27 | ],
28 | });
29 | };
30 |
--------------------------------------------------------------------------------
/src/App/composables/useIsTouchscreen.ts:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from "@vueuse/core";
2 |
3 | export const useIsTouchscreen = () => ({
4 | isTouchscreen: useMediaQuery("(pointer: course)"),
5 | canHover: useMediaQuery('(hover)')
6 | });
7 |
--------------------------------------------------------------------------------
/src/AppSettings/Settings.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/AppSettings/appSettingsStore.ts:
--------------------------------------------------------------------------------
1 | import { useVisualizationStore } from "@/Views/visualizationStore";
2 | import { useMediaQuery } from "@vueuse/core";
3 | import { defineStore } from "pinia";
4 | import { computed, ref, watchEffect } from "vue";
5 |
6 | export const defaultViewOptions = ["Timeline", "Calendar", "Map"] as const;
7 | export const themeOptions = ["System", "Light", "Dark"] as const;
8 |
9 | const getSettings = () => {
10 | const savedSettings = localStorage.getItem("settings");
11 | if (savedSettings) {
12 | return JSON.parse(savedSettings);
13 | }
14 | };
15 |
16 | export const useAppSettingsStore = defineStore("appSettings", () => {
17 | const defaultView = ref("Timeline");
18 | const theme = ref("System");
19 |
20 | const savedSettings = getSettings();
21 | if (savedSettings) {
22 | // if (
23 | // savedSettings.defaultView &&
24 | // defaultViewOptions.includes(savedSettings.defaultView)
25 | // ) {
26 | // defaultView.value = savedSettings.defaultView;
27 | // const foundView = visualizationStore.activeViews.findIndex(
28 | // (v) => v.name === savedSettings.defaultView
29 | // );
30 | // if (foundView >= 0) {
31 | // visualizationStore.selectedViewIndex = foundView;
32 | // }
33 | // }
34 | if (savedSettings.theme && themeOptions.includes(savedSettings.theme)) {
35 | theme.value = savedSettings.theme;
36 | }
37 | }
38 | const mediaQueryDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
39 |
40 | const toggleDarkMode = () => {
41 | if (theme.value === "System") {
42 | theme.value = "Dark";
43 | } else if (theme.value === "Dark") {
44 | theme.value = "Light";
45 | } else {
46 | theme.value = "System";
47 | }
48 | };
49 |
50 | const inferredDarkMode = computed(() => {
51 | if (theme.value !== "System") {
52 | return theme.value === "Dark";
53 | }
54 | if (typeof window === "undefined" || !window) {
55 | return false;
56 | }
57 | return mediaQueryDarkMode.value;
58 | });
59 |
60 | watchEffect(() => {
61 | localStorage.setItem(
62 | "settings",
63 | JSON.stringify({
64 | defaultView: defaultView.value,
65 | theme: theme.value,
66 | })
67 | );
68 | });
69 |
70 | return {
71 | defaultView,
72 | theme,
73 | inferredDarkMode,
74 | toggleDarkMode,
75 | };
76 | });
77 |
--------------------------------------------------------------------------------
/src/Dialogs/Dialogs.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/Dialogs/VisualizationOptionRow.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
70 |
71 |
72 |
96 |
--------------------------------------------------------------------------------
/src/Drawer/Drawer.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
82 |
--------------------------------------------------------------------------------
/src/Drawer/HoverHint.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
28 |
29 |
{{ title }}
30 |
31 |
32 |
33 |
34 |
37 |
38 |
46 |
47 |
{{ title }}
48 |
49 |
50 |
51 |
54 |
55 |
56 |
57 |
58 |
85 |
--------------------------------------------------------------------------------
/src/Drawer/HoverMenu.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
41 |
45 |
46 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
85 |
--------------------------------------------------------------------------------
/src/Drawer/PageButtons/PageButton.vue:
--------------------------------------------------------------------------------
1 |
84 |
85 |
86 |
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/src/Drawer/PageButtons/PageButtons.vue:
--------------------------------------------------------------------------------
1 |
82 |
83 |
84 |
89 |
99 |
105 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/src/Drawer/PageButtons/composables/usePageButtonMove.ts:
--------------------------------------------------------------------------------
1 | import type { MaybeRef } from "@vueuse/shared";
2 | import { ref, unref } from "vue";
3 |
4 | export const usePageButtonMove = (
5 | b: MaybeRef,
6 | done: (translateX: number) => void
7 | ) => {
8 | const startX = ref();
9 | const translateX = ref(0);
10 |
11 | const captureClick = (e: MouseEvent) => {
12 | e.stopPropagation();
13 | document.removeEventListener("click", captureClick, true);
14 | };
15 |
16 | const escapeListener = (e: KeyboardEvent) => {
17 | if (e.key === "Escape") {
18 | stopMoving();
19 | }
20 | };
21 |
22 | const stopMoving = () => {
23 | startX.value = undefined;
24 | translateX.value = 0;
25 | document.removeEventListener("mousemove", moveListener);
26 | document.removeEventListener("touchmove", moveListener);
27 | document.removeEventListener("mouseup", endMoveListener);
28 | document.removeEventListener("touchend", endMoveListener);
29 | document.removeEventListener("keydown", escapeListener);
30 | };
31 |
32 | const endMoveListener = (e: MouseEvent | TouchEvent) => {
33 | const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
34 | if (Math.abs(clientX - startX.value!) > 2) {
35 | document.addEventListener("click", captureClick, true);
36 | }
37 | done(translateX.value);
38 | stopMoving();
39 | };
40 |
41 | const moveListener = (e: MouseEvent | TouchEvent) => {
42 | const button = unref(b);
43 | if (!button) {
44 | return;
45 | }
46 | // We shouldn't go any further than the start of the parent element
47 | const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
48 | const diff = clientX - startX.value!;
49 | const parentOffsetLeft = button.parentElement?.offsetLeft || 0;
50 | const offsetLeft = button.offsetLeft - parentOffsetLeft;
51 | const maxRight =
52 | button.parentElement!.scrollWidth - button.clientWidth - offsetLeft;
53 | translateX.value = Math.min(
54 | Math.max(-button.offsetLeft + parentOffsetLeft, diff),
55 | maxRight
56 | );
57 | };
58 |
59 | const startMoving = (e: MouseEvent | TouchEvent) => {
60 | e.preventDefault();
61 | startX.value = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
62 | document.addEventListener("touchmove", moveListener);
63 | document.addEventListener("mousemove", moveListener);
64 | document.addEventListener("touchend", endMoveListener);
65 | document.addEventListener("mouseup", endMoveListener);
66 | document.addEventListener("keydown", escapeListener);
67 | };
68 |
69 | return { moveListener: startMoving, translateX };
70 | };
71 |
--------------------------------------------------------------------------------
/src/Drawer/ShortcutKey.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
10 | {{ keyboardKey }}
11 |
12 |
13 |
14 |
15 |
31 |
--------------------------------------------------------------------------------
/src/Drawer/Spacer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/Drawer/VerticalSpacer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Sort.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/Filter.vue:
--------------------------------------------------------------------------------
1 |
63 |
64 |
65 |
72 |
103 | {{
104 | clearFilterTitle
105 | }}
106 |
107 |
111 |
123 |
124 | Filter
125 |
126 |
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/FilterDialog.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
88 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/Tag.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
28 |
46 | {{ tag }}
47 |
48 |
49 |
50 |
55 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/TagChip.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
12 |
13 |
14 |
32 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/TagRow.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
24 |
42 | {{ tag }}
43 |
44 |
45 |
46 |
51 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/Tags.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSettings/Tags/composables/useTagColor.ts:
--------------------------------------------------------------------------------
1 | import { usePageStore } from "@/Markwhen/pageStore";
2 | import type { MaybeRef } from "@vueuse/core";
3 | import { ref, watchEffect, unref } from "vue";
4 |
5 | export const useTagColor = (tag: MaybeRef) => {
6 | const pageStore = usePageStore();
7 |
8 | const color = ref();
9 |
10 | watchEffect(() => (color.value = pageStore.tags[unref(tag)]));
11 |
12 | return color;
13 | };
14 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSwitcher.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
53 |
58 |
59 |
60 |
61 |
62 |
63 |
69 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/src/Drawer/ViewSwitcherButton.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/Drawer/VisualizationSwitcher/VisualizationIndicator.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
22 |
26 |
27 |
{{ view.name }}
28 |
29 |
30 |
Views
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/Drawer/VisualizationSwitcher/VisualizationSwitcherMenu.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
26 | Views...
51 |
52 |
53 |
63 |
64 | {{ view.name }}
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/src/EditorOrchestrator/usePageAdjustedRanges.ts:
--------------------------------------------------------------------------------
1 | import { usePageStore } from "@/Markwhen/pageStore";
2 | import { computed } from "vue";
3 |
4 | export const usePageAdjustedRanges = () => {
5 | const pageStore = usePageStore();
6 |
7 | const rangeOffset = computed(
8 | () => pageStore.pageTimelineMetadata.startStringIndex
9 | );
10 | const adjustedRanges = computed(() =>
11 | pageStore.pageTimeline.ranges.map(({ from, to, type, content }) => ({
12 | type,
13 | content,
14 | from: from - rangeOffset.value,
15 | to: to - rangeOffset.value,
16 | }))
17 | );
18 |
19 | return { rangeOffset, adjustedRanges };
20 | };
21 |
--------------------------------------------------------------------------------
/src/EventDetail/DatePicker/DateAdjuster.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
24 |
25 | {{ dateTime.year }}
26 |
27 |
{{ dateTime.monthShort }}
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/EventDetail/EventDetail.vue:
--------------------------------------------------------------------------------
1 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
67 | {{
68 | parentGroup.title ||
69 | (parentGroup.style === "group" ? "(Group)" : "(Section)")
70 | }}
71 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
92 |
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/src/EventDetail/EventDetailMarkdown.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/EventDetail/EventDetailPaneTop.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
33 |
39 |
47 |
48 |
54 |
68 |
72 | ,
73 |
74 |
75 |
76 |
82 |
86 | .
87 |
88 | next
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/src/EventDetail/EventDetailPanel.vue:
--------------------------------------------------------------------------------
1 |
67 |
68 |
69 |
82 |
83 |
89 |
90 |
91 |
92 |
96 |
97 |
101 | No event selected
102 |
103 |
104 |
105 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/src/EventDetail/EventDetailTags.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
24 | (untagged)
25 |
26 |
34 |
35 |
36 |
37 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/EventDetail/EventDetailWhen.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
{{ dateHtml }}
13 |
16 | {{ duration }}
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/EventDetail/EventGroupDetail.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/Jump/DateRangeDisplay.vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
40 | {{ dateRangeString }}
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/Jump/JumpButton.vue:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
33 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/Jump/JumpResultList.vue:
--------------------------------------------------------------------------------
1 |
64 |
65 |
66 |
72 |
79 |
80 |
81 |
100 |
105 |
106 |
107 |
108 |
109 |
110 |
--------------------------------------------------------------------------------
/src/Jump/JumpResultListItem.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
20 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/Jump/JumpResultListItemMeta.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
21 | {{ eventValue(node).dateText }}
22 |
23 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/Jump/SearchResultItem.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
30 |
35 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/Jump/dateRangeString.ts:
--------------------------------------------------------------------------------
1 | import { usePageStore } from "@/Markwhen/pageStore";
2 | import { dateRangeToString } from "@/Markwhen/utilities/dateTimeUtilities";
3 | import type { DateFormat } from "@markwhen/parser/lib/Types";
4 | import type { ParseResult } from "./jumpStore";
5 |
6 | export const useDateRangeString = () => {
7 | const pageStore = usePageStore();
8 |
9 | return (parseResult: ParseResult) =>
10 | dateRangeToString(
11 | parseResult.dateRange,
12 | parseResult.scale || "day",
13 | pageStore.header.dateFormat as DateFormat
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/Jump/jumpStore.ts:
--------------------------------------------------------------------------------
1 | import type { DisplayScale } from "@/Markwhen/utilities/dateTimeUtilities";
2 | import type { DateRangePart } from "@markwhen/parser/lib/Types";
3 | import { defineStore } from "pinia";
4 | import { ref, watch } from "vue";
5 | import { useSearch } from "./search";
6 |
7 | export type JumpResults = (ParseResult | lunr.Index.Result)[];
8 |
9 | export interface ParseResult {
10 | dateRange: DateRangePart;
11 | scale?: DisplayScale;
12 | }
13 |
14 | export const isParseResult = (
15 | r: ParseResult | lunr.Index.Result
16 | ): r is ParseResult => {
17 | return !!(r as ParseResult).dateRange;
18 | };
19 |
20 | export const useJumpStore = defineStore("jump", () => {
21 | const selectedIndex = ref(0);
22 | const showingJumpDialog = ref(false);
23 | const jumpResult = ref();
24 | const search = useSearch();
25 |
26 | watch(jumpResult, (r, prev) => {
27 | if (
28 | !r ||
29 | !r.length ||
30 | !prev ||
31 | !prev.length ||
32 | selectedIndex.value > r.length - 1
33 | ) {
34 | selectedIndex.value = 0;
35 | }
36 | });
37 |
38 | const setJumpResult = (j: JumpResults | undefined) => {
39 | jumpResult.value = j;
40 | };
41 | const setSelectedIndex = (val: number) => {
42 | selectedIndex.value = val;
43 | };
44 | const setShowJumpDialog = (show: boolean) => {
45 | showingJumpDialog.value = show;
46 | };
47 | const selectNextIndex = () => {
48 | if (!jumpResult.value || !jumpResult.value?.length) {
49 | selectedIndex.value = 0;
50 | } else {
51 | selectedIndex.value = (selectedIndex.value + 1) % jumpResult.value.length;
52 | }
53 | };
54 | const selectPrevIndex = () => {
55 | if (!jumpResult.value || !jumpResult.value?.length) {
56 | selectedIndex.value = 0;
57 | } else {
58 | if (selectedIndex.value === 0) {
59 | selectedIndex.value = jumpResult.value.length - 1;
60 | } else {
61 | selectedIndex.value =
62 | (selectedIndex.value - 1) % jumpResult.value.length;
63 | }
64 | }
65 | };
66 |
67 | const searchInput = (input?: string) => {
68 | setJumpResult(search.search(input));
69 | };
70 |
71 | return {
72 | selectedIndex,
73 | jumpResult,
74 | showingJumpDialog,
75 |
76 | setShowJumpDialog,
77 | setJumpResult,
78 | setSelectedIndex,
79 | search: searchInput,
80 | selectNextIndex,
81 | selectPrevIndex,
82 | };
83 | });
84 |
--------------------------------------------------------------------------------
/src/Keyboard/keyboardStore.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from "pinia";
2 | import { useMagicKeys, useActiveElement, whenever } from "@vueuse/core";
3 | import { computed, type ComputedRef, type Ref } from "vue";
4 | import { useAppStore } from "@/App/appStore";
5 | import { usePanelStore } from "@/Panels/panelStore";
6 | import { useSidebarStore } from "@/Sidebar/sidebarStore";
7 | import { useEventDetailStore } from "@/EventDetail/eventDetailStore";
8 | import { useTransformStore } from "@/Markwhen/transformStore";
9 | import { getLast } from "@markwhen/parser/lib/Noder";
10 | import { useAppSettingsStore } from "@/AppSettings/appSettingsStore";
11 | import { useJumpStore } from "@/Jump/jumpStore";
12 |
13 | export const useKeyboardStore = defineStore("keyboard", () => {
14 | const sidebarStore = useSidebarStore();
15 | const panelStore = usePanelStore();
16 | const activeElement = useActiveElement();
17 | const appSettingsStore = useAppSettingsStore();
18 | const jumpStore = useJumpStore();
19 | const eventDetailStore = useEventDetailStore();
20 | const transformStore = useTransformStore();
21 |
22 | const notUsingInput = computed(
23 | () =>
24 | activeElement.value?.tagName !== "INPUT" &&
25 | activeElement.value?.tagName !== "TEXTAREA" &&
26 | !activeElement.value?.isContentEditable
27 | );
28 |
29 | const { l, d, t, z, j } = useMagicKeys();
30 | const period = useMagicKeys()["."];
31 | const comma = useMagicKeys()[","];
32 |
33 | const and = (a: Ref, b: Ref) =>
34 | computed(() => a.value && b.value);
35 |
36 | const key = (k: ComputedRef, f: () => void) =>
37 | whenever(and(notUsingInput, k), f);
38 |
39 | key(l, appSettingsStore.toggleDarkMode);
40 | key(d, () =>
41 | panelStore.setVisibility("detail", !panelStore.detailPanelState.visible)
42 | );
43 | // timeline
44 | key(t, () => {});
45 | // map
46 | key(z, sidebarStore.toggle);
47 | key(j, () => jumpStore.setShowJumpDialog(!jumpStore.showingJumpDialog));
48 | key(comma, () => {
49 | if (!eventDetailStore.detailEventPath) {
50 | const last =
51 | transformStore.transformedEvents &&
52 | getLast(transformStore.transformedEvents).path;
53 | if (last) {
54 | eventDetailStore.setDetailEventPath({
55 | type: "pageFiltered",
56 | path: last,
57 | });
58 | }
59 | } else if (eventDetailStore.prev) {
60 | eventDetailStore.setDetailEventPath(eventDetailStore.prev);
61 | }
62 | });
63 | key(period, () => {
64 | if (!eventDetailStore.detailEventPath) {
65 | eventDetailStore.setDetailEventPath({ type: "pageFiltered", path: [0] });
66 | } else if (eventDetailStore.next) {
67 | eventDetailStore.setDetailEventPath(eventDetailStore.next);
68 | }
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/Markwhen/EventMarkdown.vue:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
49 |
50 |
55 |
62 |
67 |
68 |
75 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/src/Markwhen/composables/useEventRefs.ts:
--------------------------------------------------------------------------------
1 | import { usePageStore } from "@/Markwhen/pageStore";
2 | import { COMPLETION_REGEX } from "@markwhen/parser/lib/regex";
3 | import type { Event } from "@markwhen/parser/lib/Types";
4 | import type { MaybeRef } from "@vueuse/core";
5 | import { ref, watchEffect, type Ref, watch, unref } from "vue";
6 | import {
7 | dateRangeIsoComparator,
8 | stringArrayComparator,
9 | supplementalComparator,
10 | matchedListItemsComparator,
11 | bothDefined,
12 | recurrenceComparator,
13 | } from "@/Markwhen/utilities/eventComparator";
14 | import { toInnerHtml } from "@/Markwhen/utilities/innerHtml";
15 |
16 | const cachedComputed = (
17 | val: () => T | undefined,
18 | comparator: (a: T | undefined, b: T | undefined) => boolean,
19 | condition: () => boolean,
20 | defaultValue?: T
21 | ) => {
22 | // @ts-ignore
23 | const r: Ref = ref(defaultValue ? defaultValue : undefined);
24 | watchEffect(() => {
25 | if (!condition()) {
26 | return;
27 | }
28 | const value = val();
29 | if (typeof value === "undefined") {
30 | r.value = undefined;
31 | } else if (!r.value || !comparator(r.value, value)) {
32 | r.value = value;
33 | }
34 | });
35 | return r;
36 | };
37 |
38 | export const useEventRefs = (
39 | event: MaybeRef,
40 | isEventRow: () => boolean = () => true
41 | ) => {
42 | const pageStore = usePageStore();
43 |
44 | const cachedEventComputed = (
45 | val: () => T,
46 | comparator: (a: T, b: T) => boolean = (a, b) => a === b,
47 | defaultValue?: T
48 | ) =>
49 | cachedComputed(
50 | val,
51 | (a, b) => bothDefined(a, b) && comparator(a!, b!),
52 | isEventRow,
53 | defaultValue
54 | );
55 |
56 | const eventRange = cachedEventComputed(
57 | () => unref(event)!.dateRangeIso,
58 | dateRangeIsoComparator
59 | );
60 |
61 | const eventLocations = cachedEventComputed(
62 | () => unref(event)?.eventDescription?.locations || [],
63 | stringArrayComparator
64 | );
65 |
66 | const completed = cachedEventComputed(
67 | () => unref(event)?.eventDescription.completed
68 | );
69 |
70 | const supplemental = cachedEventComputed(
71 | () => unref(event)?.eventDescription?.supplemental || [],
72 | supplementalComparator
73 | );
74 |
75 | const percent = cachedEventComputed(
76 | () => unref(event)?.eventDescription?.percent
77 | );
78 |
79 | const matchedListItems = cachedEventComputed(
80 | () => unref(event)?.eventDescription?.matchedListItems || [],
81 | matchedListItemsComparator
82 | );
83 |
84 | const tags = cachedEventComputed(
85 | () => unref(event)?.eventDescription?.tags || [],
86 | stringArrayComparator
87 | );
88 |
89 | const color = ref();
90 | watchEffect(() => {
91 | if (!isEventRow()) {
92 | return;
93 | }
94 | const eventColor = tags.value?.length
95 | ? pageStore.tags[tags.value[0]]
96 | : undefined;
97 | if (color.value !== eventColor) {
98 | color.value = eventColor;
99 | }
100 | });
101 |
102 | const dateText = cachedEventComputed(() => {
103 | const e = unref(event);
104 | if (e?.recurrenceRangeInText?.content) {
105 | return e.recurrenceRangeInText.content;
106 | }
107 | return toInnerHtml(e?.dateText || "");
108 | });
109 |
110 | const titleHtml = cachedEventComputed(() => {
111 | const ed = unref(event)?.eventDescription?.eventDescription;
112 | if (!ed) {
113 | return "";
114 | }
115 | return toInnerHtml(
116 | ed.replace(COMPLETION_REGEX, (a, b) => a.substring(b.length))
117 | );
118 | });
119 |
120 | const recurrence = cachedEventComputed(
121 | () => unref(event)?.recurrence,
122 | recurrenceComparator
123 | );
124 |
125 | return {
126 | eventRange,
127 | eventLocations,
128 | supplemental,
129 | percent,
130 | matchedListItems,
131 | tags,
132 | color,
133 | dateText,
134 | titleHtml,
135 | completed,
136 | recurrence,
137 | };
138 | };
139 |
--------------------------------------------------------------------------------
/src/Markwhen/composables/usePageEffect.ts:
--------------------------------------------------------------------------------
1 | import {
2 | newOrder,
3 | useEditorOrchestratorStore,
4 | } from "@/EditorOrchestrator/editorOrchestratorStore";
5 | import { computed, reactive, watchEffect, watch } from "vue";
6 | import { useMarkwhenStore } from "../markwhenStore";
7 | import { usePageStore } from "../pageStore";
8 |
9 | export const usePageEffect = (
10 | defaultPageState: (pageIndex: number) => T
11 | ) => {
12 | const pageStore = usePageStore();
13 | const editorOrchestrator = useEditorOrchestratorStore();
14 | const markwhenStore = useMarkwhenStore();
15 |
16 | const pageState = reactive({} as { [pageIndex: number]: T });
17 |
18 | watch(
19 | () => markwhenStore.timelines.length,
20 | (length) => {
21 | const indices = Object.keys(pageState);
22 | const toDelete = indices.filter((i) => parseInt(i) >= length);
23 | toDelete.forEach((i) => delete pageState[parseInt(i)]);
24 | }
25 | );
26 |
27 | watchEffect(() => {
28 | const pageIndex = pageStore.pageIndex;
29 | if (pageState[pageIndex] === undefined) {
30 | // If we do not have state for this page, give it the default
31 | pageState[pageIndex] = defaultPageState(pageIndex);
32 | }
33 | });
34 |
35 | pageStore.$onAction(({ name, store, args, after }) => {
36 | if (name === "setPageIndex") {
37 | const pageIndex = args[0];
38 | if (pageState[pageIndex] === undefined) {
39 | pageState[pageIndex] = defaultPageState(pageIndex);
40 | }
41 | }
42 | });
43 |
44 | editorOrchestrator.$onAction(({ name, store, args, after }) => {
45 | switch (name) {
46 | case "movePages":
47 | const [from, to] = args;
48 | if (from === to) {
49 | return;
50 | }
51 | const order = newOrder(
52 | markwhenStore.timelines.map((c, i) => i),
53 | from,
54 | to
55 | );
56 | const rearrangedSettings = order.map((i) => pageState[i]);
57 | const newIndex = order.findIndex((i) => i === pageStore.pageIndex);
58 | const newIndices = Object.keys(rearrangedSettings);
59 |
60 | after(() => {
61 | for (const newIndex of newIndices) {
62 | const i = parseInt(newIndex);
63 | pageState[i] = rearrangedSettings[i];
64 | }
65 | pageStore.setPageIndex(newIndex);
66 | });
67 |
68 | break;
69 | case "deletePage":
70 | const index = args[0];
71 | if (index === 0 && markwhenStore.timelines.length === 1) {
72 | return;
73 | }
74 | if (
75 | pageStore.pageIndex === index &&
76 | index === markwhenStore.timelines.length - 1
77 | ) {
78 | pageStore.setPageIndex(index - 1);
79 | }
80 | // Move all the settings up
81 | const indices = Object.keys(pageState)
82 | .map(parseInt)
83 | .filter((i) => i > index)
84 | .sort();
85 | indices.forEach((i) => (pageState[i - 1] = pageState[i]));
86 | delete pageState[indices[indices.length]];
87 | break;
88 | }
89 | });
90 |
91 | return computed({
92 | get: () => pageState[pageStore.pageIndex],
93 | set(newVal: T) {
94 | pageState[pageStore.pageIndex] = newVal;
95 | },
96 | });
97 | };
98 |
--------------------------------------------------------------------------------
/src/Markwhen/composables/usePageEffects.ts:
--------------------------------------------------------------------------------
1 | import { useEditorOrchestratorStore } from "@/EditorOrchestrator/editorOrchestratorStore";
2 | import { watchEffect } from "vue";
3 | import { useMarkwhenStore } from "../markwhenStore";
4 | import { usePageStore } from "../pageStore";
5 |
6 | export const usePageEffects = () => {
7 | const editorOrchestrator = useEditorOrchestratorStore();
8 | const pageStore = usePageStore();
9 | const markwhenStore = useMarkwhenStore();
10 |
11 | editorOrchestrator.$onAction(({ name, store, args, after }) => {
12 | switch (name) {
13 | case "addPage":
14 | // it's either timelines.length or timelines.length + 1
15 | const newLength = markwhenStore.timelines.length;
16 | after(() => pageStore.setPageIndex(newLength));
17 | break;
18 | case "deletePage":
19 | const index = args[0];
20 | if (markwhenStore.timelines.length === 1) {
21 | break;
22 | }
23 | if (
24 | pageStore.pageIndex === index &&
25 | index === markwhenStore.timelines.length - 1
26 | ) {
27 | pageStore.setPageIndex(index - 1);
28 | } else if (index < pageStore.pageIndex) {
29 | pageStore.setPageIndex(index - 1);
30 | }
31 | }
32 | });
33 |
34 | watchEffect(() => {
35 | const numPages = markwhenStore.timelines.length;
36 | if (pageStore.pageIndex >= numPages) {
37 | pageStore.setPageIndex(pageStore.pageIndex - 1);
38 | }
39 | });
40 | };
41 |
--------------------------------------------------------------------------------
/src/Markwhen/composables/useParserWorker.ts:
--------------------------------------------------------------------------------
1 | import { emptyTimeline, type Timeline } from "@markwhen/parser/lib/Types";
2 | import { ref, watch, type Ref } from "vue";
3 |
4 | export const useParserWorker = (rawTimelineString: Ref) => {
5 | const workerPath = "../../workers/parse.worker.ts";
6 |
7 | const timelines = ref([emptyTimeline()]);
8 | const isRunning = ref(false);
9 | const queuedString = ref(rawTimelineString.value);
10 | let timeStart = 0;
11 |
12 | const worker = new Worker(new URL(workerPath, import.meta.url), {
13 | type: "module",
14 | });
15 |
16 | watch(rawTimelineString, (s) => {
17 | queuedString.value = s;
18 | if (!isRunning.value) {
19 | isRunning.value = true;
20 | timeStart = performance.now();
21 | worker.postMessage({ rawTimelineString: queuedString.value });
22 | }
23 | });
24 |
25 | worker.addEventListener("message", (message) => {
26 | const { timelines: fromWorker, cache: c } = message.data;
27 | // console.log("parse time", performance.now() - timeStart);
28 | timelines.value = fromWorker;
29 | if (queuedString.value !== rawTimelineString.value) {
30 | worker.postMessage({ rawTimelineString: queuedString.value });
31 | } else {
32 | isRunning.value = false;
33 | }
34 | // cache = console.log(timelines, cache);
35 | });
36 |
37 | return { timelines };
38 | };
39 |
--------------------------------------------------------------------------------
/src/Markwhen/eventMapStore.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | EventPath,
3 | EventPaths,
4 | } from "@/Views/ViewOrchestrator/useStateSerializer";
5 | import type { SomeNode } from "@markwhen/parser/lib/Node";
6 | import { isEventNode, eventValue, iterate } from "@markwhen/parser/lib/Noder";
7 | import type { Event } from "@markwhen/parser/lib/Types";
8 | import { defineStore } from "pinia";
9 | import { computed } from "vue";
10 | import { useEventFinder } from "@/Views/ViewOrchestrator/useEventFinder";
11 | import { usePageStore } from "./pageStore";
12 | import { useTransformStore } from "./transformStore";
13 |
14 | type EventPathMap = [number[], Map];
15 |
16 | const buildMap = (
17 | events: SomeNode | undefined,
18 | type: EventPath["type"]
19 | ): EventPathMap => {
20 | const keys = [] as number[];
21 | const map = new Map();
22 |
23 | if (!events) {
24 | return [keys, map];
25 | }
26 |
27 | // TODO: this doesn't need to run as often, or make it more efficient/smarter
28 | for (const { path, node } of iterate(events)) {
29 | const stringIndex = isEventNode(node)
30 | ? eventValue(node).rangeInText.from
31 | : node.rangeInText?.from;
32 | if (stringIndex !== undefined) {
33 | keys.push(stringIndex);
34 | map.set(stringIndex, { type, path });
35 | }
36 | }
37 |
38 | if (keys.length !== map.size) {
39 | throw new Error("Mismatched keys and map size");
40 | }
41 | return [keys, map];
42 | };
43 |
44 | const getter = (index: number, keys: number[], map: Map) => {
45 | let left = 0;
46 | let right = keys.length - 1;
47 | while (left <= right) {
48 | let mid = Math.floor((right + left) / 2);
49 | if (keys[mid] === index) {
50 | return map.get(keys[mid]);
51 | } else if (keys[mid] < index) {
52 | left = mid + 1;
53 | } else {
54 | right = mid - 1;
55 | }
56 | }
57 | if (keys[left] < keys[right]) {
58 | return map.get(keys[left]);
59 | }
60 | return map.get(keys[right]);
61 | };
62 |
63 | const indexFromEventOrIndex = (
64 | eventOrStartIndexOrPath: number | Event | EventPath
65 | ): number => {
66 | if (typeof eventOrStartIndexOrPath === "number") {
67 | return eventOrStartIndexOrPath;
68 | }
69 | if ("path" in eventOrStartIndexOrPath) {
70 | const node = useEventFinder(eventOrStartIndexOrPath).value;
71 | if (node) {
72 | if (isEventNode(node)) {
73 | return node.value.rangeInText.from;
74 | } else {
75 | return node.rangeInText!.from;
76 | }
77 | }
78 | throw new Error("Not a valid path");
79 | }
80 | return eventOrStartIndexOrPath.rangeInText.from;
81 | };
82 |
83 | export const useEventMapStore = defineStore("eventMap", () => {
84 | const transformStore = useTransformStore();
85 | const pageStore = usePageStore();
86 |
87 | const pageEvents = computed(() => pageStore.pageTimeline.events);
88 | const transformedEvents = computed(() => transformStore.transformedEvents);
89 |
90 | const pageMap = computed(() => {
91 | console.log("building page map");
92 | const [keys, map] = buildMap(pageEvents.value, "page");
93 | return (eventOrStartIndexOrPath: number | Event | EventPath) =>
94 | getter(indexFromEventOrIndex(eventOrStartIndexOrPath), keys, map);
95 | });
96 |
97 | const transformedMap = computed(() => {
98 | console.log("building transform map");
99 | const [keys, map] = buildMap(transformedEvents.value, "pageFiltered");
100 | return (eventOrStartIndexOrPath: number | Event | EventPath) =>
101 | getter(indexFromEventOrIndex(eventOrStartIndexOrPath), keys, map);
102 | });
103 |
104 | const getAllPaths = computed(
105 | () =>
106 | (eventOrStartIndexOrPath: number | Event | EventPath): EventPaths => ({
107 | page: pageMap.value(eventOrStartIndexOrPath),
108 | pageFiltered: transformedMap.value(eventOrStartIndexOrPath),
109 | })
110 | );
111 |
112 | return {
113 | getPagePath: pageMap,
114 | getTransformedPath: transformedMap,
115 | getAllPaths,
116 | };
117 | });
118 |
--------------------------------------------------------------------------------
/src/Markwhen/markwhenStore.ts:
--------------------------------------------------------------------------------
1 | import { parse } from "@markwhen/parser";
2 | import { Cache } from "@markwhen/parser/lib/Cache";
3 | import { defineStore } from "pinia";
4 | import { computed, reactive, ref, shallowReactive, type Ref } from "vue";
5 | import type { Timeline } from "@markwhen/parser/lib/Types";
6 | import { useParserWorker } from "./composables/useParserWorker";
7 | import { exampleTimeline } from "@/exampleTimeline";
8 |
9 | export const useMarkwhenStore = defineStore("markwhen", () => {
10 | const rawTimelineString = ref(exampleTimeline);
11 |
12 | // const cache = reactive(new Cache());
13 | // const timelines = computed(
14 | // () => parse(rawTimelineString.value, cache).timelines
15 | // );
16 |
17 | const useWorker = false;
18 | // console.log("using worker", useWorker);
19 |
20 | let timelines: Ref;
21 | const cache = shallowReactive(new Cache());
22 | if (useWorker) {
23 | timelines = useParserWorker(rawTimelineString).timelines;
24 | timelines.value = parse(rawTimelineString.value, cache).timelines;
25 | } else {
26 | timelines = computed(() => {
27 | // const start = performance.now();
28 | const r = parse(rawTimelineString.value, cache).timelines;
29 | // console.log("normal", performance.now() - start);
30 | return r;
31 | });
32 | }
33 |
34 | const setRawTimelineString = (s: string) => {
35 | rawTimelineString.value = s;
36 | };
37 |
38 | return {
39 | // state
40 | rawTimelineString,
41 | cache,
42 |
43 | // getters
44 | timelines,
45 |
46 | // actions
47 | setRawTimelineString,
48 | };
49 | });
50 |
--------------------------------------------------------------------------------
/src/Markwhen/pageStore.ts:
--------------------------------------------------------------------------------
1 | import { ranges } from "@/utilities/ranges";
2 | import { DateTime } from "luxon";
3 | import { defineStore } from "pinia";
4 | import { computed, ref, watch } from "vue";
5 | import { useMarkwhenStore } from "./markwhenStore";
6 |
7 | export const recurrenceLimit = 100;
8 |
9 | export const usePageStore = defineStore("page", () => {
10 | const pageIndex = ref(0);
11 | const markwhenStore = useMarkwhenStore();
12 |
13 | const setPageIndex = (index: number) => {
14 | pageIndex.value = index;
15 | };
16 |
17 | const pageTimeline = computed(() => markwhenStore.timelines[pageIndex.value]);
18 | const pageRange = computed(
19 | () =>
20 | ranges(pageTimeline.value.events, recurrenceLimit) || {
21 | fromDateTime: DateTime.now().minus({ years: 5 }),
22 | toDateTime: DateTime.now().plus({ years: 5 }),
23 | }
24 | );
25 | const pageRangeFrom = computed(() => pageRange.value.fromDateTime.toISO());
26 | const pageRangeTo = computed(() => pageRange.value.toDateTime.toISO());
27 | const pageTimelineMetadata = computed(() => pageTimeline.value.metadata);
28 | const header = computed(() => pageTimeline.value.header)
29 | const tags = computed(() => pageTimeline.value.tags);
30 |
31 | const pageTimelineString = computed(() =>
32 | markwhenStore.rawTimelineString.slice(
33 | pageTimelineMetadata.value.startStringIndex,
34 | pageTimelineMetadata.value.endStringIndex
35 | )
36 | );
37 |
38 | return {
39 | // state
40 | pageIndex,
41 |
42 | // actions
43 | setPageIndex,
44 |
45 | // getters
46 | pageTimeline,
47 | pageTimelineMetadata,
48 | pageTimelineString,
49 | header,
50 | tags,
51 | pageRange,
52 | pageRangeFrom,
53 | pageRangeTo,
54 | };
55 | });
56 |
--------------------------------------------------------------------------------
/src/Markwhen/transformStore.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from "pinia";
2 | import { computed, ref } from "vue";
3 | import { usePageEffect } from "./composables/usePageEffect";
4 | import { transformRoot } from "./composables/useTransform";
5 | import { usePageStore } from "./pageStore";
6 |
7 | export const sorts = ["none", "down", "up"] as Sort[];
8 | export type Sort = "none" | "down" | "up";
9 |
10 | export const useTransformStore = defineStore("transform", () => {
11 | const pageStore = usePageStore();
12 |
13 | const sort = usePageEffect(() => "none" as Sort);
14 | const filter = usePageEffect(() => [] as string[]);
15 | const filterUntagged = usePageEffect(() => false);
16 | const filterDialogShowing = ref(false);
17 |
18 | const setSort = (s: Sort) => (sort.value = s);
19 | const clear = () => {
20 | filterUntagged.value = false;
21 | filter.value = [];
22 | };
23 | const toggleSort = () =>
24 | (sort.value = sorts[(sorts.indexOf(sort.value) + 1) % sorts.length]);
25 |
26 | const addFilterTag = (tag: string) => {
27 | if (!pageStore.pageTimeline.tags[tag]) {
28 | return
29 | }
30 | const index = filter.value.indexOf(tag);
31 | if (index >= 0) {
32 | return;
33 | }
34 | filter.value.push(tag);
35 | };
36 |
37 | const filterTag = (tag: string) => {
38 | const index = filter.value.indexOf(tag);
39 | if (index >= 0) {
40 | filter.value.splice(index, 1);
41 | } else {
42 | filter.value.push(tag);
43 | }
44 | };
45 | const toggleFilterUntagged = () =>
46 | (filterUntagged.value = !filterUntagged.value);
47 |
48 | const setFilterDialogShowing = (showing: boolean) => {
49 | filterDialogShowing.value = showing;
50 | };
51 |
52 | // const events = computed(() => [...pageStore.pageTimeline.events]);
53 |
54 | // TODO: optimize/memoize this or something. It does not need
55 | // to be recomputed on page change, we should save it
56 | const transformedEvents = computed(() =>
57 | transformRoot(
58 | pageStore.pageTimeline.events,
59 | filter.value,
60 | filterUntagged.value,
61 | sort.value
62 | )
63 | );
64 |
65 | return {
66 | // state
67 | sort,
68 | filter,
69 | filterUntagged,
70 | filterDialogShowing,
71 |
72 | // actions
73 | setSort,
74 | clear,
75 | toggleSort,
76 | filterTag,
77 | toggleFilterUntagged,
78 | setFilterDialogShowing,
79 | addFilterTag,
80 |
81 | // getters
82 | transformedEvents,
83 | };
84 | });
85 |
--------------------------------------------------------------------------------
/src/Markwhen/utilities/DateTimeDisplay.ts:
--------------------------------------------------------------------------------
1 | import { DateTime, } from "luxon"
2 |
3 | export type DateTimeToDisplay = (dt: DateTime) => string | number
4 |
5 | const hourMinuteSecond: DateTimeToDisplay = (dt) => dt.toLocaleString(DateTime.TIME_24_WITH_SECONDS)
6 | const paddedHourMinute: DateTimeToDisplay = (dt) => dt.toLocaleString(DateTime.TIME_24_SIMPLE)
7 | const year: DateTimeToDisplay = (dt) => dt.year
8 | const isoDate: DateTimeToDisplay = (dt) => dt.toISODate()
9 | const monthDayShort: DateTimeToDisplay = (dt) => `${dt.day} ${dt.monthShort}`
10 | const full: DateTimeToDisplay = (dt) => dt.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS)
11 | const empty = () => ""
12 |
13 | export const granularities: DateTimeToDisplay[][] = [
14 | // |------------------------------------------------------------------------------ Specificity of this date ------------------------------------------------------------------------------|
15 | // Resolution second // quarterminute // minute // quarterhour, // hour // day // month // year // decade
16 | /* second */ [hourMinuteSecond, hourMinuteSecond, hourMinuteSecond, hourMinuteSecond, full, full, full, full, isoDate],
17 | /* qrtrmnt */ [hourMinuteSecond, hourMinuteSecond, hourMinuteSecond, paddedHourMinute, paddedHourMinute, monthDayShort, isoDate, isoDate, isoDate],
18 | /* minute */ [(dt) => dt.second, paddedHourMinute, paddedHourMinute, paddedHourMinute, paddedHourMinute, monthDayShort, isoDate, isoDate, isoDate],
19 | /* quarter*/ [(dt) => dt.second, (dt) => dt.minute, (dt) => dt.minute, paddedHourMinute, paddedHourMinute, monthDayShort, isoDate, isoDate, isoDate],
20 | /* hour */ [empty, (dt) => dt.minute, (dt) => dt.minute, paddedHourMinute, paddedHourMinute, monthDayShort, isoDate, isoDate, isoDate],
21 | /* day */ [empty, empty, empty, paddedHourMinute, (dt) => dt.hour, (dt) => dt.day, (dt) => `${dt.monthShort} ${dt.year}`, (dt) => `${dt.monthShort} ${dt.year}`, (dt) => `${dt.monthShort} ${dt.year}`],
22 | /* month */ [empty, empty, empty, paddedHourMinute, empty, (dt) => dt.day, (dt) => dt.monthShort, year, year ],
23 | /* year */ [empty, empty, empty, paddedHourMinute, empty, empty, (dt) => dt.month, year, year ],
24 | /* decade */ [empty, empty, empty, paddedHourMinute, empty, empty, empty, year, year ]
25 | ]
26 |
--------------------------------------------------------------------------------
/src/Markwhen/utilities/dateRangeToString2.ts:
--------------------------------------------------------------------------------
1 | import type { DateFormat, DateRange } from "@markwhen/parser/lib/Types";
2 | import { DateTime } from "luxon";
3 |
4 | export function dateRangeToString(range: DateRange, dateFormat?: DateFormat) {
5 | let from, to;
6 | if (!range.fromDateTime.second && !range.fromDateTime.millisecond) {
7 | if (!range.fromDateTime.minute && !range.fromDateTime.hour) {
8 | if (dateFormat) {
9 | from = range.fromDateTime.toFormat(dateFormat);
10 | } else {
11 | // Just the date
12 | from = range.fromDateTime.toLocaleString(DateTime.DATE_SHORT);
13 | }
14 | } else {
15 | from = range.fromDateTime.toFormat("M/d/yyyy, h:mma");
16 | }
17 | } else {
18 | from = range.fromDateTime.toISO();
19 | }
20 | if (!range.toDateTime.second && !range.toDateTime.millisecond) {
21 | if (!range.toDateTime.minute && !range.toDateTime.hour) {
22 | if (+range.toDateTime === +range.fromDateTime) {
23 | to = from;
24 | } else {
25 | // Just the date, but since this is `toDateTime`, it's going
26 | // to be the day before
27 | const dayBefore = range.toDateTime.minus({ days: 1 });
28 | to = dateFormat
29 | ? dayBefore.toFormat(dateFormat)
30 | : dayBefore.toLocaleString(DateTime.DATE_SHORT);
31 | }
32 | } else {
33 | to = range.toDateTime.toFormat("M/d/yyyy, h:mma");
34 | }
35 | } else {
36 | to = range.toDateTime.toISO();
37 | }
38 | if (from === to) {
39 | return from;
40 | }
41 | return `${from} - ${to}`;
42 | }
43 |
--------------------------------------------------------------------------------
/src/Markwhen/utilities/eventComparator.ts:
--------------------------------------------------------------------------------
1 | import type { Recurrence } from "@markwhen/parser/lib/dateRange/checkRecurrence";
2 | import {
3 | Block,
4 | BlockType,
5 | Image,
6 | type DateRangeIso,
7 | type DateTimeIso,
8 | type EventDescription,
9 | type MarkdownBlock,
10 | type Range,
11 | } from "@markwhen/parser/lib/Types";
12 |
13 | export const bothDefined = (a: T | undefined, b: T | undefined) => {
14 | if (typeof a === "undefined" && typeof b === "undefined") {
15 | return true;
16 | }
17 | if (typeof a === "undefined") {
18 | return false;
19 | }
20 | if (typeof b === "undefined") {
21 | return false;
22 | }
23 | return true;
24 | };
25 |
26 | export const rangeComparator = (a: Range, b: Range) =>
27 | a.type === b.type &&
28 | a.content === b.content &&
29 | a.from === b.from &&
30 | a.lineFrom.index === b.lineFrom.index &&
31 | b.lineFrom.line === b.lineFrom.line &&
32 | a.lineTo.index === b.lineTo.index &&
33 | a.lineTo.line === b.lineTo.line &&
34 | a.to === b.to;
35 |
36 | export const dateRangeIsoComparator = (a: DateRangeIso, b: DateRangeIso) =>
37 | dateTimeIsoComparator(a.fromDateTimeIso, b.fromDateTimeIso) &&
38 | dateTimeIsoComparator(a.toDateTimeIso, b.toDateTimeIso);
39 |
40 | export const stringComparator = (a: string, b: string) => a === b;
41 |
42 | export const dateTimeIsoComparator = stringComparator;
43 |
44 | export const stringArrayComparator = (a: string[], b: string[]) =>
45 | a.length === b.length && a.every((s, i) => s === b[i]);
46 |
47 | export const eventDescriptionComparator = (
48 | a: EventDescription,
49 | b: EventDescription
50 | ) =>
51 | a.id === b.id &&
52 | a.eventDescription === b.eventDescription &&
53 | stringArrayComparator(a.locations, b.locations) &&
54 | matchedListItemsComparator(a.matchedListItems, b.matchedListItems) &&
55 | a.percent === b.percent &&
56 | supplementalComparator(a.supplemental, b.supplemental) &&
57 | stringArrayComparator(a.tags, b.tags);
58 |
59 | export const matchedListItemsComparator = (a: Range[], b: Range[]) =>
60 | a.length == b.length && a.every((r, i) => rangeComparator(r, b[i]));
61 |
62 | export const supplementalComparator = (
63 | a: MarkdownBlock[],
64 | b: MarkdownBlock[]
65 | ) =>
66 | a.length === b.length && a.every((s, i) => markdownBlockComparator(s, b[i]));
67 |
68 | export const markdownBlockComparator = (a: MarkdownBlock, b: MarkdownBlock) => {
69 | if (a.type !== b.type) {
70 | return false;
71 | }
72 | if (a.type === BlockType.IMAGE) {
73 | return (
74 | (a as Image).altText === (b as Image).altText &&
75 | (a as Image).link === (b as Image).link
76 | );
77 | }
78 | return (
79 | (a as Block).raw === (b as Block).raw &&
80 | (a as Block).value === (b as Block).value
81 | );
82 | };
83 |
84 | export const recurrenceComparator = (a?: Recurrence, b?: Recurrence) => {
85 | if (!a || !b) {
86 | return false;
87 | }
88 | if (a.for) {
89 | if (!b.for) {
90 | return false;
91 | } else {
92 | const aFor = Object.keys(a.for) as (keyof typeof a.for)[];
93 | const bFor = Object.keys(b.for) as (keyof typeof b.for)[];
94 | if (aFor.sort().join(",") !== bFor.sort().join(",")) {
95 | return false;
96 | }
97 | for (const aKey of aFor) {
98 | if (a.for[aKey] !== b.for[aKey]) {
99 | return false;
100 | }
101 | }
102 | }
103 | } else {
104 | if (b.for) {
105 | return false;
106 | }
107 | }
108 | const aEvery = Object.keys(a.every) as (keyof typeof a.every)[];
109 | const bEvery = Object.keys(b.every) as (keyof typeof b.every)[];
110 | if (aEvery.sort().join(",") !== bEvery.sort().join(",")) {
111 | return false;
112 | }
113 | for (const aKey of aEvery) {
114 | if (a.every[aKey] !== b.every[aKey]) {
115 | return false;
116 | }
117 | }
118 | return true;
119 | };
120 |
--------------------------------------------------------------------------------
/src/Markwhen/utilities/innerHtml.ts:
--------------------------------------------------------------------------------
1 | import { LINK_REGEX, AT_REGEX } from "@markwhen/parser/lib/Types";
2 |
3 |
4 | export function toInnerHtml(s: string): string {
5 | return s
6 | .replace(/<|>/g, (match) => {
7 | if (match === '<') {
8 | return "<"
9 | }
10 | return ">"
11 | })
12 | .replace(LINK_REGEX, (substring, linkText, link) => {
13 | return `${linkText}`;
16 | })
17 | .replace(/&/g, "&")
18 | .replace(AT_REGEX, (substring, at) => {
19 | return `@${at}`;
20 | });
21 | }
22 |
23 | function addHttpIfNeeded(s: string): string {
24 | if (
25 | s.startsWith("http://") ||
26 | s.startsWith("https://") ||
27 | s.startsWith("/")
28 | ) {
29 | return s;
30 | }
31 | return `http://${s}`;
32 | }
--------------------------------------------------------------------------------
/src/Markwhen/utilities/weekdayCache.ts:
--------------------------------------------------------------------------------
1 | import type { DateTime } from "luxon";
2 | import LRU from "lru-cache";
3 | // @ts-ignore
4 | import * as lxt from "luxon/src/impl/conversions.js";
5 |
6 | export const useWeekdayCache = () => {
7 | const weekdayCache = new LRU({ max: 300 });
8 |
9 | const getWeekday = (dateTime: DateTime): number => {
10 | const key = `${dateTime.year}-${dateTime.month}-${dateTime.day}`;
11 | const cached = weekdayCache.get(key);
12 | if (cached) {
13 | return cached;
14 | }
15 | const weekday = lxt.gregorianToWeek(dateTime);
16 | weekdayCache.set(key, weekday.weekday);
17 | return weekday;
18 | };
19 |
20 | return { getWeekday };
21 | };
22 |
--------------------------------------------------------------------------------
/src/MarkwhenPreview/MarkwhenPreview.vue:
--------------------------------------------------------------------------------
1 |
44 |
45 |
46 | (empty)
47 |
48 |
58 |
62 |
{{ viewersString }}
63 |
76 |
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/src/MarkwhenPreview/PagePreviewRow.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
38 |
39 | {{ pageDisplayTitle }}
40 | |
41 |
42 |
51 | {{ flattenedEvents.length }}
56 | {{ flattenedEvents.length }}
62 | {{ flattenedEvents.length }}
68 |
69 | |
70 |
71 |
72 |
73 |
82 |
--------------------------------------------------------------------------------
/src/MarkwhenPreview/PreviewTableHeader.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 | |
18 | {{ displayRange[0] }} |
19 |
20 | {{ displayRange[0] }}{{ displayRange[1] }}
22 | |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/NewEvent/NewEvent.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/NewEvent/NewEventDialog.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
134 |
135 |
136 |
137 |
--------------------------------------------------------------------------------
/src/NewEvent/newEventStore.ts:
--------------------------------------------------------------------------------
1 | import { useEditorOrchestratorStore } from "@/EditorOrchestrator/editorOrchestratorStore";
2 | import {
3 | ceilDateTime,
4 | floorDateTime,
5 | } from "@/Markwhen/utilities/dateTimeUtilities";
6 | import { parseDateRange } from "@markwhen/parser";
7 | import {
8 | toDateRange,
9 | type DateRange,
10 | type DateRangeIso,
11 | } from "@markwhen/parser/lib/Types";
12 | import { DateTime } from "luxon";
13 | import { defineStore } from "pinia";
14 | import { ref, watch } from "vue";
15 | import { dateRangeToString } from "@/Markwhen/utilities/dateRangeToString2";
16 |
17 | export type EventCreationParams = {
18 | title?: string;
19 | description?: string;
20 | range?: DateRangeIso | string;
21 | };
22 |
23 | export const todayRange = () => ({
24 | fromDateTime: floorDateTime(DateTime.now(), "day"),
25 | toDateTime: ceilDateTime(DateTime.now(), "day"),
26 | });
27 |
28 | export const useNewEventStore = defineStore("newEvent", () => {
29 | const editorOrchestrator = useEditorOrchestratorStore();
30 |
31 | const showing = ref(false);
32 |
33 | const range = ref(todayRange());
34 | const title = ref("Event");
35 | const details = ref("");
36 |
37 | const setShowing = (show: boolean) => {
38 | showing.value = false;
39 | };
40 |
41 | const setTitle = (t: string) => {
42 | title.value = t;
43 | };
44 |
45 | const setDetails = (t: string) => {
46 | details.value = t;
47 | };
48 |
49 | const prompt = (params?: EventCreationParams) => {
50 | reset();
51 | if (params) {
52 | if (params.title) {
53 | title.value = params.title;
54 | }
55 | if (params.description) {
56 | details.value = params.description;
57 | }
58 | if (params.range) {
59 | if (typeof params.range === "string") {
60 | range.value = parseDateRange(`${params.range}:`) || todayRange();
61 | } else {
62 | range.value = toDateRange(params.range);
63 | }
64 | }
65 | }
66 | showing.value = true;
67 | };
68 |
69 | const reset = () => {
70 | title.value = "Event";
71 | range.value = todayRange();
72 | details.value = "";
73 | };
74 |
75 | const createEventWithValues = () => {
76 | editorOrchestrator.createEvent({
77 | title: title.value,
78 | range: dateRangeToString(range.value),
79 | description: details.value,
80 | });
81 | };
82 |
83 | const setRange = (dateRange: DateRange) => {
84 | range.value = dateRange;
85 | };
86 |
87 | return {
88 | showing,
89 | title,
90 | details,
91 | range,
92 | setRange,
93 | setDetails,
94 | setTitle,
95 | setShowing,
96 | prompt,
97 | reset,
98 | createEventWithValues,
99 | };
100 | });
101 |
--------------------------------------------------------------------------------
/src/Panels/PanelViewButtons.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
23 |
29 |
30 |
31 |
38 |
49 |
72 |
73 |
80 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/src/Panels/Panels.vue:
--------------------------------------------------------------------------------
1 |
60 |
61 |
62 |
75 |
76 |
77 |
85 |
--------------------------------------------------------------------------------
/src/Panels/ResizeBar.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/Panels/Visualizations.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/Panels/composables/usePanelMove.ts:
--------------------------------------------------------------------------------
1 | import { useAppStore } from "@/App/appStore";
2 | import { useThrottleFn, type MaybeRef } from "@vueuse/shared";
3 | import { ref, unref } from "vue";
4 |
5 | export const usePanelMove = (
6 | panel: MaybeRef,
7 | done: (translateX: number) => void
8 | ) => {
9 | const startX = ref();
10 | const translateX = ref(0);
11 | const parentScrollWidth = ref(0);
12 | const appStore = useAppStore();
13 |
14 | const escapeListener = (e: KeyboardEvent) => {
15 | if (e.key === "Escape") {
16 | stopMoving();
17 | }
18 | };
19 |
20 | const stopMoving = () => {
21 | startX.value = undefined;
22 | translateX.value = 0;
23 | document.removeEventListener("mousemove", moveListener);
24 | document.removeEventListener("touchmove", moveListener);
25 | document.removeEventListener("mouseup", endMoveListener);
26 | document.removeEventListener("touchend", endMoveListener);
27 | document.removeEventListener("keydown", escapeListener);
28 | appStore.clearGlobalClass();
29 | };
30 |
31 | const endMoveListener = (e: MouseEvent | TouchEvent) => {
32 | done(translateX.value);
33 | stopMoving();
34 | };
35 |
36 | const moveListener = useThrottleFn((e: MouseEvent | TouchEvent) => {
37 | const element = unref(panel);
38 | if (!element) {
39 | return;
40 | }
41 | // We shouldn't go any further than the start of the parent element
42 | const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
43 | const diff = clientX - startX.value!;
44 | const parentOffsetLeft = element.parentElement?.offsetLeft || 0;
45 | const maxRight =
46 | parentScrollWidth.value -
47 | element.offsetLeft -
48 | element.clientWidth +
49 | parentOffsetLeft;
50 | translateX.value = Math.min(
51 | Math.max(-element.offsetLeft + parentOffsetLeft, diff),
52 | maxRight
53 | );
54 | }, 10);
55 |
56 | const startMoving = (e: MouseEvent | TouchEvent) => {
57 | e.preventDefault();
58 | startX.value = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
59 | document.addEventListener("touchmove", moveListener);
60 | document.addEventListener("mousemove", moveListener);
61 | document.addEventListener("touchend", endMoveListener);
62 | document.addEventListener("mouseup", endMoveListener);
63 | document.addEventListener("keydown", escapeListener);
64 |
65 | parentScrollWidth.value = unref(panel)!.parentElement!.scrollWidth;
66 | appStore.setGlobalClass("resizing");
67 | };
68 |
69 | return { moveListener: startMoving, translateX };
70 | };
71 |
--------------------------------------------------------------------------------
/src/QuickEditor/QuickEditor.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
26 |
27 |
40 |
52 |
53 |
54 |
59 |
60 |
61 |
62 |
70 |
--------------------------------------------------------------------------------
/src/Settings/SettingsButton.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
41 |
42 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/Sidebar/DarkModeButton.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
34 |
47 |
58 |
69 |
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/Sidebar/Sidebar.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/Sidebar/ToggleSidebarButton.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
37 |
54 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/src/Sidebar/composables/usePanelResize.ts:
--------------------------------------------------------------------------------
1 | import { useAppStore } from "@/App/appStore";
2 | import type { MaybeRef } from "@vueuse/shared";
3 | import { ref, unref } from "vue";
4 |
5 | export const usePanelResize = (
6 | isLeft: MaybeRef,
7 | currentWidth: MaybeRef,
8 | setNewWidth: (width: number) => void
9 | ) => {
10 | const resizeXStarted = ref(false);
11 | const resizeStartX = ref(0);
12 | const tempWidth = ref(0);
13 | const appStore = useAppStore();
14 |
15 | const pageX = (e: MouseEvent | TouchEvent) =>
16 | e instanceof MouseEvent ? e.pageX : e.touches[0].pageX;
17 |
18 | const stop = () => {
19 | document.removeEventListener("mouseup", resizeMouseUp);
20 | document.removeEventListener("touchend", resizeMouseUp);
21 | document.removeEventListener("mousemove", resizeMouseMove);
22 | document.removeEventListener("touchmove", resizeMouseMove);
23 | document.removeEventListener("keydown", escapeListener);
24 | appStore.clearGlobalClass();
25 | };
26 |
27 | const escapeListener = (e: KeyboardEvent) => {
28 | if (e.key === "Escape") {
29 | resizeXStarted.value = false;
30 | tempWidth.value = 0;
31 | stop();
32 | }
33 | };
34 |
35 | const resizeMouseMove = (e: MouseEvent | TouchEvent) => {
36 | if (resizeXStarted.value) {
37 | if (unref(isLeft)) {
38 | tempWidth.value = Math.max(
39 | unref(currentWidth) - resizeStartX.value + pageX(e),
40 | 150
41 | );
42 | } else {
43 | tempWidth.value = Math.max(
44 | unref(currentWidth) + resizeStartX.value - pageX(e),
45 | 150
46 | );
47 | }
48 | }
49 | };
50 |
51 | const resizeMouseUp = (e: MouseEvent | TouchEvent) => {
52 | resizeXStarted.value = false;
53 |
54 | if (tempWidth.value) {
55 | setNewWidth(Math.max(tempWidth.value, 50));
56 | // TODO: cookie
57 | tempWidth.value = 0;
58 | }
59 | stop();
60 | };
61 |
62 | const resizeMouseDown = (e: MouseEvent | TouchEvent) => {
63 | resizeXStarted.value = true;
64 | resizeStartX.value = pageX(e);
65 |
66 | document.addEventListener("mousemove", resizeMouseMove);
67 | document.addEventListener("mouseup", resizeMouseUp);
68 | document.addEventListener("touchmove", resizeMouseMove);
69 | document.addEventListener("touchend", resizeMouseUp);
70 | document.addEventListener("keydown", escapeListener);
71 |
72 | appStore.setGlobalClass("resizing");
73 | };
74 |
75 | return {
76 | tempWidth,
77 | resizeMouseDown,
78 | };
79 | };
80 |
--------------------------------------------------------------------------------
/src/Sidebar/sidebarStore.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from "pinia";
2 | import { ref } from "vue";
3 |
4 | export type SidebarComponent = "" | "editor" | "profile"
5 |
6 | export const useSidebarStore = defineStore("sidebar", () => {
7 | const selectedComponent = ref("");
8 | const isLeft = ref(true);
9 | const hasSeenHowTo = ref(true);
10 | const width = ref(450);
11 | const visible = ref(true)
12 |
13 | const setWidth = (w: number) => {
14 | width.value = w;
15 | };
16 |
17 | const selectComponent = (c: SidebarComponent) => {
18 | selectedComponent.value = c
19 | }
20 |
21 | const toggleSide = () => {
22 | isLeft.value = !isLeft.value
23 | }
24 |
25 | const toggle = () => {
26 | visible.value = !visible.value
27 | }
28 |
29 | return {
30 | // state
31 | selectedComponent,
32 | isLeft,
33 | hasSeenHowTo,
34 | width,
35 | visible,
36 |
37 | // actions
38 | setWidth,
39 | selectComponent,
40 | toggleSide,
41 | toggle
42 | };
43 | });
44 |
--------------------------------------------------------------------------------
/src/Transitions/Fade.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
20 |
--------------------------------------------------------------------------------
/src/Views/ViewOrchestrator/useEventFinder.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | EventPath,
3 | EventPaths,
4 | } from "@/Views/ViewOrchestrator/useStateSerializer";
5 | import type { SomeNode } from "@markwhen/parser/lib/Node";
6 | import { get } from "@markwhen/parser/lib/Noder";
7 | import type { MaybeRef } from "@vueuse/core";
8 | import { computed, ref, unref, watchEffect } from "vue";
9 | import { usePageStore } from "@/Markwhen/pageStore";
10 | import { useTransformStore } from "@/Markwhen/transformStore";
11 |
12 | export const eqPath = (ep: EventPath, eps: EventPaths): boolean => {
13 | const path = eps[ep.type]?.path;
14 | if (path?.length !== ep.path.length) {
15 | return false;
16 | }
17 | for (let i = 0; i < path.length; i++) {
18 | if (path[i] !== ep.path[i]) {
19 | return false;
20 | }
21 | }
22 | return true;
23 | };
24 |
25 | export type EventFinder = (
26 | eventPath?: EventPath | EventPaths | null
27 | ) => SomeNode | undefined;
28 |
29 | export const useEventFinder = (
30 | path?: MaybeRef
31 | ) => {
32 | const transformStore = useTransformStore();
33 | const pageStore = usePageStore();
34 | const transformedEvents = computed(() => transformStore.transformedEvents);
35 |
36 | const isEventPath = (e: EventPath | EventPaths): e is EventPath => {
37 | return (e as EventPath).path && Array.isArray((e as EventPath).path);
38 | };
39 |
40 | const event = ref();
41 |
42 | watchEffect(() => {
43 | if (!path) {
44 | event.value = undefined;
45 | return;
46 | }
47 | const eventPath = unref(path);
48 | if (!eventPath) {
49 | event.value = undefined;
50 | return;
51 | }
52 | if (isEventPath(eventPath)) {
53 | const path = eventPath.path;
54 | let node: SomeNode | undefined;
55 | if (eventPath.type === "pageFiltered") {
56 | node = transformedEvents.value;
57 | } else if (eventPath.type === "page") {
58 | node = pageStore.pageTimeline.events;
59 | } else {
60 | event.value = undefined;
61 | throw new Error("unimplemented");
62 | }
63 | event.value = node ? get(node, eventPath.path) : undefined;
64 | return;
65 | } else {
66 | const types: EventPath["type"][] = ["page", "pageFiltered", "whole"];
67 | for (const type of types) {
68 | if (!eventPath[type]) {
69 | event.value = undefined;
70 | continue;
71 | }
72 | let root: SomeNode | undefined;
73 | if (type === "pageFiltered") {
74 | root = transformedEvents.value;
75 | } else if (type === "page") {
76 | root = pageStore.pageTimeline.events;
77 | }
78 | if (root) {
79 | event.value = get(root, eventPath[type]!.path);
80 | return;
81 | }
82 | }
83 | }
84 | event.value = undefined;
85 | });
86 |
87 | return event;
88 | };
89 |
--------------------------------------------------------------------------------
/src/Views/ViewOrchestrator/useLpc.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | DateFormat,
3 | DateRangeIso,
4 | DateTimeGranularity,
5 | } from "@markwhen/parser/lib/Types";
6 | import type { Ref } from "vue";
7 | import type { DisplayScale } from "@/Markwhen/utilities/dateTimeUtilities";
8 | import type { EventPath, State } from "./useStateSerializer";
9 |
10 | export interface MessageTypes {
11 | state: State;
12 | setHoveringPath: EventPath;
13 | setDetailPath: EventPath;
14 | key: string;
15 | showInEditor: EventPath;
16 | newEvent: {
17 | dateRangeIso: DateRangeIso;
18 | granularity?: DateTimeGranularity;
19 | immediate: boolean;
20 | };
21 | editEventDateRange: {
22 | path: EventPath;
23 | range: DateRangeIso;
24 | scale: DisplayScale;
25 | preferredInterpolationFormat: DateFormat | undefined;
26 | };
27 | jumpToPath: {
28 | path: EventPath;
29 | };
30 | jumpToRange: {
31 | dateRangeIso: DateRangeIso;
32 | };
33 | }
34 |
35 | type MessageType = keyof (MessageTypes &
36 | ViewSpecificMessageTypes);
37 | type MessageParam> = (MessageTypes & VSMT)[T];
38 |
39 | export interface Message> {
40 | type: T;
41 | request?: boolean;
42 | response?: boolean;
43 | id: string;
44 | params?: MessageParam;
45 | }
46 |
47 | type MessageListeners = {
48 | [Property in MessageType]?: (
49 | event: (MessageTypes & VSMT)[Property]
50 | ) => any;
51 | };
52 | export const getNonce = () => {
53 | let text = "";
54 | const possible =
55 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
56 | for (let i = 0; i < 32; i++) {
57 | text += possible.charAt(Math.floor(Math.random() * possible.length));
58 | }
59 | return text;
60 | };
61 |
62 | export function useLpc(
63 | frame: Ref,
64 | listeners: MessageListeners
65 | ) {
66 | const calls: Map<
67 | string,
68 | {
69 | resolve: (a: any) => void;
70 | reject: (a: any) => void;
71 | }
72 | > = new Map();
73 |
74 | const post = >(
75 | message: Message,
76 | origin: string = "*"
77 | ) =>
78 | frame.value?.contentWindow?.postMessage(message, { targetOrigin: origin });
79 |
80 | const postRequest = >(
81 | type: T,
82 | params?: MessageParam
83 | ) => {
84 | const id = `markwhen_${getNonce()}`;
85 | return new Promise((resolve, reject) => {
86 | calls.set(id, { resolve, reject });
87 | post({
88 | type,
89 | request: true,
90 | id,
91 | params,
92 | });
93 | });
94 | };
95 |
96 | const postResponse = >(
97 | id: string,
98 | type: T,
99 | params?: MessageParam
100 | ) => post({ type, response: true, id, params });
101 |
102 | window.addEventListener(
103 | "message",
104 | >(
105 | e: MessageEvent>
106 | ) => {
107 | if (!e.data.id || !e.data.id.startsWith("markwhen")) {
108 | return;
109 | }
110 |
111 | const data = e.data;
112 | if (data.response) {
113 | calls.get(data.id)?.resolve(data);
114 | calls.delete(data.id);
115 | } else if (data.request) {
116 | const result = listeners?.[data.type]?.(data.params!);
117 | Promise.resolve(result).then((resp) => {
118 | if (typeof resp !== "undefined") {
119 | postResponse(data.id, data.type, resp);
120 | }
121 | });
122 | } else {
123 | console.error("Not a request or response", data);
124 | }
125 | }
126 | );
127 |
128 | return { postRequest, post };
129 | }
130 |
--------------------------------------------------------------------------------
/src/Views/ViewOrchestrator/useStateSerializer.ts:
--------------------------------------------------------------------------------
1 | import { computed, ref, toRaw, watchEffect } from "vue";
2 | import type { NodeArray, Node } from "@markwhen/parser/lib/Node";
3 | import type { Timeline } from "@markwhen/parser/lib/Types";
4 | import { useAppStore } from "@/App/appStore";
5 | import { useMarkwhenStore } from "@/Markwhen/markwhenStore";
6 | import { usePageStore } from "@/Markwhen/pageStore";
7 | import { useTransformStore } from "@/Markwhen/transformStore";
8 | import { useEditorOrchestratorStore } from "@/EditorOrchestrator/editorOrchestratorStore";
9 | import { useEventDetailStore } from "@/EventDetail/eventDetailStore";
10 | import { useAppSettingsStore } from "@/AppSettings/appSettingsStore";
11 | import { useRoute } from "vue-router";
12 |
13 | export type EventPaths = { [pathType in EventPath["type"]]?: EventPath };
14 |
15 | export interface EventPath {
16 | type: "whole" | "page" | "pageFiltered";
17 | path: number[];
18 | }
19 |
20 | export interface AppState {
21 | isDark?: boolean;
22 | hoveringPath?: EventPaths;
23 | detailPath?: EventPath;
24 | pageIndex: number;
25 | }
26 | export interface MarkwhenState {
27 | rawText?: string;
28 | parsed?: Timeline[];
29 | page?: PageState;
30 | }
31 | export interface PageState {
32 | parsed?: Timeline;
33 | transformed?: Node;
34 | }
35 | export interface State {
36 | app?: AppState;
37 | markwhen?: MarkwhenState;
38 | }
39 |
40 | export const equivalentPaths = (p1?: EventPath, p2?: EventPath): boolean => {
41 | if (!p1 || !p2 || p1.type !== p2.type) {
42 | return false;
43 | }
44 | const path1 = p1.path;
45 | const path2 = p2.path;
46 |
47 | return (
48 | path1.length > 0 &&
49 | path2.length > 0 &&
50 | path1.length === path2.length &&
51 | path1.every((pathValue, index) => path2[index] === pathValue)
52 | );
53 | };
54 |
55 | export const useStateSerializer = () => {
56 | const appSettingsStore = useAppSettingsStore();
57 | const markwhenStore = useMarkwhenStore();
58 | const pageStore = usePageStore();
59 | const transformStore = useTransformStore();
60 | const editorOrchestrator = useEditorOrchestratorStore();
61 | const eventDetailStore = useEventDetailStore();
62 | const route = useRoute()
63 |
64 | const state = computed(() => ({
65 | app: {
66 | isDark: appSettingsStore.inferredDarkMode,
67 | hoveringPath: toRaw(editorOrchestrator.hoveringEventPaths) || undefined,
68 | detailPath: toRaw(eventDetailStore.detailEventPath),
69 | pageIndex: pageStore.pageIndex,
70 | path: route.path
71 | },
72 | markwhen: {
73 | rawText: markwhenStore.rawTimelineString,
74 | parsed: markwhenStore.timelines,
75 | page: {
76 | parsed: pageStore.pageTimeline,
77 | transformed: transformStore.transformedEvents,
78 | },
79 | },
80 | }));
81 |
82 | return state;
83 | };
84 |
--------------------------------------------------------------------------------
/src/Views/ViewOrchestrator/useViewOrchestrator.ts:
--------------------------------------------------------------------------------
1 | import { useEditorOrchestratorStore } from "@/EditorOrchestrator/editorOrchestratorStore";
2 | import { useEventDetailStore } from "@/EventDetail/eventDetailStore";
3 | import { useNewEventStore } from "@/NewEvent/newEventStore";
4 | import {
5 | toDateRange,
6 | toDateRangeIso,
7 | type DateRange,
8 | type DateRangeIso,
9 | } from "@markwhen/parser/lib/Types";
10 | import { ref, watchEffect, type Ref, watch, toRaw, unref } from "vue";
11 | import { useLpc } from "./useLpc";
12 | import { useStateSerializer, type EventPath } from "./useStateSerializer";
13 |
14 | export const useViewOrchestrator = (
15 | frame: Ref
16 | ) => {
17 | const stateSerializer = useStateSerializer();
18 | const eventDetailStore = useEventDetailStore();
19 | const editorOrchestrator = useEditorOrchestratorStore();
20 | const newEventStore = useNewEventStore();
21 |
22 | const trigger = ref(false);
23 | const lpc = useLpc(frame, {
24 | state: () => {
25 | trigger.value = !trigger.value;
26 | },
27 | setDetailPath: (path) => {
28 | if (path) {
29 | eventDetailStore.setDetailEventPath(path);
30 | } else {
31 | eventDetailStore.clearDetailEventPath();
32 | }
33 | },
34 | setHoveringPath: (path) => {
35 | if (path) {
36 | if (
37 | path.path.join(",") !==
38 | editorOrchestrator.hoveringEventPaths?.pageFiltered?.path.join(",")
39 | )
40 | editorOrchestrator.setHoveringEventPath(path);
41 | } else {
42 | editorOrchestrator.clearHoveringEvent();
43 | }
44 | },
45 | showInEditor: (path) => {
46 | editorOrchestrator.showInEditor(path);
47 | },
48 | newEvent({ dateRangeIso, immediate, granularity }) {
49 | if (immediate) {
50 | editorOrchestrator.createEventFromRange(
51 | toDateRange(dateRangeIso),
52 | granularity
53 | ? granularity === "instant"
54 | ? "minute"
55 | : granularity
56 | : "day"
57 | );
58 | } else {
59 | newEventStore.prompt({
60 | range: dateRangeIso,
61 | });
62 | }
63 | },
64 | key(key: string) {},
65 | });
66 |
67 | watchEffect(() => {
68 | // we're watching this so the view can request a state update
69 | trigger.value;
70 | lpc.postRequest("state", toRaw(stateSerializer.value));
71 | });
72 |
73 | const jumpToRange = (range: DateRange | DateRangeIso) =>
74 | lpc.postRequest("jumpToRange", {
75 | dateRangeIso: "fromDateTimeIso" in range ? range : toDateRangeIso(range),
76 | });
77 |
78 | const jumpToPath = (path: EventPath) => {
79 | lpc.postRequest("jumpToPath", {
80 | path,
81 | });
82 | };
83 |
84 | return {
85 | jumpToRange,
86 | jumpToPath,
87 | };
88 | };
89 |
--------------------------------------------------------------------------------
/src/Views/mobileViewStore.ts:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from "@vueuse/core";
2 | import { defineStore } from "pinia";
3 |
4 | export const useMobileViewStore = defineStore("views", () => {
5 | const isMobile = useMediaQuery("(max-width: 1024px)");
6 |
7 | // watch(
8 | // isMobile,
9 | // (mobile) => {
10 | // const editorIndex = viewOptions.value.findIndex(
11 | // (v) => v.name === "Editor" && typeof v.url !== "string"
12 | // );
13 | // if (mobile) {
14 | // if (editorIndex >= 0) {
15 | // viewOptions.value[editorIndex].active = true;
16 | // } else {
17 | // viewOptions.value.push(markRaw(useEditorProvider()));
18 | // }
19 | // } else {
20 | // if (editorIndex >= 0) {
21 | // viewOptions.value.splice(editorIndex, 1);
22 | // }
23 | // }
24 | // },
25 | // { immediate: true }
26 | // );
27 |
28 | return {
29 | isMobile,
30 | };
31 | });
32 |
--------------------------------------------------------------------------------
/src/Views/useViewProviders.ts:
--------------------------------------------------------------------------------
1 | import type { ViewProvider } from "@/viewProvider";
2 |
3 | export const useTimelineExternalProvider = () => ({
4 | id: "markwhen.timeline",
5 | name: "Timeline",
6 | url: "https://timeline.markwhen.com",
7 | iconSvg: ``,
11 | settings: [],
12 | capabilities: { edit: true, hoveringEvent: true },
13 | uses: {
14 | tags: true,
15 | drawerDescription: true,
16 | sort: true,
17 | pages: true,
18 | jump: true,
19 | },
20 | active: true,
21 | description:
22 | "A graph-like representation of events in a cascading timeline. Optionally view as a gantt chart as well.",
23 | screenshots: ["/images/timeline.png"],
24 | });
25 |
26 | export const useViewProviders: () => ViewProvider[] = () => {
27 | return [useTimelineExternalProvider()];
28 | };
29 |
--------------------------------------------------------------------------------
/src/Views/visualizationStore.ts:
--------------------------------------------------------------------------------
1 | import type { ViewProvider } from "@/viewProvider";
2 | import { defineStore } from "pinia";
3 | import { computed, ref, watch, watchEffect } from "vue";
4 | import { useViewProviders } from "./useViewProviders";
5 | import { useViewOrchestrator } from "./ViewOrchestrator/useViewOrchestrator";
6 |
7 | const viewAssociationsKey = "viewAssociations";
8 |
9 | export const useVisualizationStore = defineStore("visualization", () => {
10 | const showingWelcomeViewPicker = ref(true);
11 | const activeFrame = ref();
12 | const selectedViewIndex = ref(-1);
13 |
14 | const getViewOptions = () => {
15 | const defaultOptions = useViewProviders();
16 | return defaultOptions;
17 | };
18 |
19 | const viewOptions = ref(getViewOptions());
20 | const activeViews = computed(() =>
21 | viewOptions.value.filter((vo) => vo.active)
22 | );
23 |
24 | const currentView = computed(
25 | () => activeViews.value[selectedViewIndex.value]
26 | );
27 | const setActiveFrame = (frame?: HTMLIFrameElement) => {
28 | activeFrame.value = frame;
29 | };
30 |
31 | // watch(
32 | // viewOptions,
33 | // (vo) => {
34 | // if (typeof localStorage !== "undefined") {
35 | // for (const vp of useViewProviders()) {
36 |
37 | // }
38 | // localStorage.setItem(
39 | // "viewOptions",
40 | // JSON.stringify(vo.filter((v) => typeof v.url === "string"))
41 | // );
42 | // }
43 | // },
44 | // { deep: true }
45 | // );
46 |
47 | const getExistingViewAssociations = () => {
48 | if (!localStorage || typeof localStorage === "undefined") {
49 | return;
50 | }
51 | return JSON.parse(
52 | localStorage.getItem(viewAssociationsKey) || "{}"
53 | ) as Record;
54 | };
55 |
56 | watchEffect(() => {
57 | if (selectedViewIndex.value >= activeViews.value.length) {
58 | selectedViewIndex.value = 0;
59 | } else if (selectedViewIndex.value < 0) {
60 | // Set default view
61 | // Don't set view association here because we're just setting the default
62 | selectedViewIndex.value = 0;
63 | }
64 | });
65 |
66 | const showWelcomeViewPicker = () => {
67 | showingWelcomeViewPicker.value = true;
68 | };
69 |
70 | const { jumpToRange, jumpToPath } = useViewOrchestrator(activeFrame);
71 |
72 | return {
73 | selectedViewIndex,
74 | viewOptions,
75 | activeViews,
76 | activeFrame,
77 | currentView,
78 | showingWelcomeViewPicker,
79 |
80 | showWelcomeViewPicker,
81 | setActiveFrame,
82 | jumpToRange,
83 | jumpToPath,
84 | };
85 | });
86 |
--------------------------------------------------------------------------------
/src/WelcomeViewPicker/CustomViewOption.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/WelcomeViewPicker/ViewPicker.vue:
--------------------------------------------------------------------------------
1 |
38 |
39 |
40 |
41 | Views
42 |
43 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/src/WelcomeViewPicker/VisualizationOption.vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
33 |
34 |
35 | {{ vp.name }}
36 |
55 |
56 |
57 |
![]()
58 |
59 |
63 | {{ vp.description }}
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/WelcomeViewPicker/WelcomeViewPicker.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
38 |
--------------------------------------------------------------------------------
/src/exampleTimeline.ts:
--------------------------------------------------------------------------------
1 |
2 | export const exampleTimeline = `
3 | title: Welcome to Markwhen 👋
4 | description: Markwhen is a text to timeline tool.
5 |
6 | // Feel free to delete everything to start making your own timeline.
7 |
8 | #Project1: #d336b1
9 |
10 | section Welcome #welcome
11 |
12 | now: View an example timeline at [markwhen.com/example](https://markwhen.com/example) #welcome
13 |
14 | now: View the documentation [here](https://docs.markwhen.com) or join the [discord](https://discord.gg/kQbqP4uz)
15 | #welcome
16 | endSection
17 |
18 | section All Projects
19 | group Project 1 #Project1
20 | // Supports ISO8601
21 | 2023-01/2023-03: Sub task #John
22 | 2023-03/2023-06: Sub task 2 #Michelle
23 | More info about sub task 2
24 |
25 | - [ ] We need to get this done
26 | - [x] And this
27 | - [ ] This one is extra
28 |
29 | 2023-07: Yearly planning
30 | endGroup
31 | group Project 2 #Project2
32 | 2023-04/4 months: Larger sub task #Danielle
33 |
34 | // Supports American date formats
35 | 03/2023 - 1 year: Longer ongoing task #Michelle
36 |
37 | - [x] Sub task 1
38 | - [x] Sub task 2
39 | - [ ] Sub task 3
40 | - [ ] Sub task 4
41 | - [ ] so many checkboxes omg
42 |
43 | 10/2023 - 2 months: Holiday season
44 | endGroup
45 |
46 | group Project 3
47 | 01/2024: Project kickoff
48 | 02/2024-04/2024: Other stuff
49 | endGroup
50 | endSection
51 |
52 | 2023-01-03 every other week for 1 year: Biweekly meeting
53 | `;
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html, body {
6 | height: 100%;
7 | font-family: Avenir, Helvetica, Arial, sans-serif;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | mix-blend-mode: multiply;
11 | }
12 |
13 | .noScrollBar::-webkit-scrollbar {
14 | display: none;
15 | }
16 |
17 | .noScrollBar {
18 | scrollbar-width: none;
19 | }
20 |
21 | button {
22 | -webkit-tap-highlight-color: transparent;
23 | }
24 |
25 | .safeBottomPadding {
26 | padding-bottom: env(safe-area-inset-bottom);
27 | }
--------------------------------------------------------------------------------
/src/injectionKeys.ts:
--------------------------------------------------------------------------------
1 | import type { InjectionKey } from "vue";
2 |
3 | export const isEditable = Symbol() as InjectionKey
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from "vue";
2 | import { createPinia } from "pinia";
3 | import "./index.css";
4 | import router from "./router";
5 | import App from "@/App/App.vue";
6 | import { createHead, useHead } from "@vueuse/head";
7 |
8 | const app = createApp(App);
9 | export const pinia = createPinia();
10 |
11 | app.use(pinia);
12 | app.use(router);
13 | app.use(createHead());
14 |
15 | app.mount("#app");
16 |
--------------------------------------------------------------------------------
/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from "vue-router";
2 |
3 | const router = createRouter({
4 | history: createWebHistory(import.meta.env.BASE_URL),
5 | routes: [],
6 | });
7 |
8 | export default router;
9 |
--------------------------------------------------------------------------------
/src/router/useQuerySetter.ts:
--------------------------------------------------------------------------------
1 | import { computed, watch } from "vue";
2 | import { useRoute, useRouter } from "vue-router";
3 | import { usePageStore } from "../Markwhen/pageStore";
4 | import { useTransformStore } from "@/Markwhen/transformStore";
5 | import { useVisualizationStore } from "@/Views/visualizationStore";
6 |
7 | export const useQuerySetter = () => {
8 | const route = useRoute();
9 | const router = useRouter();
10 | const pageStore = usePageStore();
11 | const visualizationStore = useVisualizationStore();
12 | const transformStore = useTransformStore();
13 |
14 | const currentViewName = computed(() => {
15 | return visualizationStore.currentView.name;
16 | });
17 |
18 | const queryMap = computed(() => ({
19 | page: `${pageStore.pageIndex + 1}`,
20 | view: currentViewName.value,
21 | sort: transformStore.sort,
22 | filter: transformStore.filter.join(","),
23 | }));
24 |
25 | const computedQuery = computed(() =>
26 | new URLSearchParams(queryMap.value).toString()
27 | );
28 |
29 | const currentQueryMap = computed(() =>
30 | new URLSearchParams(route.query as Record).toString()
31 | );
32 |
33 | let setterTimeout: any;
34 | watch([computedQuery, currentQueryMap], ([computedQ, receivedQ]) => {
35 | clearTimeout(setterTimeout as number);
36 | setterTimeout = setTimeout(() => {
37 | if (computedQ !== receivedQ) {
38 | router.replace(route.path + route.hash + "?" + computedQ);
39 | }
40 | }, 200);
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/src/router/useRouteWatcherStore.ts:
--------------------------------------------------------------------------------
1 | import { useMarkwhenStore } from "@/Markwhen/markwhenStore";
2 | import { useTransformStore, type Sort } from "@/Markwhen/transformStore";
3 | import { usePageStore } from "@/Markwhen/pageStore";
4 | import { defineStore } from "pinia";
5 | import { computed, ref, watch, watchEffect } from "vue";
6 | import { useRoute, useRouter } from "vue-router";
7 | import { useVisualizationStore } from "@/Views/visualizationStore";
8 |
9 | export type RouteWatchState = "idle" | "error" | "loading";
10 |
11 | export const useRouteWatcherStore = defineStore("routeWatcher", () => {
12 | const route = useRoute();
13 | const markwhenStore = useMarkwhenStore();
14 | const watchState = ref("loading");
15 | const pageStore = usePageStore();
16 | const visualizationStore = useVisualizationStore();
17 | const transformStore = useTransformStore();
18 |
19 | const pageTitles = computed(() => {
20 | return markwhenStore.timelines.map((t) => t.header.title);
21 | });
22 |
23 | const pageIndexFromQuery = (index: string) => {
24 | if (typeof index === "string") {
25 | const parsed = parseInt(index);
26 | if (isNaN(parsed)) {
27 | for (let i = 0; i < pageTitles.value.length; i++) {
28 | if (
29 | pageTitles.value[i]?.toLowerCase() ===
30 | decodeURIComponent(index).toLowerCase()
31 | ) {
32 | return i;
33 | }
34 | }
35 | } else if (parsed >= 1 && parsed < pageTitles.value.length + 1) {
36 | return parsed - 1;
37 | }
38 | } else if (
39 | typeof index === "number" &&
40 | index >= 1 &&
41 | index < pageTitles.value.length + 1
42 | ) {
43 | return index - 1;
44 | }
45 | };
46 |
47 | const setFromQuery = (query: Record) => {
48 | if (query.page) {
49 | const index = pageIndexFromQuery(route.query.page as string);
50 | if (typeof index === "number") {
51 | pageStore.setPageIndex(index);
52 | }
53 | }
54 | if (query.view) {
55 | for (let i = 0; i < visualizationStore.activeViews.length; i++) {
56 | if (
57 | (query.view as string).toLowerCase() ===
58 | visualizationStore.activeViews[i].name.toLowerCase()
59 | ) {
60 | visualizationStore.selectedViewIndex = i;
61 | }
62 | }
63 | }
64 | const sort = (query.sort as string)?.toLowerCase();
65 | for (const s of ["none", "up", "down"] as Sort[]) {
66 | if (sort === s) {
67 | transformStore.setSort(sort);
68 | }
69 | }
70 | const filters = (query.filter as string)?.split(",") || [];
71 | for (const filter of filters) {
72 | transformStore.addFilterTag(filter);
73 | }
74 | };
75 |
76 | watch(
77 | () => route.query,
78 | (query) => setFromQuery(query as Record),
79 | { immediate: true }
80 | );
81 |
82 | return {
83 | watchState,
84 | };
85 | });
86 |
--------------------------------------------------------------------------------
/src/utilities/Spinner.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/utilities/colorUtils.ts:
--------------------------------------------------------------------------------
1 | // RGB, so we can use rgba(... ) with a different alpha where we need it
2 | export const COLORS = [
3 | "22, 163, 76",
4 | "2, 132, 199",
5 | "212, 50, 56",
6 | "242, 202, 45",
7 | "80, 73, 229",
8 | "145, 57, 234",
9 | "214, 45, 123",
10 | "234, 88, 11",
11 | "168, 162, 157",
12 | "255, 255, 255",
13 | "0, 0, 0",
14 | ];
15 | export const HUMAN_COLORS = [
16 | "green",
17 | "blue",
18 | "red",
19 | "yellow",
20 | "indigo",
21 | "purple",
22 | "pink",
23 | "orange",
24 | "gray",
25 | "white",
26 | "black",
27 | ];
28 |
29 | export function hexToRgb(hex: string): string | undefined {
30 | hex = hex.replace("#", "");
31 | const isShortHex = hex.length === 3;
32 | var r = parseInt(
33 | isShortHex ? hex.slice(0, 1).repeat(2) : hex.slice(0, 2),
34 | 16
35 | );
36 | if (isNaN(r)) {
37 | return undefined;
38 | }
39 | var g = parseInt(
40 | isShortHex ? hex.slice(1, 2).repeat(2) : hex.slice(2, 4),
41 | 16
42 | );
43 | if (isNaN(g)) {
44 | return undefined;
45 | }
46 | var b = parseInt(
47 | isShortHex ? hex.slice(2, 3).repeat(2) : hex.slice(4, 6),
48 | 16
49 | );
50 | if (isNaN(b)) {
51 | return undefined;
52 | }
53 | return `${r}, ${g}, ${b}`;
54 | }
55 |
56 | function componentToHex(c: number) {
57 | var hex = c.toString(16);
58 | return hex.length == 1 ? "0" + hex : hex;
59 | }
60 |
61 | function rgbNumberToHex(...rgb: number[]) {
62 | return (
63 | "#" +
64 | componentToHex(rgb[0]) +
65 | componentToHex(rgb[1]) +
66 | componentToHex(rgb[2])
67 | );
68 | }
69 |
70 | export function rgbStringToHex(s: string) {
71 | return rgbNumberToHex(...s.split(",").map((n) => parseInt(n.trim())));
72 | }
73 |
--------------------------------------------------------------------------------
/src/utilities/composables/useIsActive.ts:
--------------------------------------------------------------------------------
1 | import { onActivated, onDeactivated, ref } from "vue"
2 |
3 | export const useIsActive = () => {
4 | const isActive = ref(false)
5 |
6 | onDeactivated(() => {
7 | isActive.value = false
8 | })
9 |
10 | onActivated(() => {
11 | isActive.value = true
12 | })
13 |
14 | return { isActive }
15 | }
16 |
--------------------------------------------------------------------------------
/src/utilities/ranges.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | SomeNode,
3 | GroupRange,
4 | NodeArray,
5 | } from "@markwhen/parser/lib/Node";
6 | import { Event, toDateRange, type DateRange } from "@markwhen/parser/lib/Types";
7 | import { expand } from "@markwhen/parser/lib/utilities/recurrence";
8 | import LRUCache from "lru-cache";
9 |
10 | const cache = new LRUCache({ max: 1000 });
11 | const cacheAndReturn = (cacheKey: string, dateRange: DateRange) => {
12 | cache.set(cacheKey, dateRange);
13 | return dateRange;
14 | };
15 | export const eventRange = (e: Event, recurrenceLimit: number) => {
16 | const cacheKey =
17 | JSON.stringify(e.dateRangeIso) + JSON.stringify(e.recurrence);
18 | const cached = cache.get(cacheKey);
19 | if (cached) {
20 | return cached;
21 | }
22 |
23 | if (e.recurrence) {
24 | const expanded = expand(
25 | toDateRange(e.dateRangeIso),
26 | e.recurrence,
27 | recurrenceLimit
28 | );
29 | return cacheAndReturn(cacheKey, {
30 | fromDateTime: expanded[0].fromDateTime,
31 | toDateTime: expanded[expanded.length - 1].toDateTime,
32 | });
33 | } else {
34 | return cacheAndReturn(cacheKey, toDateRange(e.dateRangeIso));
35 | }
36 | };
37 |
38 | export type RecurrenceRangeOptions = {
39 | recurrenceLimit: number;
40 | };
41 |
42 | export const ranges = (root: SomeNode, recurrenceLimit: number): GroupRange => {
43 | if (!root || !root.value) {
44 | return undefined;
45 | }
46 |
47 | if (!Array.isArray(root.value)) {
48 | const eRange = eventRange(root.value, recurrenceLimit);
49 | return {
50 | ...eRange,
51 | maxFrom: eRange.fromDateTime,
52 | };
53 | }
54 |
55 | const childRanges = (root.value as NodeArray).reduce((prev, curr) => {
56 | const currRange: GroupRange = ranges(curr, recurrenceLimit);
57 | if (!prev) {
58 | return currRange;
59 | }
60 | if (!currRange) {
61 | return currRange;
62 | }
63 |
64 | const min =
65 | +currRange.fromDateTime < +prev.fromDateTime
66 | ? currRange.fromDateTime
67 | : prev.fromDateTime;
68 | const max =
69 | +currRange.toDateTime > +prev.toDateTime
70 | ? currRange.toDateTime
71 | : prev.toDateTime;
72 | const maxFrom =
73 | +currRange.maxFrom > +prev.maxFrom ? currRange.maxFrom : prev.maxFrom;
74 |
75 | const range = {
76 | fromDateTime: min,
77 | toDateTime: max,
78 | maxFrom,
79 | };
80 | return range;
81 | }, undefined as GroupRange);
82 |
83 | return childRanges;
84 | };
85 |
--------------------------------------------------------------------------------
/src/viewProvider.ts:
--------------------------------------------------------------------------------
1 | export interface ViewCapabilities {
2 | edit?: boolean
3 | hoveringEvent?: boolean
4 | mobile?: boolean
5 | jumpToEvent?: boolean
6 | }
7 |
8 | export interface ViewUses {
9 | tags?: boolean
10 | drawerDescription?: boolean
11 | sort?: boolean
12 | pages?: boolean
13 | jump?: boolean
14 | }
15 |
16 | export interface ViewSetting {
17 | name: string
18 | iconSvg: string,
19 | }
20 |
21 | export interface ViewProvider {
22 | id: string,
23 | url: string | any,
24 | name: string,
25 | iconSvg?: string,
26 | settings?: (() => ViewSetting | any)[]
27 | capabilities?: ViewCapabilities
28 | uses?: ViewUses
29 | active?: boolean
30 | description: string
31 | screenshots: string[]
32 | }
--------------------------------------------------------------------------------
/src/workers/parse.worker.ts:
--------------------------------------------------------------------------------
1 | import { parse } from "@markwhen/parser";
2 | import { Cache } from "@markwhen/parser/lib/Cache";
3 |
4 | addEventListener("message", (message) => {
5 | postMessage(parse(message.data.rawTimelineString));
6 | });
7 |
8 | export {};
9 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {
6 | transitionProperty: {
7 | width: "width",
8 | height: "height",
9 | },
10 | },
11 | },
12 | safelist: ["underline"],
13 | plugins: [
14 | function ({ addBase, theme }) {
15 | function extractColorVars(colorObj, colorGroup = "") {
16 | return Object.keys(colorObj).reduce((vars, colorKey) => {
17 | const value = colorObj[colorKey];
18 |
19 | const newVars =
20 | typeof value === "string"
21 | ? { [`--color${colorGroup}-${colorKey}`]: value }
22 | : extractColorVars(value, `-${colorKey}`);
23 |
24 | return { ...vars, ...newVars };
25 | }, {});
26 | }
27 |
28 | addBase({
29 | ":root": extractColorVars(theme("colors")),
30 | });
31 | },
32 | require("@tailwindcss/container-queries"),
33 | ],
34 | darkMode: "class",
35 | };
36 |
--------------------------------------------------------------------------------
/tests/pages.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, Page } from "@playwright/test";
2 |
3 | const url = "http://localhost:8788/";
4 |
5 | const expectPageQuery = async (page: Page, pageQuery: string | number) => {
6 | await page.waitForURL(/page/);
7 | const pageUrl = new URL(page.url());
8 | expect(pageUrl.searchParams.get("page")).toBe(`${pageQuery}`);
9 | };
10 |
11 | const goToPage = async (page: Page, pageWithTitle: string) => {
12 | const pageButton = await page.getByRole("button", {
13 | name: pageWithTitle,
14 | exact: true,
15 | });
16 | await pageButton.click();
17 | await expect(page.getByText(`title: ${pageWithTitle}`)).toBeVisible();
18 | };
19 |
20 | const movePages = async (page: Page, fromTitle: string, toTitle: string) => {
21 | const page0Button = async () =>
22 | page.getByRole("button", { name: fromTitle, exact: true });
23 | const page1Button = async () =>
24 | page.getByRole("button", { name: toTitle, exact: true });
25 |
26 | const page0Left = async () => (await (await page0Button()).boundingBox())?.x;
27 | const page1Left = async () => (await (await page1Button()).boundingBox())?.x;
28 |
29 | const page1LeftBefore = await page1Left();
30 | const page0LeftBefore = await page0Left();
31 | const movingLeft = page0LeftBefore! > page1LeftBefore!;
32 |
33 | await (await page1Button()).dragTo(await page0Button());
34 |
35 | const page1LeftAfter = await page1Left();
36 | const page0LeftAfter = await page0Left();
37 | if (movingLeft) {
38 | expect(page1LeftAfter).toBeGreaterThan(page0LeftAfter!);
39 | } else {
40 | expect(page0LeftAfter).toBeGreaterThan(page1LeftAfter!);
41 | }
42 | };
43 |
44 | test("go to page", async ({ page }) => {
45 | await page.goto(url);
46 | await page.getByRole("button", { name: "Header" }).click();
47 | const secondPageComment = await page
48 | .getByText("// The header is everything before any events are defined")
49 | .isVisible();
50 | expect(secondPageComment).toBeTruthy();
51 | });
52 |
53 | test("move pages left", async ({ page }) => {
54 | await page.goto(url);
55 | await movePages(page, "Header", "Welcome to Markwhen 👋");
56 | await expectPageQuery(page, 3);
57 | });
58 |
59 | test("move pages right", async ({ page }) => {
60 | await page.goto(url);
61 | await movePages(page, "Welcome to Markwhen 👋", "Header");
62 | });
63 |
64 | test("Move current page, is still selected", async ({ page }) => {
65 | await page.goto(url);
66 | await goToPage(page, "Events");
67 | await movePages(page, "Events", "Welcome to Markwhen 👋");
68 | await expect(page.getByText(`title: Events`)).toBeVisible();
69 | await expectPageQuery(page, 3);
70 | });
71 |
72 | test("New page, is immediately selected", async ({ page }) => {
73 | await page.goto(url);
74 | const pageButtons = await page.locator("#pageButtons");
75 | await pageButtons.evaluate((buttons) => {
76 | buttons.scrollLeft = buttons.scrollWidth;
77 | });
78 | const newPageButton = await page.getByRole("button", {
79 | name: "Add new page",
80 | });
81 | await newPageButton.scrollIntoViewIfNeeded();
82 | await newPageButton.click();
83 | await expect(page.getByText(`title: Page 10`)).toBeVisible();
84 | await expectPageQuery(page, 10);
85 | });
86 |
87 | test("Route to page by page index, is selected", async ({ page }) => {
88 | await page.goto(url + "?page=2");
89 | await expect(page.getByText(`title: Header`)).toBeVisible();
90 | });
91 |
92 | test("Route to page by page title, is selected", async ({ page }) => {
93 | await page.goto(url + "?page=" + encodeURIComponent("Groups and Sections"));
94 | await expect(page.getByText(`title: Groups and Sections`)).toBeVisible();
95 | });
96 |
97 | test("Route to shared page by page title, is selected", async ({ page }) => {
98 | await page.goto(
99 | url + "example?page=" + encodeURIComponent("event descriptions"),
100 | { waitUntil: "networkidle" }
101 | );
102 | await expect(page.getByText(`title: Event Descriptions`)).toBeVisible();
103 | });
104 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.web.json",
3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
4 | "exclude": ["src/**/__tests__/*"],
5 | "compilerOptions": {
6 | "composite": true,
7 | "baseUrl": ".",
8 | "paths": {
9 | "@/*": ["./src/*"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/tsconfig.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.node.json",
3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
4 | "compilerOptions": {
5 | "composite": true,
6 | "types": ["node"]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020"
4 | },
5 | "files": [],
6 | "references": [
7 | {
8 | "path": "./tsconfig.config.json"
9 | },
10 | {
11 | "path": "./tsconfig.app.json"
12 | },
13 | {
14 | "path": "./tsconfig.vitest.json"
15 | }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "exclude": [],
4 | "compilerOptions": {
5 | "composite": true,
6 | "lib": [],
7 | "types": ["node", "jsdom"]
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from "node:url";
2 |
3 | import { defineConfig } from "vite";
4 | import vue from "@vitejs/plugin-vue";
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [
9 | vue({
10 | template: {
11 | compilerOptions: {
12 | isCustomElement: (tag) => tag.startsWith("svg:style"),
13 | },
14 | },
15 | }),
16 | ],
17 | resolve: {
18 | alias: {
19 | "@": fileURLToPath(new URL("./src", import.meta.url)),
20 | },
21 | },
22 | server: {
23 | host: "0.0.0.0",
24 | },
25 | });
26 |
--------------------------------------------------------------------------------