mirror of
https://github.com/wth461694678/text-block-timer.git
synced 2026-07-22 05:46:04 +00:00
V1.0.9
1. **==Critical Bug Fix==**: Fixed the undo history pollution issue — timer real-time updates no longer pollute the editor's undo/redo stack. (Thanks to [mosmash](https://github.com/mosmash) for reporting!) 2. **Timer Style Optimization**: Redesigned the timer rendering style; added option to hide hours digit for shorter durations. 3. **==New Feature==**: Added Time Adjustment function with an iPhone-style scroll wheel time picker UI, accessible via right-click menu and command palette, fully compatible with mobile. 4. Closed issues: [#12](https://github.com/wth461694678/text-block-timer/issues/12), [#11](https://github.com/wth461694678/text-block-timer/issues/11), [#7](https://github.com/wth461694678/text-block-timer/issues/7)
This commit is contained in:
parent
9c34f8e82b
commit
b729e2fe5b
7 changed files with 703 additions and 49 deletions
24
README.md
24
README.md
|
|
@ -4,8 +4,20 @@
|
|||
|
||||
A text block timing tool designed for Obsidian, allowing you to add a **count-up stopwatch** function to any text line in your notes.
|
||||
|
||||
## Latest Change
|
||||
**Appearance optimization**: Added a timer emoji customization function. Now you can use your favorite ICONS to represent the running and paused states respectively.
|
||||
## Latest Change (V1.0.9)
|
||||
|
||||
1. **Critical Bug Fix**: Fixed the undo history pollution issue — timer real-time updates no longer pollute the editor's undo/redo stack. Thanks to [mosmash](https://github.com/mosmash) for reporting this issue!
|
||||
- *What is undo pollution?* Previously, every time the timer updated (once per second), it was recorded as an editor change. This meant pressing `Ctrl+Z` would undo the timer update instead of your actual text edits, making the undo function essentially unusable while a timer was running.
|
||||
2. **Timer Style Optimization**: Redesigned the timer rendering with a cleaner appearance; removed redundant spaces around timer spans for a more compact display. Added a new option to hide the hours digit for shorter durations (e.g. display `05:30` instead of `00:05:30`).
|
||||
3. **New Feature: Time Adjustment**: Added an iPhone-style scroll wheel time picker UI for adjusting timer durations. Accessible via right-click menu or command palette, fully compatible with mobile devices.
|
||||
|
||||
**Closed Issues in this release:**
|
||||
|
||||
- [#12](https://github.com/wth461694678/text-block-timer/issues/12) \[Bug Report\] Text Block Timer: Ctrl+Z — from [p0nczek](https://github.com/p0nczek)
|
||||
- [#11](https://github.com/wth461694678/text-block-timer/issues/11) \[Feedback/Suggestion\] Hiding HTML in Callouts & Custom Time Formatting — from [p0nczek](https://github.com/p0nczek)
|
||||
- [#7](https://github.com/wth461694678/text-block-timer/issues/7) Hope to add the function of ending the timer — from [kyle-one](https://github.com/kyle-one)
|
||||
|
||||
> 🙏 I sincerely apologize for the long hiatus — due to a busy schedule with work and life, updates were paused for a while. Thank you for your patience and continued support!
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -86,6 +98,12 @@ In the default settings, path control is disabled. When you only want to use the
|
|||

|
||||
|
||||
## Version Log:
|
||||
|
||||
- V1.0.9
|
||||
1. **==Critical Bug Fix==**: Fixed the undo history pollution issue — timer real-time updates no longer pollute the editor's undo/redo stack. (Thanks to [mosmash](https://github.com/mosmash) for reporting!)
|
||||
2. **Timer Style Optimization**: Redesigned the timer rendering style; added option to hide hours digit for shorter durations.
|
||||
3. **==New Feature==**: Added Time Adjustment function with an iPhone-style scroll wheel time picker UI, accessible via right-click menu and command palette, fully compatible with mobile.
|
||||
4. Closed issues: [#12](https://github.com/wth461694678/text-block-timer/issues/12), [#11](https://github.com/wth461694678/text-block-timer/issues/11), [#7](https://github.com/wth461694678/text-block-timer/issues/7)
|
||||
- V1.0.8
|
||||
1. Appearance optimization: Added a timer emoji customization function. Now you can use your favorite ICONS to represent the running and paused states respectively.
|
||||
- V1.0.7
|
||||
|
|
@ -116,4 +134,4 @@ In the default settings, path control is disabled. When you only want to use the
|
|||
## Development Information
|
||||
|
||||
- Developer: frankthwang
|
||||
- Version: 1.0.8
|
||||
- Version: 1.0.9
|
||||
|
|
|
|||
37
README.zh.md
37
README.zh.md
|
|
@ -4,8 +4,20 @@
|
|||
|
||||
一个为Obsidian设计的文本块计时工具,可以在笔记中为任意文本行添加**正计时**功能。
|
||||
|
||||
## 最新变更
|
||||
**外观优化**:增加计时器emoji自定义功能,现在你可以使用喜欢的图标来分别表示运行和暂停状态了
|
||||
## 最新变更 (V1.0.9)
|
||||
|
||||
1. **重大Bug修复**:修复了 undo 历史污染问题 — 计时器的实时更新不再污染编辑器的撤销/重做历史栈。感谢 [mosmash](https://github.com/mosmash) 提供解决方案并提交 Pull Request!
|
||||
- *什么是 undo 污染?* 此前,计时器每秒更新一次时间显示,每次更新都会被编辑器记录为一次文本变更。这导致按下 `Ctrl+Z` 撤销时,撤销的是计时器的更新而非用户的实际文本编辑,使得撤销功能在计时器运行期间几乎无法正常使用。
|
||||
2. **计时器外观优化**:重新设计了计时器渲染样式,外观更加简洁;移除了计时器标签前后多余的空格,显示更紧凑。新增隐藏小时数选项,针对较短耗时可以省略小时位显示(例如显示 `05:30` 而非 `00:05:30`)。
|
||||
3. **新功能:耗时调整**:新增 iPhone 风格的滚轮式时间选择器 UI,可通过右键菜单或命令面板快速调整计时器耗时,完全兼容移动端。
|
||||
|
||||
**本次版本关闭的 Issue:**
|
||||
|
||||
- [#12](https://github.com/wth461694678/text-block-timer/issues/12) \[Bug Report\] Text Block Timer: Ctrl+Z — 来自 [p0nczek](https://github.com/p0nczek)
|
||||
- [#11](https://github.com/wth461694678/text-block-timer/issues/11) \[Feedback/Suggestion\] Hiding HTML in Callouts & Custom Time Formatting — 来自 [p0nczek](https://github.com/p0nczek)
|
||||
- [#7](https://github.com/wth461694678/text-block-timer/issues/7) Hope to add the function of ending the timer — 来自 [kyle-one](https://github.com/kyle-one)
|
||||
|
||||
> 🙏 在此对长时间未更新表示诚挚的歉意 — 由于工作和生活繁忙,更新中断了一段时间。感谢各位的耐心等待和持续支持!
|
||||
|
||||
## 功能特性
|
||||
|
||||
|
|
@ -42,8 +54,6 @@
|
|||
|
||||

|
||||
|
||||
|
||||
|
||||
## 自定义设置
|
||||
|
||||
用户可以在插件设置中自定义以下选项:
|
||||
|
|
@ -82,18 +92,19 @@
|
|||
在默认设置下,路径控制是关闭的。当你只希望在部分文件夹下使用该功能,你可以通过白名单或黑名单的方式进行控制,这两种方式只会生效其中一种。
|
||||
|
||||
- 不控制:所有文件夹下都可以使用该功能
|
||||

|
||||
|
||||

|
||||
- 白名单:只有在白名单中的文件夹下,才会生效
|
||||

|
||||
|
||||

|
||||
- 黑名单:在黑名单中的文件夹下,不会生效
|
||||

|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
## 版本日志:
|
||||
|
||||
- V1.0.9
|
||||
1. **==重大Bug修复==**:修复了 undo 历史污染问题 — 计时器实时更新不再污染编辑器的撤销/重做历史栈。(感谢 [mosmash](https://github.com/mosmash) 提供修复 PR!)
|
||||
2. **计时器外观优化**:重新设计计时器渲染样式,外观更简洁;新增隐藏小时数选项,针对较短耗时可省略小时位。
|
||||
3. **==新功能==**:新增耗时调整功能,提供 iPhone 风格的滚轮式时间选择器 UI,可通过右键菜单和命令面板使用,完全兼容移动端。
|
||||
4. 关闭的 Issue:[#12](https://github.com/wth461694678/text-block-timer/issues/12)、[#11](https://github.com/wth461694678/text-block-timer/issues/11)、[#7](https://github.com/wth461694678/text-block-timer/issues/7)
|
||||
- V1.0.8
|
||||
1. 外观优化:增加计时器emoji自定义功能,现在你可以使用喜欢的图标来分别表示运行和暂停状态了
|
||||
- V1.0.7
|
||||
|
|
@ -124,4 +135,4 @@
|
|||
## 开发信息
|
||||
|
||||
- 开发者: frankthwang
|
||||
- 版本: 1.0.8
|
||||
- 版本: 1.0.9
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@
|
|||
"timerDisplayStyle": "badge",
|
||||
"runningTextColor": "#10b981",
|
||||
"runningBgColor": "rgba(16, 185, 129, 0.15)",
|
||||
"pausedTextColor": "#6b7280",
|
||||
"pausedBgColor": "rgba(107, 114, 128, 0.12)"
|
||||
"pausedTextColor": "#ffffff",
|
||||
"pausedBgColor": "rgba(255, 255, 255, 0.12)"
|
||||
}
|
||||
BIN
demo/demo.gif
BIN
demo/demo.gif
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 1.5 MiB |
523
main.js
523
main.js
|
|
@ -28,8 +28,8 @@ class TimerWidget extends WidgetType {
|
|||
const match = this.htmlText.match(/>([^<]*)<\/span>/);
|
||||
span.textContent = match ? match[1] : '[Timer]';
|
||||
|
||||
// Add hover title to show what it is
|
||||
span.title = 'Timer (click to edit)';
|
||||
|
||||
|
||||
|
||||
return span;
|
||||
}
|
||||
|
|
@ -435,7 +435,7 @@ class PerfMonitor {
|
|||
|
||||
// TimerPlugin methods - wrap prototype methods after class is defined
|
||||
if (typeof TimerPlugin !== 'undefined') {
|
||||
window.__PerfMonitor.wrapMethods(TimerPlugin.prototype, ['onload', 'onunload', 'onEditorMenu', 'onFileOpen', 'restoreTimers', 'handleStart', 'handlePause', 'handleContinue', 'handleDelete', 'onTick'], 'TimerPlugin');
|
||||
window.__PerfMonitor.wrapMethods(TimerPlugin.prototype, ['onload', 'onunload', 'onEditorMenu', 'onFileOpen', 'restoreTimers', 'handleStart', 'handlePause', 'handleContinue', 'handleDelete', 'handleSetDuration', 'onTick'], 'TimerPlugin');
|
||||
}
|
||||
|
||||
// console.log('[PerfMonitor] Core classes wrapped. Start session with PerfMonitorStart()');
|
||||
|
|
@ -462,11 +462,21 @@ const TRANSLATIONS = {
|
|||
en: {
|
||||
command_name: {
|
||||
toggle: "Toggle timer",
|
||||
delete: "Delete timer"
|
||||
delete: "Delete timer",
|
||||
timeAdjust: "Time Adjustment"
|
||||
},
|
||||
action_start: "Start timer",
|
||||
action_paused: "Pause timer",
|
||||
action_continue: "Continue timer",
|
||||
timeBackfill: {
|
||||
menu: "Time Adjustment",
|
||||
title: "Set Time Cost To",
|
||||
disabledTip: "Only works on paused timers",
|
||||
hours: "Hours",
|
||||
minutes: "Minutes",
|
||||
seconds: "Seconds",
|
||||
save: "Save"
|
||||
},
|
||||
settings: {
|
||||
name: "Text Block Timer Settings",
|
||||
desc: "Configure timer behavior",
|
||||
|
|
@ -576,11 +586,21 @@ const TRANSLATIONS = {
|
|||
zh: {
|
||||
command_name: {
|
||||
toggle: "启动计时器/切换计时器状态",
|
||||
delete: "删除计时器"
|
||||
delete: "删除计时器",
|
||||
timeAdjust: "调整耗时"
|
||||
},
|
||||
action_start: "开始计时",
|
||||
action_paused: "暂停计时",
|
||||
action_continue: "继续计时",
|
||||
timeBackfill: {
|
||||
menu: "调整耗时",
|
||||
title: "设定耗时为",
|
||||
disabledTip: "仅对暂停中的计时器生效",
|
||||
hours: "小时",
|
||||
minutes: "分钟",
|
||||
seconds: "秒",
|
||||
save: "保存"
|
||||
},
|
||||
settings: {
|
||||
name: "文本块计时器设置",
|
||||
desc: "配置计时器行为",
|
||||
|
|
@ -690,11 +710,21 @@ const TRANSLATIONS = {
|
|||
zhTW: {
|
||||
command_name: {
|
||||
toggle: "啟動計時器/切換計時器狀態",
|
||||
delete: "刪除計時器"
|
||||
delete: "刪除計時器",
|
||||
timeAdjust: "調整耗時"
|
||||
},
|
||||
action_start: "開始計時",
|
||||
action_paused: "暫停計時",
|
||||
action_continue: "繼續計時",
|
||||
timeBackfill: {
|
||||
menu: "調整耗時",
|
||||
title: "設定耗時為",
|
||||
disabledTip: "僅對暫停中的計時器生效",
|
||||
hours: "小時",
|
||||
minutes: "分鐘",
|
||||
seconds: "秒",
|
||||
save: "保存"
|
||||
},
|
||||
settings: {
|
||||
name: "文本塊計時器設定",
|
||||
desc: "配置計時器行為",
|
||||
|
|
@ -804,11 +834,21 @@ const TRANSLATIONS = {
|
|||
ja: {
|
||||
command_name: {
|
||||
toggle: "タイマーを始める/切替タイマ",
|
||||
delete: "タイマーを削除"
|
||||
delete: "タイマーを削除",
|
||||
timeAdjust: "時間調整"
|
||||
},
|
||||
action_start: "タイマーを始める",
|
||||
action_paused: "タイマーを止める",
|
||||
action_continue: "タイマーを続ける",
|
||||
timeBackfill: {
|
||||
menu: "調整耗時",
|
||||
title: "時間を設定",
|
||||
disabledTip: "一時停止中のタイマーのみ有効",
|
||||
hours: "時間",
|
||||
minutes: "分",
|
||||
seconds: "秒",
|
||||
save: "保存"
|
||||
},
|
||||
settings: {
|
||||
name: "テキストブロックタイマー設定",
|
||||
desc: "タイマーの動作を設定します",
|
||||
|
|
@ -918,11 +958,21 @@ const TRANSLATIONS = {
|
|||
ko: {
|
||||
command_name: {
|
||||
toggle: "타이머 시작/상태 전환",
|
||||
delete: "타이머 삭제"
|
||||
delete: "타이머 삭제",
|
||||
timeAdjust: "시간 조정"
|
||||
},
|
||||
action_start: "타이머 시작",
|
||||
action_paused: "타이머 일시 정지",
|
||||
action_continue: "타이머 계속",
|
||||
timeBackfill: {
|
||||
menu: "시간 조정",
|
||||
title: "시간 설정",
|
||||
disabledTip: "일시정지된 타이머에서만 작동합니다",
|
||||
hours: "시간",
|
||||
minutes: "분",
|
||||
seconds: "초",
|
||||
save: "저장"
|
||||
},
|
||||
settings: {
|
||||
name: "텍스트 블록 타이머 설정",
|
||||
desc: "타이머 동작 설정",
|
||||
|
|
@ -1097,6 +1147,17 @@ class TimerDataUpdater {
|
|||
};
|
||||
break;
|
||||
|
||||
case 'setDuration':
|
||||
// Set timer to a specific duration (in seconds)
|
||||
// oldData.newDur is passed as the target duration
|
||||
newData = {
|
||||
...oldData,
|
||||
dur: oldData.newDur,
|
||||
newDur: undefined
|
||||
};
|
||||
delete newData.newDur;
|
||||
break;
|
||||
|
||||
default:
|
||||
newData = oldData;
|
||||
break;
|
||||
|
|
@ -1266,11 +1327,11 @@ class TimerFileManager {
|
|||
this.settings.timerInsertLocation
|
||||
);
|
||||
if (position.before === 0) {
|
||||
lines[lineNum] = newSpan + ' ' + line;
|
||||
lines[lineNum] = newSpan + line;
|
||||
} else if (position.before === line.length) {
|
||||
lines[lineNum] = line + ' ' + newSpan;
|
||||
lines[lineNum] = line + newSpan;
|
||||
} else {
|
||||
lines[lineNum] = line.substring(0, position.before) + ' ' + newSpan + ' ' + line.substring(position.before);
|
||||
lines[lineNum] = line.substring(0, position.before) + newSpan + line.substring(position.before);
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
|
|
@ -1296,8 +1357,6 @@ class TimerFileManager {
|
|||
|
||||
// 3. Determine insertion position
|
||||
let before, after;
|
||||
let needsSpacing = false;
|
||||
|
||||
if (parsedResult && parsedResult.timerId === timerId) {
|
||||
// Update existing timer
|
||||
before = parsedResult.beforeIndex;
|
||||
|
|
@ -1311,11 +1370,10 @@ class TimerFileManager {
|
|||
);
|
||||
before = position.before;
|
||||
after = position.after;
|
||||
needsSpacing = true; // New timer needs spacing
|
||||
}
|
||||
|
||||
// 4. Add spacing for new timers
|
||||
const finalSpan = needsSpacing ? ' ' + newSpan + ' ' : newSpan;
|
||||
// 4. Prepare final span (no extra spacing)
|
||||
const finalSpan = newSpan;
|
||||
|
||||
// 5. Execute write
|
||||
if (editor.cm && editor.cm.dispatch) {
|
||||
|
|
@ -1709,6 +1767,34 @@ class TimerPlugin extends obsidian.Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// Register "Time Adjustment" command
|
||||
this.addCommand({
|
||||
id: 'time-adjustment',
|
||||
icon: 'clock',
|
||||
name: getTranslation('command_name').timeAdjust,
|
||||
editorCallback: (editor, view) => {
|
||||
const cursor = editor.getCursor();
|
||||
const lineNum = cursor.line;
|
||||
const lineText = editor.getLine(lineNum);
|
||||
const parsed = TimerParser.parse(lineText, 'auto');
|
||||
|
||||
if (parsed && parsed.class === 'timer-p') {
|
||||
const backfillLang = getTranslation('timeBackfill');
|
||||
const modal = new TimePickerModal(
|
||||
this.app,
|
||||
parsed.dur,
|
||||
backfillLang,
|
||||
(newDur) => this.handleSetDuration(view, lineNum, parsed, newDur)
|
||||
);
|
||||
modal.open();
|
||||
} else if (parsed) {
|
||||
new obsidian.Notice(getTranslation('timeBackfill').disabledTip);
|
||||
} else {
|
||||
new obsidian.Notice(getTranslation('timeBackfill').disabledTip);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Listen to file open event
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-open', this.onFileOpen.bind(this))
|
||||
|
|
@ -1852,6 +1938,9 @@ class TimerPlugin extends obsidian.Plugin {
|
|||
const parsed = TimerParser.parse(lineText, 'auto');
|
||||
const className = parsed ? parsed.class : null;
|
||||
|
||||
// --- Timer section separator ---
|
||||
menu.addSeparator();
|
||||
|
||||
if (className === 'timer-r') {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
|
|
@ -1879,11 +1968,56 @@ class TimerPlugin extends obsidian.Plugin {
|
|||
if (parsed && parsed.timerId) {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Delete timer')
|
||||
.setTitle(getTranslation('command_name').delete)
|
||||
.setIcon('trash')
|
||||
.onClick(() => this.handleDelete(view, lineNum, parsed))
|
||||
);
|
||||
}
|
||||
|
||||
// Add time backfill option
|
||||
const backfillLang = getTranslation('timeBackfill');
|
||||
const isPaused = className === 'timer-p';
|
||||
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle(backfillLang.menu)
|
||||
.setIcon('clock');
|
||||
if (isPaused) {
|
||||
item.onClick(() => {
|
||||
const modal = new TimePickerModal(
|
||||
this.app,
|
||||
parsed.dur,
|
||||
backfillLang,
|
||||
(newDur) => this.handleSetDuration(view, lineNum, parsed, newDur)
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
} else {
|
||||
item.setDisabled(true);
|
||||
item.dom.addClass('timer-menu-disabled');
|
||||
// Custom tooltip with 100ms delay
|
||||
let tipEl = null;
|
||||
let tipTimer = null;
|
||||
item.dom.addEventListener('mouseenter', () => {
|
||||
tipTimer = setTimeout(() => {
|
||||
tipEl = document.createElement('div');
|
||||
tipEl.className = 'timer-menu-tooltip';
|
||||
tipEl.textContent = backfillLang.disabledTip;
|
||||
document.body.appendChild(tipEl);
|
||||
const rect = item.dom.getBoundingClientRect();
|
||||
tipEl.style.top = `${rect.top - tipEl.offsetHeight - 4}px`;
|
||||
tipEl.style.left = `${rect.left + rect.width / 2 - tipEl.offsetWidth / 2}px`;
|
||||
}, 100);
|
||||
});
|
||||
item.dom.addEventListener('mouseleave', () => {
|
||||
clearTimeout(tipTimer);
|
||||
if (tipEl) {
|
||||
tipEl.remove();
|
||||
tipEl = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkPathRestriction() {
|
||||
|
|
@ -1968,8 +2102,27 @@ class TimerPlugin extends obsidian.Plugin {
|
|||
// 3. Remove from text (only in edit mode)
|
||||
if (view.getMode() === 'source') {
|
||||
const editor = view.editor;
|
||||
const lineText = editor.getLine(lineNum);
|
||||
let delFrom = parsedData.beforeIndex;
|
||||
let delTo = parsedData.afterIndex;
|
||||
|
||||
// Clean up adjacent spaces left from old spacing logic:
|
||||
// If both sides have a space, remove one of them to avoid double space
|
||||
const charBefore = delFrom > 0 ? lineText[delFrom - 1] : null;
|
||||
const charAfter = delTo < lineText.length ? lineText[delTo] : null;
|
||||
if (charBefore === ' ' && charAfter === ' ') {
|
||||
// Both sides have space, remove one extra space
|
||||
delTo += 1;
|
||||
} else if (charBefore === ' ' && delTo >= lineText.length) {
|
||||
// Space before and timer is at end of line, remove trailing space
|
||||
delFrom -= 1;
|
||||
} else if (delFrom === 0 && charAfter === ' ') {
|
||||
// Timer is at start of line and space after, remove leading space
|
||||
delTo += 1;
|
||||
}
|
||||
|
||||
editor.replaceRange(
|
||||
'', { line: lineNum, ch: parsedData.beforeIndex }, { line: lineNum, ch: parsedData.afterIndex }
|
||||
'', { line: lineNum, ch: delFrom }, { line: lineNum, ch: delTo }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1998,6 +2151,20 @@ class TimerPlugin extends obsidian.Plugin {
|
|||
this.fileManager.writeTimer(timerId, newData, view, view.file, lineNum, parsedData);
|
||||
}
|
||||
|
||||
handleSetDuration(view, lineNum, parsedData, newDur) {
|
||||
if (!parsedData || !parsedData.timerId) return;
|
||||
if (parsedData.class !== 'timer-p') return; // Only works on paused timers
|
||||
|
||||
const timerId = parsedData.timerId;
|
||||
const newData = TimerDataUpdater.calculate('setDuration', {
|
||||
...parsedData,
|
||||
newDur: newDur
|
||||
});
|
||||
|
||||
// Update file
|
||||
this.fileManager.writeTimer(timerId, newData, view, view.file, lineNum, parsedData);
|
||||
}
|
||||
|
||||
async onTick(timerId) {
|
||||
// 可见性检查:后台超过1分钟时跳过
|
||||
if (this.manager.shouldSkipTick()) {
|
||||
|
|
@ -2192,6 +2359,301 @@ class TimerPlugin extends obsidian.Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// —— Time Picker Modal - iPhone-style scroll wheel time picker —— //
|
||||
class TimePickerModal extends obsidian.Modal {
|
||||
constructor(app, currentDur, lang, onSubmit) {
|
||||
super(app);
|
||||
this.currentDur = currentDur || 0;
|
||||
this.lang = lang;
|
||||
this.onSubmit = onSubmit;
|
||||
this.ITEM_HEIGHT = 36;
|
||||
this.VISIBLE_COUNT = 5; // number of visible items in the wheel
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl, modalEl } = this;
|
||||
modalEl.addClass('timer-picker-modal');
|
||||
contentEl.empty();
|
||||
|
||||
// Parse current duration
|
||||
const curH = Math.min(999, Math.floor(this.currentDur / 3600));
|
||||
const curM = Math.floor((this.currentDur % 3600) / 60);
|
||||
const curS = this.currentDur % 60;
|
||||
|
||||
// Header bar: Title only (centered)
|
||||
const header = contentEl.createEl('div', { cls: 'timer-picker-header' });
|
||||
header.createEl('span', { text: this.lang.title, cls: 'timer-picker-title' });
|
||||
|
||||
// Wheel container
|
||||
const wheelContainer = contentEl.createEl('div', { cls: 'timer-picker-wheels' });
|
||||
|
||||
// Selection highlight band - position dynamically based on item height
|
||||
const highlightEl = wheelContainer.createEl('div', { cls: 'timer-picker-highlight' });
|
||||
const centerOffset = Math.floor(this.VISIBLE_COUNT / 2) * this.ITEM_HEIGHT;
|
||||
highlightEl.style.top = `${centerOffset - 1}px`;
|
||||
highlightEl.style.height = `${this.ITEM_HEIGHT + 2}px`;
|
||||
|
||||
// Create 3 wheels: hours (0-999), minutes (0-59 circular), seconds (0-59 circular)
|
||||
this.hourWheel = this._createWheel(wheelContainer, 1000, curH, null, false);
|
||||
|
||||
// Colon separator between hours and minutes
|
||||
const colon1 = wheelContainer.createEl('div', { cls: 'timer-picker-colon' });
|
||||
colon1.textContent = ':';
|
||||
colon1.style.top = `${centerOffset}px`;
|
||||
|
||||
this.minuteWheel = this._createWheel(wheelContainer, 60, curM, null, true);
|
||||
|
||||
// Colon separator between minutes and seconds
|
||||
const colon2 = wheelContainer.createEl('div', { cls: 'timer-picker-colon' });
|
||||
colon2.textContent = ':';
|
||||
colon2.style.top = `${centerOffset}px`;
|
||||
|
||||
this.secondWheel = this._createWheel(wheelContainer, 60, curS, null, true);
|
||||
|
||||
// Footer: Save button (flat green checkmark)
|
||||
const footer = contentEl.createEl('div', { cls: 'timer-picker-footer' });
|
||||
const saveBtn = footer.createEl('button', { text: '✓', cls: 'timer-picker-save-btn' });
|
||||
|
||||
// Events
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const h = this.hourWheel.getValue();
|
||||
const m = this.minuteWheel.getValue();
|
||||
const s = this.secondWheel.getValue();
|
||||
this.onSubmit(h * 3600 + m * 60 + s);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
_createWheel(parent, count, initialValue, label, circular) {
|
||||
const ITEM_HEIGHT = this.ITEM_HEIGHT;
|
||||
const VISIBLE = this.VISIBLE_COUNT;
|
||||
const PADDING = Math.floor(VISIBLE / 2); // items above/below center
|
||||
const isCircular = !!circular;
|
||||
|
||||
const col = parent.createEl('div', { cls: 'timer-picker-column' });
|
||||
|
||||
// Scroll wrapper
|
||||
const viewport = col.createEl('div', { cls: 'timer-picker-viewport' });
|
||||
viewport.style.height = `${ITEM_HEIGHT * VISIBLE}px`;
|
||||
|
||||
const list = viewport.createEl('div', { cls: 'timer-picker-list' });
|
||||
|
||||
// For circular mode, we create a large virtual list (count * REPEAT_COUNT)
|
||||
// so that the user can scroll continuously in both directions.
|
||||
const REPEAT_COUNT = isCircular ? 21 : 1; // odd number for symmetry
|
||||
const totalItems = count * REPEAT_COUNT;
|
||||
const midRepeatStart = isCircular ? Math.floor(REPEAT_COUNT / 2) * count : 0;
|
||||
|
||||
const items = [];
|
||||
if (isCircular) {
|
||||
// Circular: repeat values, with PADDING empty slots at top/bottom
|
||||
for (let i = -PADDING; i < totalItems + PADDING; i++) {
|
||||
const item = list.createEl('div', { cls: 'timer-picker-item' });
|
||||
item.style.height = `${ITEM_HEIGHT}px`;
|
||||
item.style.lineHeight = `${ITEM_HEIGHT}px`;
|
||||
if (i >= 0 && i < totalItems) {
|
||||
const val = i % count;
|
||||
item.textContent = String(val).padStart(2, '0');
|
||||
item.dataset.value = val;
|
||||
} else {
|
||||
item.textContent = '';
|
||||
item.dataset.value = '';
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
} else {
|
||||
// Non-circular (hours): simple list with padding
|
||||
for (let i = -PADDING; i < count + PADDING; i++) {
|
||||
const item = list.createEl('div', { cls: 'timer-picker-item' });
|
||||
item.style.height = `${ITEM_HEIGHT}px`;
|
||||
item.style.lineHeight = `${ITEM_HEIGHT}px`;
|
||||
if (i >= 0 && i < count) {
|
||||
item.textContent = String(i).padStart(2, '0');
|
||||
item.dataset.value = i;
|
||||
} else {
|
||||
item.textContent = '';
|
||||
item.dataset.value = '';
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
// For circular mode, start in the middle repeat set
|
||||
let currentIndex = isCircular ? (midRepeatStart + initialValue) : initialValue;
|
||||
let offset = -currentIndex * ITEM_HEIGHT;
|
||||
let startY = 0;
|
||||
let startOffset = 0;
|
||||
let isDragging = false;
|
||||
let lastY = 0;
|
||||
let velocity = 0;
|
||||
let lastTime = 0;
|
||||
let animFrame = null;
|
||||
|
||||
const setTransform = (y) => {
|
||||
list.style.transform = `translateY(${y}px)`;
|
||||
};
|
||||
|
||||
const maxIndex = isCircular ? (totalItems - 1) : (count - 1);
|
||||
|
||||
const snapTo = (index, animate) => {
|
||||
index = Math.max(0, Math.min(maxIndex, index));
|
||||
currentIndex = index;
|
||||
offset = -index * ITEM_HEIGHT;
|
||||
if (animate) {
|
||||
list.style.transition = 'transform 0.3s cubic-bezier(0.23, 1, 0.32, 1)';
|
||||
setTransform(offset);
|
||||
setTimeout(() => {
|
||||
list.style.transition = '';
|
||||
// After snap animation, silently re-center if circular
|
||||
if (isCircular) {
|
||||
const val = currentIndex % count;
|
||||
const recentered = midRepeatStart + val;
|
||||
if (Math.abs(currentIndex - recentered) > count) {
|
||||
currentIndex = recentered;
|
||||
offset = -currentIndex * ITEM_HEIGHT;
|
||||
setTransform(offset);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
setTransform(offset);
|
||||
}
|
||||
updateStyles();
|
||||
};
|
||||
|
||||
const updateStyles = () => {
|
||||
items.forEach((el, i) => {
|
||||
const realIndex = i - PADDING;
|
||||
const distance = Math.abs(realIndex - currentIndex);
|
||||
if (distance === 0) {
|
||||
el.style.opacity = '1';
|
||||
el.style.fontSize = '20px';
|
||||
el.style.fontWeight = '700';
|
||||
el.style.color = 'var(--text-normal)';
|
||||
} else if (distance === 1) {
|
||||
el.style.opacity = '0.6';
|
||||
el.style.fontSize = '16px';
|
||||
el.style.fontWeight = '400';
|
||||
el.style.color = 'var(--text-muted)';
|
||||
} else if (distance === 2) {
|
||||
el.style.opacity = '0.3';
|
||||
el.style.fontSize = '14px';
|
||||
el.style.fontWeight = '400';
|
||||
el.style.color = 'var(--text-faint)';
|
||||
} else {
|
||||
el.style.opacity = '0';
|
||||
el.style.fontSize = '12px';
|
||||
el.style.fontWeight = '400';
|
||||
el.style.color = 'var(--text-faint)';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Touch/mouse handlers
|
||||
const onStart = (y) => {
|
||||
if (animFrame) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
animFrame = null;
|
||||
}
|
||||
list.style.transition = '';
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
startOffset = offset;
|
||||
lastY = y;
|
||||
velocity = 0;
|
||||
lastTime = Date.now();
|
||||
};
|
||||
|
||||
const onMove = (y) => {
|
||||
if (!isDragging) return;
|
||||
const now = Date.now();
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0) {
|
||||
velocity = (y - lastY) / dt;
|
||||
}
|
||||
lastY = y;
|
||||
lastTime = now;
|
||||
offset = startOffset + (y - startY);
|
||||
setTransform(offset);
|
||||
|
||||
// Update live style
|
||||
const approxIndex = Math.round(-offset / ITEM_HEIGHT);
|
||||
const clampedIndex = Math.max(0, Math.min(maxIndex, approxIndex));
|
||||
if (clampedIndex !== currentIndex) {
|
||||
currentIndex = clampedIndex;
|
||||
updateStyles();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
// Momentum / inertia
|
||||
const momentumDistance = velocity * 150; // px
|
||||
const targetOffset = offset + momentumDistance;
|
||||
let targetIndex = Math.round(-targetOffset / ITEM_HEIGHT);
|
||||
targetIndex = Math.max(0, Math.min(maxIndex, targetIndex));
|
||||
snapTo(targetIndex, true);
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
viewport.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
onStart(e.clientY);
|
||||
const onMouseMove = (ev) => onMove(ev.clientY);
|
||||
const onMouseUp = () => {
|
||||
onEnd();
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
// Touch events (mobile support)
|
||||
viewport.addEventListener('touchstart', (e) => {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, { passive: true });
|
||||
viewport.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
viewport.addEventListener('touchend', () => onEnd());
|
||||
|
||||
// Click to select
|
||||
viewport.addEventListener('click', (e) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const clickY = e.clientY - rect.top;
|
||||
const clickedSlot = Math.floor(clickY / ITEM_HEIGHT); // 0-based slot in viewport
|
||||
const targetIndex = currentIndex + (clickedSlot - PADDING);
|
||||
if (targetIndex >= 0 && targetIndex <= maxIndex) {
|
||||
snapTo(targetIndex, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll wheel
|
||||
viewport.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const direction = e.deltaY > 0 ? 1 : -1;
|
||||
const targetIndex = Math.max(0, Math.min(maxIndex, currentIndex + direction));
|
||||
snapTo(targetIndex, true);
|
||||
}, { passive: false });
|
||||
|
||||
// Initial position
|
||||
snapTo(initialValue, false);
|
||||
|
||||
return {
|
||||
getValue: () => isCircular ? (currentIndex % count) : currentIndex
|
||||
};
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
class TimerSettingTab extends obsidian.PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
|
|
@ -2231,25 +2693,26 @@ class TimerSettingTab extends obsidian.PluginSettingTab {
|
|||
const lang = getTranslation('settings');
|
||||
|
||||
containerEl.createEl('h1', { text: lang.name });
|
||||
containerEl.createEl('h2', { text: lang.desc });
|
||||
containerEl.createEl('div', { text: '' });
|
||||
|
||||
const tutorialInfo = containerEl.createEl('div');
|
||||
tutorialInfo.addClass('tutorial-info');
|
||||
// Plugin introduction block
|
||||
const introBlock = containerEl.createEl('div', { cls: 'timer-plugin-intro' });
|
||||
introBlock.style.cssText = 'background: var(--background-secondary); border-radius: 8px; padding: 12px 16px; margin: 8px 0 16px 0; font-size: 13px; line-height: 1.8; color: var(--text-muted);';
|
||||
|
||||
const tutorialParagraph = tutorialInfo.createEl('p');
|
||||
tutorialParagraph.setText(lang.tutorial);
|
||||
|
||||
const tutorialLink = tutorialParagraph.createEl('a');
|
||||
const tutorialLine = introBlock.createEl('p');
|
||||
tutorialLine.style.cssText = 'margin: 0 0 4px 0;';
|
||||
tutorialLine.appendText(lang.tutorial);
|
||||
const tutorialLink = tutorialLine.createEl('a');
|
||||
tutorialLink.href = 'https://github.com/wth461694678/text-block-timer';
|
||||
tutorialLink.setText('GitHub');
|
||||
tutorialLink.target = '_blank';
|
||||
|
||||
containerEl.createEl('div', { text: '' });
|
||||
containerEl.createEl('p', { text: lang.askforvote });
|
||||
const voteLine = introBlock.createEl('p');
|
||||
voteLine.style.cssText = 'margin: 0 0 4px 0;';
|
||||
voteLine.setText(lang.askforvote);
|
||||
|
||||
containerEl.createEl('div', { text: '' });
|
||||
containerEl.createEl('p', { text: lang.issue });
|
||||
const issueLine = introBlock.createEl('p');
|
||||
issueLine.style.cssText = 'margin: 0;';
|
||||
issueLine.setText(lang.issue);
|
||||
|
||||
containerEl.createEl('h3', { text: lang.sections.basic.name });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "text-block-timer",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"minAppVersion": "1.8.0",
|
||||
"name": "Text Block Timer",
|
||||
"author": "frankthwang",
|
||||
|
|
|
|||
162
styles.css
162
styles.css
|
|
@ -164,4 +164,166 @@
|
|||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* ===== Time Picker Modal (iPhone-style scroll wheel) ===== */
|
||||
|
||||
.timer-picker-modal.modal {
|
||||
max-width: 380px;
|
||||
width: 90vw;
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
.timer-picker-modal .modal-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timer-picker-modal .modal-close-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timer-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 24px 16px;
|
||||
}
|
||||
|
||||
.timer-picker-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.timer-picker-wheels {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.timer-picker-highlight {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
border: 1.5px solid var(--background-modifier-border);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timer-picker-colon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
z-index: 1;
|
||||
user-select: none;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.timer-picker-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timer-picker-viewport {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timer-picker-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.timer-picker-list {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.timer-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.15s, font-size 0.15s;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.timer-picker-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timer-picker-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 24px 18px;
|
||||
}
|
||||
|
||||
.timer-picker-save-btn {
|
||||
background: transparent;
|
||||
color: #22c55e;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-picker-save-btn:hover {
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
|
||||
/* ===== Disabled Menu Item (Time Adjustment on running timers) ===== */
|
||||
|
||||
.timer-menu-disabled {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.timer-menu-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-on-accent);
|
||||
background: var(--background-modifier-message);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: timer-tooltip-fadein 0.1s ease;
|
||||
}
|
||||
|
||||
@keyframes timer-tooltip-fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue