├── .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 | 2 | 6 | 10 | 14 | 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 | 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 | 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 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Dialogs/VisualizationOptionRow.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 71 | 72 | 96 | -------------------------------------------------------------------------------- /src/Drawer/Drawer.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 50 | 51 | 82 | -------------------------------------------------------------------------------- /src/Drawer/HoverHint.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 57 | 58 | 85 | -------------------------------------------------------------------------------- /src/Drawer/HoverMenu.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 57 | 58 | 85 | -------------------------------------------------------------------------------- /src/Drawer/PageButtons/PageButton.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/Drawer/PageButtons/PageButtons.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 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 | 14 | 15 | 31 | -------------------------------------------------------------------------------- /src/Drawer/Spacer.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Drawer/VerticalSpacer.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Sort.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Tags/Filter.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Tags/FilterDialog.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Tags/Tag.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | 50 | 55 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Tags/TagChip.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | 14 | 32 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Tags/TagRow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 45 | 46 | 51 | -------------------------------------------------------------------------------- /src/Drawer/ViewSettings/Tags/Tags.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 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 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/Drawer/ViewSwitcherButton.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/Drawer/VisualizationSwitcher/VisualizationIndicator.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/Drawer/VisualizationSwitcher/VisualizationSwitcherMenu.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 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 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/EventDetail/EventDetail.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/EventDetail/EventDetailMarkdown.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/EventDetail/EventDetailPaneTop.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /src/EventDetail/EventDetailPanel.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /src/EventDetail/EventDetailTags.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/EventDetail/EventDetailWhen.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/EventDetail/EventGroupDetail.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Jump/DateRangeDisplay.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/Jump/JumpButton.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/Jump/JumpResultList.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/Jump/JumpResultListItem.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Jump/JumpResultListItemMeta.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Jump/SearchResultItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 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 | 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 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/MarkwhenPreview/PagePreviewRow.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 72 | 73 | 82 | -------------------------------------------------------------------------------- /src/MarkwhenPreview/PreviewTableHeader.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/NewEvent/NewEvent.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/NewEvent/NewEventDialog.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 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 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/Panels/Panels.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 76 | 77 | 85 | -------------------------------------------------------------------------------- /src/Panels/ResizeBar.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Panels/Visualizations.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 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 |