├── .npmrc ├── .eslintignore ├── versions.json ├── obsidian_datepicker_screenshot.png ├── .gitignore ├── manifest.json ├── tsconfig.json ├── styles.css ├── version-bump.mjs ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── package.json ├── README.md ├── LICENSE ├── esbuild.config.mjs └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "1.1.9" 3 | } 4 | -------------------------------------------------------------------------------- /obsidian_datepicker_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joycode-hub/datepicker-plugin/HEAD/obsidian_datepicker_screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "datepicker", 3 | "name": "Datepicker", 4 | "version": "0.3.25", 5 | "minAppVersion": "1.1.9", 6 | "description": "Use a date picker to modify and insert date/time anywhere in your markdown notes.", 7 | "author": "Mostafa Mohamed", 8 | "fundingUrl": { 9 | "Buy Me a Coffee": "https://buymeacoffee.com/joycode", 10 | "Support me on Ko-fi": "https://ko-fi.com/joycode", 11 | "PayPal": "paypal.me/mostafamohamed207" 12 | }, 13 | "isDesktopOnly": false 14 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .datepicker-container{ 2 | /* required for the Datepicker plugin to function correctly */ 3 | position: fixed; 4 | min-width: 0px; 5 | min-height: 0px; 6 | display: flex; 7 | 8 | padding: 5px; 9 | background-color: var(--interactive-hover); 10 | border-radius: 10px; 11 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 12 | } 13 | .datepicker-button{ 14 | --icon-size: 14px; 15 | margin-left: 4px; 16 | margin-right: 6px; 17 | } 18 | .datepicker-container-button{ 19 | --icon-size: 12px; 20 | margin-left: 4px; 21 | } -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: joycode 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: joycode 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datepicker", 3 | "version": "0.3.25", 4 | "description": "Use a date picker to modify and insert date/time anywhere in your markdown notes.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@codemirror/language": "^6.11.2", 16 | "@types/node": "^16.11.6", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.17.3", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Use a datepicker to edit and insert date and time anywhere in your markdown notes 2 | 3 | - Can automatically show picker whenever a date is selected. 4 | - Can add a calendar button to date and time values in your notes that can be selected to open a datepicker. 5 | - Can automatically select date/time text for quick formatting. 6 | - Adds command to edit date and time. 7 | - Adds commands to insert a new date or date and time. 8 | - Adds commands to select previous/next date and time. 9 | 10 | ![datepicker-screenshot](./obsidian_datepicker_screenshot.png) 11 | 12 | For issues or bugs with the plugin, feature requests or suggestions, please open an issue. 13 | 14 | Buy Me A Coffee 15 | 16 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/V7V410Q9KD) 17 | 18 | Your support is welcome and appreciated! 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 joycode-hub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, Editor, Plugin, PluginSettingTab, Setting, moment, Platform, Notice, setIcon, MomentFormatComponent } from 'obsidian'; 2 | import { 3 | ViewUpdate, 4 | PluginValue, 5 | EditorView, 6 | ViewPlugin, 7 | WidgetType, 8 | Decoration, 9 | DecorationSet 10 | } from "@codemirror/view"; 11 | import { syntaxTree } from "@codemirror/language"; 12 | import { platform } from 'os'; 13 | 14 | interface DateMatch { 15 | from: number; 16 | to: number; 17 | value: string; 18 | format: DateFormat; 19 | 20 | } 21 | interface DateFormat { 22 | regex: RegExp; 23 | formatToUser: string; 24 | formatToPicker: string; 25 | type: 'DATE' | 'DATETIME' | 'TIME'; 26 | } 27 | interface VisibleText { 28 | from: number; 29 | to: number; 30 | text: string; 31 | } 32 | 33 | class DateButtonWidget extends WidgetType { 34 | toDOM(): HTMLElement { 35 | const button = document.createElement('span'); 36 | button.className = 'datepicker-button'; 37 | setIcon(button, 'calendar'); 38 | return button; 39 | } 40 | ignoreEvent() { return false }; 41 | eq(): boolean { 42 | return true; 43 | } 44 | } 45 | class TimeButtonWidget extends WidgetType { 46 | toDOM(): HTMLElement { 47 | const button = document.createElement('span'); 48 | button.className = 'datepicker-button'; 49 | setIcon(button, 'clock'); 50 | return button; 51 | } 52 | ignoreEvent() { return false }; 53 | eq(): boolean { 54 | return true; 55 | } 56 | } 57 | 58 | function pickerButtons(dateMatches: DateMatch[]) { 59 | const buttons = []; 60 | if (!DatepickerPlugin.settings.showDateButtons && !DatepickerPlugin.settings.showTimeButtons) return Decoration.set([]); 61 | 62 | for (const dateMatch of dateMatches) { 63 | if (DatepickerPlugin.settings.showDateButtons && (dateMatch.format.type === 'DATE' || dateMatch.format.type === 'DATETIME')) { 64 | let buttonDeco = Decoration.widget({ 65 | widget: new DateButtonWidget(), 66 | side: -1 67 | }) 68 | buttons.push(buttonDeco.range(dateMatch.from)); 69 | } else 70 | if (DatepickerPlugin.settings.showTimeButtons && dateMatch.format.type === 'TIME') { 71 | let buttonDeco = Decoration.widget({ 72 | widget: new TimeButtonWidget(), 73 | side: -1 74 | }) 75 | buttons.push(buttonDeco.range(dateMatch.from)); 76 | } 77 | } 78 | return Decoration.set(buttons, true); 79 | } 80 | 81 | class DatepickerCMPlugin implements PluginValue { 82 | 83 | private view: EditorView; 84 | 85 | datepickerPositionHandler() { 86 | if (this.datepicker === undefined) return; 87 | this.view.requestMeasure({ 88 | read: state => { 89 | let pos = state.coordsAtPos(this.datepicker?.cursorPosition!); 90 | return pos; 91 | }, 92 | write: pos => { 93 | if (pos) { 94 | this.datepicker!.updatePosition({ 95 | top: pos!.top, 96 | left: pos!.left, 97 | bottom: pos!.bottom 98 | }); 99 | } 100 | } 101 | }); 102 | } 103 | datepickerScrollHandler = () => { 104 | this.datepickerPositionHandler(); 105 | }; 106 | 107 | private get formats(): DateFormat[] { 108 | return this.getFormats(); 109 | } 110 | 111 | 112 | private getFormats(): DateFormat[] { 113 | const formatPatterns = [ 114 | 'YYYY-MM-DD', 115 | 'DD.MM.YYYY', 116 | 'MM-DD-YYYY', 117 | 'DD-MM-YYYY', 118 | 'MM.DD.YYYY', 119 | 'YYYY.MM.DD', 120 | 'YYYY/MM/DD', 121 | 'DD/MM/YYYY', 122 | 'MM/DD/YYYY' 123 | ]; 124 | 125 | let formats: DateFormat[] = []; 126 | 127 | // Add user's preferred format first 128 | const userFormat = DatepickerPlugin.settings.dateFormat; 129 | const userSeparator = userFormat.includes('.') ? '\\.' : (userFormat.includes('/') ? '\\/' : '-'); 130 | 131 | // Add datetime formats for all patterns 132 | formatPatterns.forEach(format => { 133 | const separator = format.includes('.') ? '\\.' : (format.includes('/') ? '\\/' : '-'); 134 | formats.push( 135 | { 136 | regex: new RegExp(`\\d{1,4}${separator}\\d{1,2}${separator}\\d{1,4} \\d{1,2}:\\d{1,2}( )?([apm]{2})`, 'ig'), 137 | formatToUser: `${format} hh:mm A`, 138 | formatToPicker: "YYYY-MM-DDTHH:mm", 139 | type: 'DATETIME' 140 | }, 141 | { 142 | regex: new RegExp(`\\d{1,4}${separator}\\d{1,2}${separator}\\d{1,4} \\d{1,2}:\\d{1,2}`, 'g'), 143 | formatToUser: `${format} HH:mm`, 144 | formatToPicker: "YYYY-MM-DDTHH:mm", 145 | type: 'DATETIME' 146 | }, 147 | { 148 | regex: new RegExp(`\\d{1,4}${separator}\\d{1,2}${separator}\\d{1,4}`, 'g'), 149 | formatToUser: format, 150 | formatToPicker: "YYYY-MM-DD", 151 | type: 'DATE' 152 | } 153 | ); 154 | }); 155 | 156 | // Add time formats 157 | formats.push( 158 | { 159 | regex: /\d{1,2}:\d{1,2}( )?([apm]{2})/ig, 160 | formatToUser: "hh:mm A", 161 | formatToPicker: "HH:mm", 162 | type: 'TIME' 163 | }, 164 | { 165 | regex: /\d{1,2}:\d{1,2}/g, 166 | formatToUser: "HH:mm", 167 | formatToPicker: "HH:mm", 168 | type: 'TIME' 169 | } 170 | ); 171 | 172 | return formats; 173 | } 174 | 175 | private isInCodeblock(view: EditorView, position: number): boolean { 176 | // If the setting is disabled, never consider any position to be in a codeblock 177 | if (!DatepickerPlugin.settings.ignoreCodeblocks) { 178 | return false; 179 | } 180 | 181 | const tree = syntaxTree(view.state); 182 | let node: any = tree.resolve(position); 183 | 184 | // Traverse up the syntax tree to find if we're inside a codeblock 185 | while (node) { 186 | // Check for fenced code blocks (```) or inline code (`) 187 | if (node.name === 'FencedCode' || 188 | node.name === 'CodeBlock' || 189 | node.name === 'InlineCode' || 190 | node.name === 'CodeText' || 191 | // Additional checks for different markdown parsers 192 | node.name.includes('Code') || 193 | node.name.includes('code')) { 194 | return true; 195 | } 196 | node = node.parent; 197 | } 198 | return false; 199 | } 200 | 201 | 202 | private getVisibleDates(view: EditorView): DateMatch[] { 203 | let visibleText: VisibleText[] = []; 204 | visibleText = view.visibleRanges.map(r => { return { from: r.from, to: r.to, text: view.state.doc.sliceString(r.from, r.to) } }); 205 | let matchingDate: RegExpExecArray | null; 206 | const dateMatches: DateMatch[] = []; 207 | 208 | for (const vt of visibleText) { 209 | if (vt.from >= view.viewport.from && vt.to <= view.viewport.to) 210 | for (const format of this.formats) { 211 | while ((matchingDate = format.regex.exec(vt.text ?? "")) !== null) { 212 | const matchingDateStart = matchingDate?.index! + vt.from; 213 | const matchingDateEnd = matchingDate?.index! + matchingDate![0].length + vt.from; 214 | 215 | // Skip dates that are inside codeblocks 216 | if (this.isInCodeblock(view, matchingDateStart)) continue; 217 | 218 | /* 219 | avoid pushing values that are part of another match to avoid recognizing values that are part of other values 220 | as their own date/time, eg: the time portion of a date/time is not seperate from the date portion, two dates on 221 | the same line with no space or seperation should not be recognized as several dates (this was a bug) 222 | */ 223 | if (dateMatches.some((m) => 224 | matchingDateStart >= m.from && ((matchingDateEnd <= m.to) || (matchingDateStart <= m.to)))) continue; 225 | dateMatches.push({ from: matchingDate.index + vt.from, to: matchingDate.index + matchingDate[0].length + vt.from, value: matchingDate[0], format: format }); 226 | } 227 | } 228 | } 229 | return dateMatches; 230 | } 231 | 232 | private getAllDates(view: EditorView): DateMatch[] { 233 | let matchingDate: RegExpExecArray | null; 234 | const dateMatches: DateMatch[] = []; 235 | const noteText = view.state.doc.toString(); 236 | this.formats.forEach((format) => { 237 | while ((matchingDate = format.regex.exec(noteText)) !== null) { 238 | const matchingDateStart = matchingDate?.index!; 239 | const matchingDateEnd = matchingDate?.index! + matchingDate![0].length; 240 | 241 | // Skip dates that are inside codeblocks 242 | if (this.isInCodeblock(view, matchingDateStart)) continue; 243 | 244 | if (dateMatches.some((m) => 245 | matchingDateStart >= m.from && ((matchingDateEnd <= m.to) || (matchingDateStart <= m.to)))) continue; 246 | dateMatches.push({ from: matchingDate.index, to: matchingDate.index + matchingDate[0].length, value: matchingDate[0], format: format }); 247 | } 248 | }); 249 | return dateMatches; 250 | } 251 | 252 | public getNextMatch(view: EditorView, cursorPosition: number): DateMatch | undefined { 253 | const matches = this.getAllDates(view).sort((a, b) => a.from - b.from); 254 | return matches.find(m => m.from > cursorPosition); 255 | } 256 | 257 | public getPreviousMatch(view: EditorView, cursorPosition: number): DateMatch | undefined { 258 | const matches = this.getAllDates(view).sort((a, b) => b.from - a.from); 259 | return matches.find(m => m.to < cursorPosition); 260 | } 261 | 262 | decorations: DecorationSet; 263 | 264 | private scrollEventAbortController = new AbortController(); 265 | 266 | constructor(view: EditorView) { 267 | this.view = view; 268 | view.scrollDOM.addEventListener("scroll", this.datepickerScrollHandler.bind(this, view), { signal: this.scrollEventAbortController.signal }); 269 | this.dates = this.getVisibleDates(view); 270 | this.decorations = pickerButtons(this.dates); 271 | } 272 | 273 | public datepicker: Datepicker | undefined = undefined; 274 | private previousDateMatch: DateMatch; 275 | dates: DateMatch[] = []; 276 | // flag to prevent repeatedly selecting text on every click of the datetime value, select only the first time 277 | private performedSelectText = false; 278 | 279 | public match: DateMatch | undefined; 280 | public performingReplace = false; 281 | 282 | openDatepicker(view: EditorView, match: DateMatch) { 283 | view.requestMeasure({ 284 | read: view => { 285 | let pos = view.coordsAtPos(match.from); 286 | return pos; 287 | }, 288 | write: pos => { 289 | if (!pos) { 290 | console.error("position is undefined"); 291 | return; 292 | } 293 | 294 | this.datepicker = new Datepicker(); 295 | this.datepicker.open(pos, match 296 | , (result) => { 297 | const resultFromPicker = moment(result); 298 | if (!resultFromPicker.isValid()) { 299 | return; 300 | } 301 | // Use the user's preferred format when editing dates 302 | const dateFromPicker = resultFromPicker.format( 303 | match.format.type === 'DATETIME' 304 | ? (DatepickerPlugin.settings.overrideFormat 305 | ? DatepickerPlugin.settings.dateFormat + " " + (match.format.formatToUser.includes('A') ? 'hh:mm A' : 'HH:mm') 306 | : match.format.formatToUser) 307 | : match.format.type === 'DATE' 308 | ? (DatepickerPlugin.settings.overrideFormat 309 | ? DatepickerPlugin.settings.dateFormat 310 | : match.format.formatToUser) 311 | : match.format.formatToUser 312 | ); 313 | 314 | if (dateFromPicker === match.value) return; 315 | this.performingReplace = true; 316 | setTimeout(() => { this.performingReplace = false; }, 300); 317 | let transaction = view.state.update({ 318 | changes: { 319 | from: match.from, 320 | to: match.to, 321 | insert: dateFromPicker 322 | } 323 | }); 324 | view.dispatch(transaction); 325 | 326 | if (this.match !== undefined && this.match.from !== match.from && DatepickerPlugin.settings.selectDateText) { 327 | const m = this.match; 328 | setTimeout(() => { 329 | view.dispatch({ selection: { anchor: m.from, head: m.to } }) 330 | }, 0); 331 | } 332 | }); 333 | } 334 | }); 335 | } 336 | 337 | 338 | update(update: ViewUpdate) { 339 | 340 | this.view = update.view; 341 | 342 | if (update.docChanged || update.geometryChanged || update.viewportChanged || update.heightChanged || this.performingReplace) { 343 | this.datepickerPositionHandler(); 344 | this.dates = this.getVisibleDates(update.view); 345 | this.decorations = pickerButtons(this.dates); 346 | } 347 | 348 | 349 | /* 350 | CM fires two update events for selection change, 351 | I use the code section below to ignore the second one 352 | */ 353 | if (update.docChanged === false && 354 | update.state.selection.main.from === update.startState.selection.main.from && 355 | update.state.selection.main.to === update.startState.selection.main.to 356 | ) return; 357 | 358 | 359 | const { view } = update; 360 | 361 | const cursorPosition = view.state.selection.main.head; 362 | 363 | this.match = this.dates.find(date => date.from <= cursorPosition && date.to >= cursorPosition); 364 | if (this.match) { 365 | 366 | const { from } = update.state.selection.main; 367 | const { to } = update.state.selection.main; 368 | if (from !== to)// Closes datepicker if selection is a range and the range is not the entire matched datetime 369 | if (from !== this.match.from || to !== this.match.to) { 370 | if (this.datepicker !== undefined) this.datepicker.respectSettingAndClose(); 371 | return; 372 | } 373 | 374 | let sameMatch = false; 375 | if (this.previousDateMatch !== undefined) 376 | sameMatch = this.previousDateMatch.from === this.match.from; 377 | 378 | if (this.datepicker !== undefined) { 379 | if (this.previousDateMatch !== undefined) { 380 | // prevent reopening date picker on the same date field when closed by button 381 | // or when esc was pressed 382 | if (sameMatch) { 383 | if (this.datepicker?.closedByButton || Datepicker.escPressed || Datepicker.enterPressed) return; 384 | } else { 385 | this.performedSelectText = false;//Allow possibly selecting datetime text again 386 | if (!Datepicker.openedByButton) { 387 | Datepicker.calendarImmediatelyShownOnce = false;//Allow possibly showing calendar automatically again 388 | } else Datepicker.openedByButton = false; 389 | } 390 | } 391 | } else this.performedSelectText = false;//Allow possibly selecting datetime text again 392 | if (DatepickerPlugin.settings.selectDateText && !this.performedSelectText && this.match !== undefined && (!update.docChanged || Datepicker.performedInsertCommand)) { 393 | setTimeout(() => view.dispatch({ selection: { anchor: this.match!.from, head: this.match!.to } }), 0); 394 | this.performedSelectText = true; 395 | } 396 | 397 | 398 | 399 | this.previousDateMatch = this.match; 400 | if (DatepickerPlugin.settings.showAutomatically) { 401 | // prevent reopening date picker on the same date field when just performed insert command 402 | if (Datepicker.performedInsertCommand) setTimeout(() => Datepicker.performedInsertCommand = false, 300); 403 | if (Datepicker.openedByButton) setTimeout(() => Datepicker.openedByButton = false, 300); 404 | if (!Datepicker.performedInsertCommand && !Datepicker.openedByButton && !this.performingReplace) 405 | setTimeout(() => this.openDatepicker(view, this.match!), 0); 406 | } 407 | } else { 408 | Datepicker.calendarImmediatelyShownOnce = false; 409 | this.performedSelectText = false; 410 | Datepicker.performedInsertCommand = false; 411 | if (this.datepicker !== undefined) { 412 | if (this.previousDateMatch !== undefined) 413 | if (cursorPosition < this.previousDateMatch.from || cursorPosition > this.previousDateMatch.to) { 414 | this.datepicker.respectSettingAndClose(); 415 | this.datepicker = undefined; 416 | } 417 | } 418 | } 419 | 420 | 421 | } 422 | 423 | destroy() { 424 | this.datepicker?.respectSettingAndClose(); 425 | this.scrollEventAbortController.abort(); 426 | } 427 | } 428 | export const datepickerCMPlugin = ViewPlugin.fromClass(DatepickerCMPlugin, { 429 | decorations: (v) => { 430 | return v.decorations; 431 | }, 432 | 433 | eventHandlers: { 434 | mousedown: (e, view) => { 435 | datepickerButtonEventHandler(e, view); 436 | 437 | }, 438 | touchend: (e, view) => { 439 | datepickerButtonEventHandler(e, view); 440 | }, 441 | } 442 | }); 443 | 444 | const pickerButtonsAbortController = new AbortController(); 445 | let dbounce = false;//debouncing is essential for button to work correctly on mobile 446 | function datepickerButtonEventHandler(e: Event, view: EditorView) { 447 | if (dbounce) return; 448 | dbounce = true; 449 | setTimeout(() => dbounce = false, 100); 450 | let target = e.target as HTMLElement 451 | const dpCMPlugin = view.plugin(datepickerCMPlugin); 452 | if (!dpCMPlugin) return; 453 | if (target.matches(".datepicker-button, .datepicker-button *")) { 454 | e.preventDefault(); 455 | const cursorPositionAtButton = view.posAtDOM(target); 456 | const dateMatch = dpCMPlugin!.dates.find(date => date.from === cursorPositionAtButton)!; 457 | // this toggles showing the datepicker if it is already open at the button position 458 | if (dpCMPlugin!.datepicker !== undefined && dpCMPlugin.datepicker.isOpened) { 459 | dpCMPlugin!.datepicker.respectSettingAndClose(); 460 | dpCMPlugin!.datepicker.closedByButton = true; // to prevent picker from opening again on selecting same date field 461 | } else { 462 | dpCMPlugin!.datepicker?.respectSettingAndClose(); 463 | Datepicker.openedByButton = true; 464 | Datepicker.calendarImmediatelyShownOnce = false; 465 | setTimeout(() => { 466 | if (DatepickerPlugin.settings.selectDateText) setTimeout(() => view.dispatch({ selection: { anchor: dateMatch.from, head: dateMatch.to } }), 0); 467 | dpCMPlugin!.openDatepicker(view, dateMatch); 468 | }, 0); 469 | } 470 | } 471 | return true; 472 | } 473 | 474 | 475 | interface DatepickerPluginSettings { 476 | dateFormat: string; 477 | overrideFormat: boolean; 478 | showDateButtons: boolean; 479 | showTimeButtons: boolean; 480 | showAutomatically: boolean; 481 | autoApplyEdits: boolean; 482 | immediatelyShowCalendar: boolean; 483 | autofocus: boolean; 484 | focusOnArrowDown: boolean; 485 | insertIn24HourFormat: boolean; 486 | selectDateText: boolean; 487 | ignoreCodeblocks: boolean; 488 | } 489 | 490 | const DEFAULT_SETTINGS: DatepickerPluginSettings = { 491 | dateFormat: 'YYYY-MM-DD', 492 | overrideFormat: false, 493 | showDateButtons: true, 494 | showTimeButtons: true, 495 | showAutomatically: false, 496 | autoApplyEdits: true, 497 | immediatelyShowCalendar: false, 498 | autofocus: false, 499 | focusOnArrowDown: false, 500 | insertIn24HourFormat: false, 501 | selectDateText: false, 502 | ignoreCodeblocks: false 503 | } 504 | 505 | export default class DatepickerPlugin extends Plugin { 506 | 507 | public static settings: DatepickerPluginSettings = DEFAULT_SETTINGS; 508 | 509 | async onload() { 510 | 511 | await this.loadSettings(); 512 | 513 | this.registerEditorExtension(datepickerCMPlugin); 514 | 515 | this.addCommand({ 516 | id: 'edit-datetime', 517 | name: 'Edit date/time', 518 | editorCallback: (editor: Editor) => { 519 | // @ts-expect-error, not typed 520 | const editorView = editor.cm as EditorView; 521 | const cursorPosition = editorView.state.selection.main.to; 522 | if (cursorPosition === undefined) { 523 | new Notice("Please select a date/time"); 524 | return; 525 | } 526 | const plugin = editorView.plugin(datepickerCMPlugin); 527 | const match = plugin!.dates.find(date => date.from <= cursorPosition && date.to >= cursorPosition); 528 | if (match) { 529 | plugin!.openDatepicker(editorView, match); 530 | } else new Notice("Please select a date/time"); 531 | } 532 | }) 533 | 534 | this.addCommand({ 535 | id: 'insert-date', 536 | name: 'Insert new date', 537 | editorCallback: (editor: Editor) => { 538 | // @ts-expect-error, not typed 539 | const editorView = editor.cm as EditorView; 540 | const cursorPosition = editorView.state.selection.main.to; 541 | if (cursorPosition === undefined) return; 542 | const pos = editorView.coordsAtPos(cursorPosition); 543 | if (!pos) return; 544 | 545 | const datepicker = new Datepicker() 546 | const dateFormat: DateFormat = { regex: new RegExp(""), type: "DATE", formatToUser: "", formatToPicker: "" } 547 | const dateType: DateMatch = { from: cursorPosition, to: cursorPosition, value: "", format: dateFormat }; 548 | datepicker.open( 549 | { top: pos.top, left: pos.left, right: pos.right, bottom: pos.bottom }, dateType, 550 | (result) => { 551 | if (moment(result).isValid() === true) { 552 | setTimeout(() => { // delay to wait for editor update to finish 553 | Datepicker.performedInsertCommand = true; 554 | editorView.dispatch({ 555 | changes: { 556 | from: cursorPosition, 557 | to: cursorPosition, 558 | insert: moment(result).format(DatepickerPlugin.settings.dateFormat) 559 | } 560 | }); 561 | }, 0); 562 | } else new Notice("Please enter a valid date"); 563 | } 564 | ) 565 | datepicker.focus(); 566 | } 567 | }); 568 | this.addCommand({ 569 | id: 'insert-time', 570 | name: 'Insert new time', 571 | editorCallback: (editor: Editor) => { 572 | // @ts-expect-error, not typed 573 | const editorView = editor.cm as EditorView; 574 | const cursorPosition = editorView.state.selection.main.to; 575 | if (cursorPosition === undefined) return; 576 | const pos = editorView.coordsAtPos(cursorPosition); 577 | if (!pos) return; 578 | const datepicker = new Datepicker() 579 | const dateFormat: DateFormat = { regex: new RegExp(""), type: "TIME", formatToUser: "", formatToPicker: "" } 580 | const dateType: DateMatch = { from: cursorPosition, to: cursorPosition, value: "", format: dateFormat }; 581 | datepicker.open( 582 | { top: pos.top, left: pos.left, right: pos.right, bottom: pos.bottom }, dateType, 583 | (result) => { 584 | if (moment(result, "HH:mm").isValid() === true) { 585 | let timeFormat: string; 586 | if (DatepickerPlugin.settings.insertIn24HourFormat) timeFormat = "HH:mm"; 587 | else timeFormat = "hh:mm A"; 588 | setTimeout(() => { // delay to wait for editor update to finish 589 | Datepicker.performedInsertCommand = true; 590 | editorView.dispatch({ 591 | changes: { 592 | from: cursorPosition, 593 | to: cursorPosition, 594 | insert: moment(result).format(timeFormat) 595 | } 596 | }); 597 | }, 25); 598 | } else new Notice("Please enter a valid time"); 599 | } 600 | ) 601 | datepicker.focus(); 602 | } 603 | }); 604 | this.addCommand({ 605 | id: 'insert-datetime', 606 | name: 'Insert new date and time', 607 | editorCallback: (editor: Editor) => { 608 | // @ts-expect-error, not typed 609 | const editorView = editor.cm as EditorView; 610 | const cursorPosition = editorView.state.selection.main.to; 611 | if (cursorPosition === undefined) return; 612 | const pos = editorView.coordsAtPos(cursorPosition); 613 | if (!pos) return; 614 | const datepicker = new Datepicker(); 615 | const dateFormat: DateFormat = { regex: new RegExp(""), type: "DATETIME", formatToUser: "", formatToPicker: "" } 616 | const dateType: DateMatch = { from: cursorPosition, to: cursorPosition, value: "", format: dateFormat }; 617 | datepicker.open( 618 | { top: pos.top, left: pos.left, right: pos.right, bottom: pos.bottom }, dateType, 619 | (result) => { 620 | if (moment(result).isValid() === true) { 621 | let timeFormat: string; 622 | if (DatepickerPlugin.settings.insertIn24HourFormat) timeFormat = "HH:mm"; 623 | else timeFormat = "hh:mm A"; 624 | setTimeout(() => { // delay to wait for editor update to finish 625 | Datepicker.performedInsertCommand = true; 626 | editorView.dispatch({ 627 | changes: { 628 | from: cursorPosition, 629 | to: cursorPosition, 630 | insert: moment(result).format(DatepickerPlugin.settings.dateFormat + " " + timeFormat) 631 | } 632 | }); 633 | }, 25); 634 | } else new Notice("Please enter a valid date and time"); 635 | } 636 | ) 637 | datepicker.focus(); 638 | } 639 | }); 640 | this.addCommand({ 641 | id: 'insert-current-time', 642 | name: 'Insert current time', 643 | editorCallback: (editor: Editor) => { 644 | // @ts-expect-error, not typed 645 | const editorView = editor.cm as EditorView; 646 | const cursorPosition = editorView.state.selection.main.to; 647 | if (cursorPosition === undefined) return; 648 | let timeFormat: string; 649 | if (DatepickerPlugin.settings.insertIn24HourFormat) timeFormat = "HH:mm"; 650 | else timeFormat = "hh:mm A"; 651 | Datepicker.performedInsertCommand = true; 652 | editorView.dispatch({ 653 | changes: { 654 | from: cursorPosition, 655 | to: cursorPosition, 656 | insert: moment().format(timeFormat) 657 | } 658 | }) 659 | } 660 | }); 661 | 662 | this.addCommand({ 663 | id: 'insert-current-datetime', 664 | name: 'Insert current date and time', 665 | editorCallback: (editor: Editor) => { 666 | // @ts-expect-error, not typed 667 | const editorView = editor.cm as EditorView; 668 | const cursorPosition = editorView.state.selection.main.to; 669 | if (cursorPosition === undefined) return; 670 | let timeFormat: string; 671 | if (DatepickerPlugin.settings.insertIn24HourFormat) timeFormat = "HH:mm"; 672 | else timeFormat = "hh:mm A"; 673 | Datepicker.performedInsertCommand = true; 674 | editorView.dispatch({ 675 | changes: { 676 | from: cursorPosition, 677 | to: cursorPosition, 678 | insert: moment().format(DatepickerPlugin.settings.dateFormat + " " + timeFormat) 679 | } 680 | }) 681 | } 682 | }); 683 | 684 | this.addCommand({ 685 | id: 'insert-current-date', 686 | name: 'Insert current date', 687 | editorCallback: (editor: Editor) => { 688 | // @ts-expect-error, not typed 689 | const editorView = editor.cm as EditorView; 690 | const cursorPosition = editorView.state.selection.main.to; 691 | if (cursorPosition === undefined) return; 692 | Datepicker.performedInsertCommand = true; 693 | editorView.dispatch({ 694 | changes: { 695 | from: cursorPosition, 696 | to: cursorPosition, 697 | insert: moment().format(DatepickerPlugin.settings.dateFormat) 698 | } 699 | }) 700 | } 701 | }); 702 | 703 | this.addCommand({ 704 | id: 'select-next-datetime', 705 | name: 'Select next date/time', 706 | editorCallback: (editor: Editor) => { 707 | // @ts-expect-error, not typed 708 | const editorView = editor.cm as EditorView; 709 | const cursorPosition = editorView.state.selection.main.to; 710 | if (cursorPosition === undefined) return; 711 | const dpCMPlugin = editorView.plugin(datepickerCMPlugin); 712 | if (!dpCMPlugin) return; 713 | const match = dpCMPlugin.getNextMatch(editorView, cursorPosition); 714 | if (match) { 715 | editorView.dispatch({ 716 | selection: { 717 | anchor: match.from, 718 | head: match.from 719 | }, 720 | scrollIntoView: true 721 | }) 722 | } else new Notice("No next date/time found"); 723 | } 724 | }); 725 | 726 | this.addCommand({ 727 | id: 'select-previous-datetime', 728 | name: 'Select previous date/time', 729 | editorCallback: (editor: Editor) => { 730 | // @ts-expect-error, not typed 731 | const editorView = editor.cm as EditorView; 732 | const cursorPosition = editorView.state.selection.main.to; 733 | if (cursorPosition === undefined) return; 734 | const dpCMPlugin = editorView.plugin(datepickerCMPlugin); 735 | if (!dpCMPlugin) return; 736 | const match = dpCMPlugin.getPreviousMatch(editorView, cursorPosition); 737 | if (match) { 738 | editorView.dispatch({ 739 | selection: { 740 | anchor: match.from, 741 | head: match.from 742 | }, 743 | scrollIntoView: true 744 | }) 745 | } else new Notice("No previous date/time found"); 746 | } 747 | }); 748 | 749 | this.addSettingTab(new DatepickerSettingTab(this.app, this)); 750 | 751 | this.registerEvent( 752 | this.app.workspace.on("active-leaf-change", (event) => { 753 | const editor = event?.view.app.workspace.activeEditor?.editor; 754 | if (!editor) return; 755 | // @ts-expect-error, not typed 756 | const editorView = editor.cm as EditorView; 757 | const dpCMPlugin = editorView.plugin(datepickerCMPlugin); 758 | if (!dpCMPlugin) return; 759 | let delay = 350; 760 | // if(dpCMPlugin.performingReplace) delay = 30; 761 | setTimeout(() => { // restores selection after replacing text on previuos date/time 762 | const { match } = dpCMPlugin; 763 | if (match !== undefined) { 764 | if (DatepickerPlugin.settings.selectDateText) 765 | editorView.dispatch({ selection: { anchor: match!.from, head: match!.to } }) 766 | if (DatepickerPlugin.settings.showAutomatically) 767 | dpCMPlugin.openDatepicker(editorView, match); 768 | } 769 | }, delay); 770 | Datepicker.escPressed = false; 771 | Datepicker.calendarImmediatelyShownOnce = false; 772 | } 773 | ) 774 | ) 775 | } 776 | 777 | onunload() { 778 | } 779 | 780 | async loadSettings() { 781 | DatepickerPlugin.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 782 | } 783 | 784 | async saveSettings() { 785 | await this.saveData(DatepickerPlugin.settings); 786 | } 787 | 788 | } 789 | 790 | class Datepicker { 791 | 792 | private onSubmit: (result: string) => void; 793 | private submited = false; 794 | public isOpened = false; 795 | private pickerContainer: HTMLSpanElement; 796 | private pickerInput: HTMLInputElement; 797 | private viewContainer: HTMLElement; 798 | public datetime: DateMatch; 799 | public static escPressed = false; 800 | public cursorPosition: number; 801 | public closedByButton = false; 802 | public static openedByButton = false; 803 | // prevents reopening the datepicker on the just inserted date 804 | public static performedInsertCommand = false; 805 | // Used for preventing the calendar from continuously reopening on every 806 | // interaction with the datefield when set to immediatelyShowCalendar 807 | public static calendarImmediatelyShownOnce = false; 808 | // Used for preventing blur event from inserting date twice 809 | public static enterPressed = false; 810 | 811 | constructor() { 812 | this.close(); 813 | } 814 | 815 | 816 | public updatePosition(pos: { top: number, left: number, bottom: number }) { 817 | // TODO: add support for rtl windows: pseudo:if(window.rtl) 818 | const left = pos.left - this.viewContainer.getBoundingClientRect().left; 819 | if (left + this.pickerContainer.offsetWidth > this.viewContainer.offsetWidth) 820 | this.pickerContainer.style.left = left - ((left + this.pickerContainer.offsetWidth) - this.viewContainer.offsetWidth) + 'px'; 821 | else this.pickerContainer.style.left = left + 'px'; 822 | 823 | const leafTop = this.viewContainer.closest('.workspace-leaf-content')!.getBoundingClientRect().top; 824 | if (pos.bottom - leafTop > this.viewContainer.offsetHeight) 825 | this.pickerContainer.style.top = pos.top - leafTop - this.pickerContainer.offsetHeight + 'px'; 826 | else this.pickerContainer.style.top = pos.bottom - leafTop + 'px'; 827 | } 828 | 829 | public focus() { 830 | setTimeout(() => this.pickerInput.focus(), 250); 831 | } 832 | 833 | public close() { 834 | let datepickers = activeDocument.getElementsByClassName("datepicker-container"); 835 | for (var i = 0; i < datepickers.length; i++) { 836 | datepickers[i].remove(); 837 | } 838 | 839 | this.isOpened = false; 840 | 841 | // Simulate clicking the active tab header to regain focus 842 | setTimeout(() => { 843 | // Simulate Escape key press to restore focus 844 | const escapeEvent = new KeyboardEvent('keydown', { 845 | key: 'Escape', 846 | code: 'Escape', 847 | bubbles: true 848 | }); 849 | activeDocument.dispatchEvent(escapeEvent); 850 | }, 50); 851 | } 852 | 853 | public respectSettingAndClose() { 854 | if (DatepickerPlugin.settings.autoApplyEdits) this.submitAndClose(); 855 | else this.close(); 856 | } 857 | 858 | private submitAndClose() { 859 | this.submit(); 860 | this.close(); 861 | } 862 | 863 | public submit() { 864 | if (this.submited || Datepicker.escPressed || !this.isOpened) return; 865 | this.submited = true; 866 | let submitValue = this.pickerInput.value; 867 | if (submitValue.length === 0) return; 868 | if (moment(submitValue).format(this.datetime.format.formatToUser) === this.datetime.value) return; 869 | if (this.datetime.format.type === "TIME") 870 | // Neccessary for momentjs to parse and format time 871 | submitValue = moment().format('YYYY-MM-DD') + "T" + this.pickerInput.value; 872 | 873 | setTimeout(() => this.onSubmit(submitValue), 0); 874 | } 875 | 876 | public open(pos: { top: number, left: number, right: number, bottom: number }, 877 | datetime: DateMatch, onSubmit: (result: string) => void 878 | ) { 879 | this.onSubmit = onSubmit; 880 | this.datetime = datetime; 881 | this.cursorPosition = datetime.from; 882 | this.closedByButton = false; 883 | Datepicker.escPressed = false; 884 | Datepicker.enterPressed = false; 885 | 886 | this.viewContainer = activeDocument.querySelector('.workspace-leaf.mod-active')?.querySelector('.cm-editor')!; 887 | if (!this.viewContainer) { 888 | console.error("Could not find view container"); 889 | return; 890 | } 891 | this.pickerContainer = this.viewContainer.createEl('div'); 892 | this.pickerContainer.className = 'datepicker-container'; 893 | this.pickerContainer.id = 'datepicker-container'; 894 | this.pickerInput = this.pickerContainer.createEl('input'); 895 | 896 | if (datetime.format.type === "TIME") this.pickerInput.type = 'time'; 897 | else if (datetime.format.type === "DATE") this.pickerInput.type = 'date'; 898 | else if (datetime.format.type === "DATETIME") this.pickerInput.type = 'datetime-local'; 899 | 900 | this.pickerInput.id = 'datepicker-input'; 901 | this.pickerInput.className = 'datepicker-input'; 902 | 903 | this.pickerInput.value = moment(datetime.value, [ 904 | "YYYY-MM-DD hh:mm A" 905 | , "YYYY-MM-DDThh:mm" 906 | , "YYYY-MM-DD hh:mma" 907 | , "YYYY.MM.DD HH:mm" 908 | , "YYYY-MM-DD" 909 | , "DD-MM-YYYY HH:mm" 910 | , "DD-MM-YYYY hh:mm A" 911 | , "DD-MM-YYYY hh:mma" 912 | , "DD-MM-YYYY" 913 | , "hh:mm A" 914 | , "HH:mm" 915 | ], false).format(datetime.format.formatToPicker); 916 | 917 | const acceptButton = this.pickerContainer.createEl('button'); 918 | acceptButton.className = 'datepicker-container-button'; 919 | setIcon(acceptButton, 'check'); 920 | 921 | const buttonEventAbortController = new AbortController(); 922 | const acceptButtonEventHandler = (event: Event) => { 923 | // event.preventDefault(); 924 | if (this.pickerInput.value === '') { 925 | new Notice('Please enter a valid date'); 926 | } else { 927 | Datepicker.enterPressed = true; 928 | this.submitAndClose(); 929 | buttonEventAbortController.abort(); 930 | } 931 | } 932 | acceptButton.addEventListener('click', acceptButtonEventHandler, { signal: buttonEventAbortController.signal }); 933 | acceptButton.addEventListener('touchend', acceptButtonEventHandler, { signal: buttonEventAbortController.signal }); 934 | 935 | const cancelButton = this.pickerContainer.createEl('button'); 936 | cancelButton.className = 'datepicker-container-button'; 937 | setIcon(cancelButton, 'x'); 938 | function cancelButtonEventHandler(event: Event) { 939 | event.preventDefault(); 940 | 941 | Datepicker.escPressed = true; 942 | this.close(); 943 | buttonEventAbortController.abort(); 944 | } 945 | 946 | cancelButton.addEventListener('click', cancelButtonEventHandler.bind(this), { signal: buttonEventAbortController.signal }); 947 | cancelButton.addEventListener('touchend', cancelButtonEventHandler.bind(this), { signal: buttonEventAbortController.signal }); 948 | 949 | 950 | const controller = new AbortController(); 951 | const keypressHandler = (event: KeyboardEvent) => { 952 | if (event.key === 'ArrowDown') { 953 | if (DatepickerPlugin.settings.focusOnArrowDown) { 954 | event.preventDefault(); 955 | this.focus(); 956 | controller.abort(); 957 | } 958 | } 959 | if (event.key === 'Escape') { 960 | event.preventDefault(); 961 | Datepicker.escPressed = true; 962 | this.close(); 963 | controller.abort(); 964 | } 965 | } 966 | this.pickerContainer.parentElement?.addEventListener('keydown', keypressHandler, { signal: controller.signal, capture: true }); 967 | 968 | 969 | this.pickerInput.addEventListener('keydown', (event) => { 970 | if (event.key === 'Enter') { 971 | if (this.pickerInput.value === '') { 972 | new Notice('Please enter a valid date/time'); 973 | } else { 974 | Datepicker.enterPressed = true; 975 | this.submitAndClose(); 976 | } 977 | } 978 | // this works only when the datepicker is in focus 979 | if (event.key === 'Escape') { 980 | Datepicker.escPressed = true; 981 | this.close(); 982 | } 983 | }, { capture: true }); 984 | 985 | 986 | const blurEventHandler = () => { 987 | setTimeout(() => { 988 | if (!this.submited && !Datepicker.escPressed && !Datepicker.enterPressed && DatepickerPlugin.settings.autoApplyEdits) 989 | this.submit(); 990 | }, 300); 991 | } 992 | this.pickerInput.addEventListener('blur', blurEventHandler,); 993 | 994 | this.updatePosition(pos); 995 | 996 | // On mobile, the calendar doesn't show up the first time the input is touched, 997 | // unless the element is focused, and focusing the element causes unintended closing 998 | // of keyboard, so I implement event listeners and prevent default behavior. 999 | if (Platform.isMobile) { 1000 | this.pickerInput.addEventListener('touchstart', (e) => { 1001 | e.preventDefault(); 1002 | }) 1003 | this.pickerInput.addEventListener('touchend', (e) => { 1004 | e.preventDefault(); 1005 | (this.pickerInput as any).showPicker(); 1006 | }); 1007 | } 1008 | 1009 | if (DatepickerPlugin.settings.autofocus) 1010 | if (!Platform.isMobile) this.focus(); 1011 | else if (!DatepickerPlugin.settings.immediatelyShowCalendar) this.focus(); 1012 | 1013 | const click = new MouseEvent('click', { 1014 | bubbles: true, 1015 | cancelable: true, 1016 | view: activeWindow 1017 | }); 1018 | 1019 | if (DatepickerPlugin.settings.immediatelyShowCalendar) { 1020 | if (Datepicker.calendarImmediatelyShownOnce) return; 1021 | if (Platform.isMobile) { 1022 | this.pickerInput.focus(); 1023 | setTimeout(() => { 1024 | this.pickerInput.dispatchEvent(click) 1025 | Datepicker.calendarImmediatelyShownOnce = true; 1026 | }, 150); 1027 | } else { 1028 | this.focus(); 1029 | 1030 | // delay is necessary because showing immediately doesn't show the calendar 1031 | // in the correct position, maybe it shows the calendar before the dom is updated 1032 | setTimeout(() => { 1033 | (this.pickerInput as any).showPicker(); 1034 | Datepicker.calendarImmediatelyShownOnce = true; 1035 | }, 500); 1036 | } 1037 | } 1038 | 1039 | this.isOpened = true; 1040 | } 1041 | 1042 | } 1043 | 1044 | class DatepickerSettingTab extends PluginSettingTab { 1045 | plugin: DatepickerPlugin; 1046 | 1047 | constructor(app: App, plugin: DatepickerPlugin) { 1048 | super(app, plugin); 1049 | this.plugin = plugin; 1050 | } 1051 | 1052 | display(): void { 1053 | const { containerEl: settingsContainerElement } = this; 1054 | 1055 | settingsContainerElement.empty(); 1056 | 1057 | new Setting(settingsContainerElement) 1058 | .setName('Date Format') 1059 | .setDesc('Choose your preferred date format for inserting new dates') 1060 | .addDropdown(dropdown => dropdown 1061 | .addOption('YYYY-MM-DD', 'YYYY-MM-DD') 1062 | .addOption('DD.MM.YYYY', 'DD.MM.YYYY') 1063 | .addOption('MM-DD-YYYY', 'MM-DD-YYYY') 1064 | .addOption('DD-MM-YYYY', 'DD-MM-YYYY') 1065 | .addOption('MM.DD.YYYY', 'MM.DD.YYYY') 1066 | .addOption('YYYY.MM.DD', 'YYYY.MM.DD') 1067 | .addOption('YYYY/MM/DD', 'YYYY/MM/DD') 1068 | .addOption('DD/MM/YYYY', 'DD/MM/YYYY') 1069 | .addOption('MM/DD/YYYY', 'MM/DD/YYYY') 1070 | .setValue(DatepickerPlugin.settings.dateFormat) 1071 | .onChange(async (value) => { 1072 | DatepickerPlugin.settings.dateFormat = value; 1073 | await this.plugin.saveSettings(); 1074 | })); 1075 | 1076 | new Setting(settingsContainerElement) 1077 | .setName('Use date format when modifying existing dates') 1078 | .setDesc('Use the selected date format when modifying existing dates') 1079 | .addToggle((toggle) => toggle 1080 | .setValue(DatepickerPlugin.settings.overrideFormat) 1081 | .onChange(async (value) => { 1082 | DatepickerPlugin.settings.overrideFormat = value; 1083 | await this.plugin.saveSettings(); 1084 | })); 1085 | 1086 | new Setting(settingsContainerElement) 1087 | .setName('Insert new time in 24 hour format') 1088 | .setDesc('Insert time in 24 hour format when performing "Insert new time" and "Insert new date and time" commands') 1089 | .addToggle((toggle) => toggle 1090 | .setValue(DatepickerPlugin.settings.insertIn24HourFormat) 1091 | .onChange(async (value) => { 1092 | DatepickerPlugin.settings.insertIn24HourFormat = value; 1093 | await this.plugin.saveSettings(); 1094 | })); 1095 | 1096 | new Setting(settingsContainerElement) 1097 | .setName('Show a picker button for dates') 1098 | .setDesc('Shows a button with a calendar icon associated with date values, select it to open the picker (Reloading Obsidian may be required)') 1099 | .addToggle((toggle) => toggle 1100 | .setValue(DatepickerPlugin.settings.showDateButtons) 1101 | .onChange(async (value) => { 1102 | DatepickerPlugin.settings.showDateButtons = value; 1103 | await this.plugin.saveSettings(); 1104 | })); 1105 | 1106 | new Setting(settingsContainerElement) 1107 | .setName('Show a picker button for times') 1108 | .setDesc('Shows a button with a clock icon associated with time values, select it to open the picker (Reloading Obsidian may be required)') 1109 | .addToggle((toggle) => toggle 1110 | .setValue(DatepickerPlugin.settings.showTimeButtons) 1111 | .onChange(async (value) => { 1112 | DatepickerPlugin.settings.showTimeButtons = value; 1113 | await this.plugin.saveSettings(); 1114 | })); 1115 | 1116 | new Setting(settingsContainerElement) 1117 | .setName('Ignore dates in codeblocks') 1118 | .setDesc('When enabled, the datepicker will ignore dates inside markdown codeblocks (both inline code and fenced code blocks, Reloading Obsidian may be required)') 1119 | .addToggle((toggle) => toggle 1120 | .setValue(DatepickerPlugin.settings.ignoreCodeblocks) 1121 | .onChange(async (value) => { 1122 | DatepickerPlugin.settings.ignoreCodeblocks = value; 1123 | await this.plugin.saveSettings(); 1124 | })); 1125 | 1126 | new Setting(settingsContainerElement) 1127 | .setName('Show automatically') 1128 | .setDesc('Datepicker will show automatically whenever a date/time value is selected') 1129 | .addToggle((toggle) => toggle 1130 | .setValue(DatepickerPlugin.settings.showAutomatically) 1131 | .onChange(async (value) => { 1132 | DatepickerPlugin.settings.showAutomatically = value; 1133 | await this.plugin.saveSettings(); 1134 | })); 1135 | 1136 | new Setting(settingsContainerElement) 1137 | .setName('Auto apply edits') 1138 | .setDesc('Will automatically apply edits made to the date when the datepicker closes or loses focus') 1139 | .addToggle((toggle) => toggle 1140 | .setValue(DatepickerPlugin.settings.autoApplyEdits) 1141 | .onChange(async (value) => { 1142 | DatepickerPlugin.settings.autoApplyEdits = value; 1143 | await this.plugin.saveSettings(); 1144 | })); 1145 | 1146 | 1147 | new Setting(settingsContainerElement) 1148 | .setName('Immediately show calendar') 1149 | .setDesc('Immediately show the calendar when the datepicker opens') 1150 | .addToggle((toggle) => toggle 1151 | .setValue(DatepickerPlugin.settings.immediatelyShowCalendar) 1152 | .onChange(async (value) => { 1153 | DatepickerPlugin.settings.immediatelyShowCalendar = value; 1154 | await this.plugin.saveSettings(); 1155 | })); 1156 | 1157 | new Setting(settingsContainerElement) 1158 | .setName('Autofocus') 1159 | .setDesc('Automatically focus the datepicker whenever the datepicker opens') 1160 | .addToggle((toggle) => toggle 1161 | .setValue(DatepickerPlugin.settings.autofocus) 1162 | .onChange(async (value) => { 1163 | DatepickerPlugin.settings.autofocus = value; 1164 | await this.plugin.saveSettings(); 1165 | })); 1166 | 1167 | new Setting(settingsContainerElement) 1168 | .setName('Focus on pressing down arrow key') 1169 | .setDesc('Focuses the datepicker when the down arrow keyboard key is pressed') 1170 | .addToggle((toggle) => toggle 1171 | .setValue(DatepickerPlugin.settings.focusOnArrowDown) 1172 | .onChange(async (value) => { 1173 | DatepickerPlugin.settings.focusOnArrowDown = value; 1174 | await this.plugin.saveSettings(); 1175 | })); 1176 | 1177 | new Setting(settingsContainerElement) 1178 | .setName('Select date/time text') 1179 | .setDesc('Automatically select the entire date/time text when a date/time is selected') 1180 | .addToggle((toggle) => toggle 1181 | .setValue(DatepickerPlugin.settings.selectDateText) 1182 | .onChange(async (value) => { 1183 | DatepickerPlugin.settings.selectDateText = value; 1184 | await this.plugin.saveSettings(); 1185 | })); 1186 | 1187 | 1188 | } 1189 | } 1190 | --------------------------------------------------------------------------------