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:
Carrianna 2026-02-13 18:59:58 +08:00
parent 9c34f8e82b
commit b729e2fe5b
7 changed files with 703 additions and 49 deletions

View file

@ -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
![pathControl_black](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_black.gif)
## 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

View file

@ -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 @@
![controlbycheckbox](https://github.com/wth461694678/text-block-timer/blob/main/demo/controlbycheckbox.gif)
## 自定义设置
用户可以在插件设置中自定义以下选项:
@ -82,18 +92,19 @@
在默认设置下,路径控制是关闭的。当你只希望在部分文件夹下使用该功能,你可以通过白名单或黑名单的方式进行控制,这两种方式只会生效其中一种。
- 不控制:所有文件夹下都可以使用该功能
![pathControl_no](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_no.gif)
![pathControl_no](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_no.gif)
- 白名单:只有在白名单中的文件夹下,才会生效
![pathControl_white](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_white.gif)
![pathControl_white](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_white.gif)
- 黑名单:在黑名单中的文件夹下,不会生效
![pathControl_black](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_black.gif)
![pathControl_black](https://github.com/wth461694678/text-block-timer/blob/main/demo/pathControl_black.gif)
## 版本日志:
- 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

View file

@ -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)"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

523
main.js
View file

@ -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 });

View file

@ -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",

View file

@ -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);
}
}