mirror of
https://github.com/mars-marcelyn/Shuffle-Lab.git
synced 2026-07-22 07:44:45 +00:00
978 lines
36 KiB
JavaScript
978 lines
36 KiB
JavaScript
const { Plugin, PluginSettingTab, Setting, Modal, TFolder, TFile, Notice, MarkdownRenderer, Platform } = require('obsidian');
|
|
|
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp'];
|
|
|
|
const FALLBACK_LOCALE = {
|
|
"pluginName": "Shuffle Lab",
|
|
"commandName": "New session",
|
|
"noticeNoDirectory": "Please set your main directory in Shuffle Lab settings",
|
|
"noticeSelectTopic": "Please select a folder",
|
|
"noticeTopicNotFound": "Folder not found",
|
|
"noticeNoImages": "No image files found in the selected folder",
|
|
"noticeLogCleared": "Session log cleared",
|
|
"noticeNoLog": "No log file found",
|
|
"noticePromptDeleted": "Prompt file deleted",
|
|
"settingsHeading": "Shuffle Lab",
|
|
"mainDirectoryName": "Main directory",
|
|
"mainDirectoryDesc": "Root folder containing subfolders with reference images",
|
|
"folderPlaceholder": "\u2014 Select a folder \u2014",
|
|
"sessionHeading": "New session",
|
|
"noFoldersFound": "No folders found. Add subfolders inside your main directory.",
|
|
"folderName": "Folder",
|
|
"folderDesc": "Choose a folder for this session",
|
|
"numImagesName": "Number of images",
|
|
"numImagesDesc": "Images to include in the session pool",
|
|
"noImagesWarning": "This folder has no images",
|
|
"loopName": "Loop images",
|
|
"loopDesc": "Repeat shuffled images indefinitely until session ends or stops",
|
|
"durationName": "Duration per image",
|
|
"durationDesc": "How long each image is displayed",
|
|
"customDurationName": "Custom duration",
|
|
"customDurationDesc": "Enter duration in seconds",
|
|
"totalTimeName": "Total session time",
|
|
"totalTimeDesc": "Calculated from images \u00d7 duration",
|
|
"loggingName": "Session logging",
|
|
"loggingDesc": "Log shown images to prioritize unseen ones next session",
|
|
"clearLogName": "Clear session log",
|
|
"clearLogDesc": "Delete the log file for the selected topic",
|
|
"clearLogBtn": "Clear",
|
|
"promptFileName": "Prompt file",
|
|
"promptFileDesc": "Intro slide shown before the session starts",
|
|
"editBtn": "Edit",
|
|
"deleteBtn": "Delete",
|
|
"createBtn": "Create",
|
|
"startBtn": "Start Session",
|
|
"durationPresets": {
|
|
"30s": "30s",
|
|
"60s": "60s",
|
|
"2min": "2 min",
|
|
"5min": "5 min",
|
|
"custom": "Custom"
|
|
},
|
|
"introStartBtn": "Start",
|
|
"endOfDeckTitle": "No more images left",
|
|
"endOfDeckHint": "Press \u25b6 to finish or \u25c0 to review",
|
|
"sessionCompleteTitle": "Session Complete",
|
|
"sessionCompleteHint": "Press \u25b6 or ESC to exit",
|
|
"failedToLoad": "(failed to load)",
|
|
"failedToLoadName": "Failed to load: {0}",
|
|
"sessionLogTitle": "# Session Log",
|
|
"sessionLogHeader": "Shown in last session:",
|
|
"promptTemplate": "# Insert your prompt title here\n\nWrite your prompt instructions here.\n\n---\n# Notes\n\nAnything you write below the divider won't show up in the intro slide\n",
|
|
"languageName": "Language",
|
|
"languageDesc": "Choose interface language",
|
|
"languageFilesName": "Language files",
|
|
"languageFilesDesc": "Add or edit translation files",
|
|
"openFolderBtn": "Open folder",
|
|
"noticeLanguageChanged": "Language changed. Reload Obsidian or the plugin for it to take effect."
|
|
};
|
|
|
|
const DEFAULT_SETTINGS = {
|
|
mainDirectory: '',
|
|
folder: '',
|
|
numPictures: 5,
|
|
durationPerImage: 30,
|
|
loop: true,
|
|
language: 'en',
|
|
};
|
|
|
|
module.exports = class VisualConceptLab extends Plugin {
|
|
async onload() {
|
|
await this.loadSettings();
|
|
await this.loadLocale();
|
|
await this.cacheAvailableLanguages();
|
|
this.addSettingTab(new DrawingSessionSettingTab(this.app, this));
|
|
this.addRibbonIcon('shuffle', this.t('pluginName'), () => {
|
|
this.openSetupModal();
|
|
});
|
|
this.addCommand({
|
|
id: 'open-session-setup',
|
|
name: this.t('commandName'),
|
|
callback: () => this.openSetupModal(),
|
|
});
|
|
|
|
this.app.vault.on('rename', (file, oldPath) => {
|
|
if (!(file instanceof TFolder)) return;
|
|
if (this.settings.folder === oldPath) {
|
|
this.settings.folder = file.path;
|
|
this.saveSettings();
|
|
}
|
|
});
|
|
}
|
|
|
|
openSetupModal() {
|
|
if (!this.settings.mainDirectory) {
|
|
new Notice(this.t('noticeNoDirectory'));
|
|
return;
|
|
}
|
|
new SessionSetupModal(this.app, this).open();
|
|
}
|
|
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
async loadLocale() {
|
|
const lang = this.settings.language || 'en';
|
|
try {
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const basePath = this.app.vault.adapter.getBasePath();
|
|
const filePath = path.join(basePath, this.app.vault.configDir, 'plugins', 'shuffle-lab', 'lang', lang + '.json');
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
this.locale = JSON.parse(content);
|
|
return;
|
|
} catch (e) {}
|
|
|
|
this.locale = Object.assign({}, FALLBACK_LOCALE);
|
|
}
|
|
|
|
async cacheAvailableLanguages() {
|
|
try {
|
|
const adapter = this.app.vault.adapter;
|
|
const langDir = this.app.vault.configDir + '/plugins/shuffle-lab/lang';
|
|
const result = await adapter.list(langDir);
|
|
const found = result.files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', '').split('/').pop());
|
|
if (found.length > 0) {
|
|
this._cachedLanguages = found.map(code => [code, code]);
|
|
this.settings.availableLanguages = found;
|
|
await this.saveSettings();
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
|
|
if (this.settings.availableLanguages) {
|
|
this._cachedLanguages = this.settings.availableLanguages.map(code => [code, code]);
|
|
} else {
|
|
this._cachedLanguages = [['en', 'English']];
|
|
}
|
|
}
|
|
|
|
getAvailableLanguages() {
|
|
return this._cachedLanguages || [['en', 'English']];
|
|
}
|
|
|
|
openLanguageFolder() {
|
|
if (!Platform.isDesktop) {
|
|
new Notice('Cannot open folder on this device');
|
|
return;
|
|
}
|
|
try {
|
|
const path = require('path');
|
|
const { exec } = require('child_process');
|
|
const basePath = this.app.vault.adapter.getBasePath();
|
|
const langDir = path.join(basePath, this.app.vault.configDir, 'plugins', 'shuffle-lab', 'lang');
|
|
exec(`explorer.exe "${langDir}"`);
|
|
} catch (e) {
|
|
new Notice('Cannot open folder on this device');
|
|
}
|
|
}
|
|
|
|
t(key, ...args) {
|
|
const parts = key.split('.');
|
|
let value = this.locale;
|
|
for (const part of parts) {
|
|
if (value && typeof value === 'object' && part in value) {
|
|
value = value[part];
|
|
} else {
|
|
return key;
|
|
}
|
|
}
|
|
if (typeof value === 'string') {
|
|
if (args.length > 0) {
|
|
return value.replace(/{(\d+)}/g, (match, num) => args[num] !== undefined ? args[num] : match);
|
|
}
|
|
return value;
|
|
}
|
|
return key;
|
|
}
|
|
};
|
|
|
|
class DrawingSessionSettingTab extends PluginSettingTab {
|
|
constructor(app, plugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
display() {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
|
|
new Setting(containerEl)
|
|
.setName(this.plugin.t('mainDirectoryName'))
|
|
.setDesc(this.plugin.t('mainDirectoryDesc'))
|
|
.addDropdown(dropdown => {
|
|
const folders = this.getFolders();
|
|
folders.forEach(([value, label]) => dropdown.addOption(value, label));
|
|
dropdown.setValue(this.plugin.settings.mainDirectory);
|
|
dropdown.onChange(async value => {
|
|
this.plugin.settings.mainDirectory = value;
|
|
await this.plugin.saveSettings();
|
|
});
|
|
});
|
|
|
|
if (Platform.isDesktop) {
|
|
const langSetting = new Setting(containerEl)
|
|
.setName(this.plugin.t('languageName'))
|
|
.setDesc(this.plugin.t('languageDesc'))
|
|
.addDropdown(dropdown => {
|
|
const langs = this.plugin.getAvailableLanguages();
|
|
langs.forEach(([value, label]) => dropdown.addOption(value, label));
|
|
dropdown.setValue(this.plugin.settings.language || 'en');
|
|
dropdown.onChange(async value => {
|
|
const changed = value !== this.plugin.settings.language;
|
|
this.plugin.settings.language = value;
|
|
await this.plugin.saveSettings();
|
|
langWarningEl.classList.toggle('shuffle-lab-is-hidden', !changed);
|
|
});
|
|
});
|
|
|
|
const langWarningEl = langSetting.descEl.createEl('div', {
|
|
text: this.plugin.t('noticeLanguageChanged'),
|
|
cls: 'shuffle-lab-lang-warning shuffle-lab-is-hidden'
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(this.plugin.t('languageFilesName'))
|
|
.setDesc(this.plugin.t('languageFilesDesc'))
|
|
.addButton(button => button
|
|
.setButtonText(this.plugin.t('openFolderBtn'))
|
|
.onClick(() => this.plugin.openLanguageFolder()));
|
|
}
|
|
|
|
}
|
|
|
|
getFolders() {
|
|
const folders = [['', this.plugin.t('folderPlaceholder')]];
|
|
this.app.vault.getAllLoadedFiles().forEach(file => {
|
|
if (file instanceof TFolder && file.path !== '/') {
|
|
folders.push([file.path, file.path]);
|
|
}
|
|
});
|
|
return folders.sort((a, b) => a[0].localeCompare(b[0]));
|
|
}
|
|
}
|
|
|
|
class SessionSetupModal extends Modal {
|
|
constructor(app, plugin) {
|
|
super(app);
|
|
this.plugin = plugin;
|
|
this.selectedFolder = plugin.settings.folder || null;
|
|
this.numPictures = plugin.settings.numPictures;
|
|
this.durationPerImage = plugin.settings.durationPerImage;
|
|
this.loop = plugin.settings.loop;
|
|
this.enableLogging = true;
|
|
this.sliderComponent = null;
|
|
}
|
|
|
|
onOpen() {
|
|
this.render();
|
|
}
|
|
|
|
render() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.addClass('session-setup-modal');
|
|
|
|
contentEl.createEl('h2', { text: this.plugin.t('sessionHeading') });
|
|
|
|
const folders = this.getFolders();
|
|
|
|
if (folders.length === 0) {
|
|
contentEl.createEl('p', {
|
|
text: this.plugin.t('noFoldersFound'),
|
|
cls: 'setting-item-description',
|
|
});
|
|
return;
|
|
}
|
|
|
|
new Setting(contentEl)
|
|
.setName(this.plugin.t('folderName'))
|
|
.setDesc(this.plugin.t('folderDesc'))
|
|
.addDropdown(dropdown => {
|
|
folders.forEach(t => dropdown.addOption(t.path, t.name));
|
|
const current = this.selectedFolder && folders.some(t => t.path === this.selectedFolder)
|
|
? this.selectedFolder : folders[0].path;
|
|
this.selectedFolder = current;
|
|
dropdown.setValue(current);
|
|
dropdown.onChange(value => {
|
|
this.selectedFolder = value;
|
|
this.updateSliderMax();
|
|
this.updatePromptBtn();
|
|
});
|
|
});
|
|
|
|
new Setting(contentEl)
|
|
.setName(this.plugin.t('numImagesName'))
|
|
.setDesc(this.plugin.t('numImagesDesc'))
|
|
.addSlider(slider => {
|
|
this.sliderComponent = slider;
|
|
const max = Math.max(1, this.countImagesInTopic(this.selectedFolder));
|
|
slider.setLimits(1, max, 1);
|
|
if (this.numPictures > max) this.numPictures = max;
|
|
slider.setValue(this.numPictures);
|
|
slider.setDynamicTooltip();
|
|
slider.onChange(value => {
|
|
this.numPictures = value;
|
|
this.updateTotalTime();
|
|
});
|
|
});
|
|
|
|
this.warningEl = contentEl.createEl('div', {
|
|
cls: 'setting-item-description',
|
|
text: this.plugin.t('noImagesWarning')
|
|
});
|
|
this.warningEl.classList.add('shuffle-lab-warning');
|
|
this.warningEl.classList.toggle('shuffle-lab-is-hidden', this.countImagesInTopic(this.selectedFolder) !== 0);
|
|
|
|
new Setting(contentEl)
|
|
.setName(this.plugin.t('loopName'))
|
|
.setDesc(this.plugin.t('loopDesc'))
|
|
.addToggle(toggle => {
|
|
toggle.setValue(this.loop);
|
|
toggle.onChange(value => {
|
|
this.loop = value;
|
|
});
|
|
});
|
|
|
|
const DURATION_PRESETS = [
|
|
{ label: '30s', value: 30, localeKey: '30s' },
|
|
{ label: '60s', value: 60, localeKey: '60s' },
|
|
{ label: '2 min', value: 120, localeKey: '2min' },
|
|
{ label: '5 min', value: 300, localeKey: '5min' },
|
|
{ label: 'Custom', value: -1, localeKey: 'custom' },
|
|
];
|
|
|
|
const matchedPreset = DURATION_PRESETS.find(p => p.value === this.durationPerImage);
|
|
|
|
new Setting(contentEl)
|
|
.setName(this.plugin.t('durationName'))
|
|
.setDesc(this.plugin.t('durationDesc'))
|
|
.addDropdown(dropdown => {
|
|
DURATION_PRESETS.forEach(p => dropdown.addOption(String(p.value), this.plugin.t('durationPresets.' + p.localeKey)));
|
|
dropdown.setValue(matchedPreset ? String(matchedPreset.value) : '-1');
|
|
dropdown.onChange(value => {
|
|
const num = parseInt(value);
|
|
if (num === -1) {
|
|
customDurationSetting.classList.remove('shuffle-lab-is-hidden');
|
|
} else {
|
|
this.durationPerImage = num;
|
|
customDurationSetting.classList.add('shuffle-lab-is-hidden');
|
|
}
|
|
});
|
|
});
|
|
|
|
const customDurationSetting = new Setting(contentEl)
|
|
.setName(this.plugin.t('customDurationName'))
|
|
.setDesc(this.plugin.t('customDurationDesc'))
|
|
.addText(text => {
|
|
text.setValue(String(this.durationPerImage));
|
|
text.onChange(value => {
|
|
const num = parseInt(value);
|
|
if (!isNaN(num) && num > 0) {
|
|
this.durationPerImage = num;
|
|
this.updateTotalTime();
|
|
}
|
|
});
|
|
}).settingEl;
|
|
|
|
customDurationSetting.classList.toggle('shuffle-lab-is-hidden', !!matchedPreset);
|
|
|
|
this.totalTimeSetting = new Setting(contentEl)
|
|
.setName(this.plugin.t('totalTimeName'))
|
|
.setDesc(this.plugin.t('totalTimeDesc'));
|
|
this.totalTimeEl = this.totalTimeSetting.controlEl.createEl('span', { cls: 'drawing-session-total-time' });
|
|
this.updateTotalTime();
|
|
|
|
new Setting(contentEl)
|
|
.setName(this.plugin.t('loggingName'))
|
|
.setDesc(this.plugin.t('loggingDesc'))
|
|
.addToggle(toggle => {
|
|
toggle.setValue(this.enableLogging);
|
|
toggle.onChange(value => { this.enableLogging = value; });
|
|
});
|
|
|
|
new Setting(contentEl)
|
|
.setName(this.plugin.t('clearLogName'))
|
|
.setDesc(this.plugin.t('clearLogDesc'))
|
|
.addButton(button => button
|
|
.setButtonText(this.plugin.t('clearLogBtn'))
|
|
.onClick(() => this.clearLog()));
|
|
|
|
const promptSetting = new Setting(contentEl)
|
|
.setName(this.plugin.t('promptFileName'))
|
|
.setDesc(this.plugin.t('promptFileDesc'))
|
|
.addButton(button => {
|
|
this.promptBtn = button;
|
|
button.onClick(() => this.promptFileAction());
|
|
})
|
|
.addButton(button => {
|
|
this.deletePromptBtn = button;
|
|
button.setButtonText(this.plugin.t('deleteBtn'));
|
|
button.onClick(() => this.deletePromptFile());
|
|
});
|
|
this.updatePromptBtn();
|
|
|
|
new Setting(contentEl)
|
|
.addButton(button => button
|
|
.setButtonText(this.plugin.t('startBtn'))
|
|
.setCta()
|
|
.onClick(() => this.startSession()));
|
|
}
|
|
|
|
getFolders() {
|
|
const mainDir = this.app.vault.getAbstractFileByPath(this.plugin.settings.mainDirectory);
|
|
if (!(mainDir instanceof TFolder)) return [];
|
|
return mainDir.children.filter(child => child instanceof TFolder);
|
|
}
|
|
|
|
countImagesInTopic(folderPath) {
|
|
const folder = this.app.vault.getAbstractFileByPath(folderPath);
|
|
if (!(folder instanceof TFolder)) return 0;
|
|
let count = 0;
|
|
for (const child of folder.children) {
|
|
if (child instanceof TFolder) {
|
|
count += this.countImagesRecursive(child);
|
|
} else if (child instanceof TFile && IMAGE_EXTENSIONS.includes(child.extension.toLowerCase())) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
countImagesRecursive(folder) {
|
|
let count = 0;
|
|
for (const child of folder.children) {
|
|
if (child instanceof TFolder) {
|
|
count += this.countImagesRecursive(child);
|
|
} else if (child instanceof TFile && IMAGE_EXTENSIONS.includes(child.extension.toLowerCase())) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
updateSliderMax() {
|
|
if (!this.sliderComponent) return;
|
|
const count = this.countImagesInTopic(this.selectedFolder);
|
|
const max = Math.max(1, count);
|
|
this.sliderComponent.setLimits(1, max, 1);
|
|
if (this.numPictures > max) {
|
|
this.numPictures = max;
|
|
this.sliderComponent.setValue(max);
|
|
}
|
|
this.warningEl.classList.toggle('shuffle-lab-is-hidden', count !== 0);
|
|
this.updateTotalTime();
|
|
}
|
|
|
|
updateTotalTime() {
|
|
if (!this.totalTimeEl) return;
|
|
const totalSeconds = this.numPictures * this.durationPerImage;
|
|
this.totalTimeEl.textContent = formatTimeLong(totalSeconds);
|
|
}
|
|
|
|
startSession() {
|
|
if (!this.selectedFolder) {
|
|
new Notice(this.plugin.t('noticeSelectTopic'));
|
|
return;
|
|
}
|
|
this.plugin.settings.folder = this.selectedFolder;
|
|
this.plugin.settings.numPictures = this.numPictures;
|
|
this.plugin.settings.durationPerImage = this.durationPerImage;
|
|
this.plugin.settings.loop = this.loop;
|
|
this.plugin.saveSettings();
|
|
this.close();
|
|
new SlideshowModal(this.app, this.plugin, this.selectedFolder, this.numPictures, this.durationPerImage, this.loop, this.enableLogging).open();
|
|
}
|
|
|
|
async clearLog() {
|
|
if (!this.selectedFolder) return;
|
|
const filePath = this.selectedFolder + '/_session_log.md';
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (file instanceof TFile) {
|
|
await this.app.vault.delete(file);
|
|
new Notice(this.plugin.t('noticeLogCleared'));
|
|
} else {
|
|
new Notice(this.plugin.t('noticeNoLog'));
|
|
}
|
|
}
|
|
|
|
updatePromptBtn() {
|
|
if (!this.promptBtn) return;
|
|
if (!this.selectedFolder) {
|
|
this.promptBtn.setButtonText('—');
|
|
this.promptBtn.setDisabled(true);
|
|
if (this.deletePromptBtn) this.deletePromptBtn.buttonEl.style.display = 'none';
|
|
return;
|
|
}
|
|
const filePath = this.selectedFolder + '/_prompt.md';
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (file instanceof TFile) {
|
|
this.promptBtn.setButtonText(this.plugin.t('editBtn'));
|
|
this.promptBtn.setDisabled(false);
|
|
if (this.deletePromptBtn) {
|
|
this.deletePromptBtn.setDisabled(false);
|
|
this.deletePromptBtn.setButtonText(this.plugin.t('deleteBtn'));
|
|
this.deletePromptBtn.buttonEl.style.display = '';
|
|
}
|
|
} else {
|
|
this.promptBtn.setButtonText(this.plugin.t('createBtn'));
|
|
this.promptBtn.setDisabled(false);
|
|
if (this.deletePromptBtn) this.deletePromptBtn.buttonEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async deletePromptFile() {
|
|
if (!this.selectedFolder) return;
|
|
const filePath = this.selectedFolder + '/_prompt.md';
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (file instanceof TFile) {
|
|
await this.app.vault.delete(file);
|
|
new Notice(this.plugin.t('noticePromptDeleted'));
|
|
this.updatePromptBtn();
|
|
}
|
|
}
|
|
|
|
async promptFileAction() {
|
|
if (!this.selectedFolder) return;
|
|
const filePath = this.selectedFolder + '/_prompt.md';
|
|
let file = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (!(file instanceof TFile)) {
|
|
const template = this.plugin.t('promptTemplate');
|
|
file = await this.app.vault.create(filePath, template);
|
|
}
|
|
const leaf = this.app.workspace.getLeaf();
|
|
await leaf.openFile(file);
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
class SlideshowModal extends Modal {
|
|
constructor(app, plugin, sessionFolder, numPictures, durationPerImage, loop, enableLogging) {
|
|
super(app);
|
|
this.plugin = plugin;
|
|
this.sessionFolder = sessionFolder;
|
|
this.numPictures = numPictures;
|
|
this.durationPerImage = durationPerImage;
|
|
this.loop = loop;
|
|
this.enableLogging = enableLogging;
|
|
this.imageFiles = [];
|
|
this.currentIndex = 0;
|
|
this.sessionTimeRemaining = numPictures * durationPerImage;
|
|
this.imageTimeRemaining = durationPerImage;
|
|
this.isPaused = false;
|
|
this.sessionEnded = false;
|
|
this.showingEndOfDeck = false;
|
|
this.shownImageNames = [];
|
|
this.sessionInterval = null;
|
|
this.imageInterval = null;
|
|
|
|
this.scope.register([], 'Escape', (e) => { this.stopSession(); return false; });
|
|
}
|
|
|
|
async onOpen() {
|
|
const folder = this.app.vault.getAbstractFileByPath(this.sessionFolder);
|
|
if (!(folder instanceof TFolder)) {
|
|
new Notice(this.plugin.t('noticeTopicNotFound'));
|
|
this.close();
|
|
return;
|
|
}
|
|
|
|
const allImages = [];
|
|
this.collectImages(folder, allImages);
|
|
|
|
if (allImages.length === 0) {
|
|
new Notice(this.plugin.t('noticeNoImages'));
|
|
this.close();
|
|
return;
|
|
}
|
|
|
|
if (this.enableLogging) {
|
|
const log = await this.readSessionLog();
|
|
const newImages = shuffleArray(allImages.filter(f => !log.includes(f.name)));
|
|
const oldImages = shuffleArray(allImages.filter(f => log.includes(f.name)));
|
|
this.imageFiles = [...newImages.slice(0, this.numPictures)];
|
|
if (this.imageFiles.length < this.numPictures) {
|
|
this.imageFiles.push(...oldImages.slice(0, this.numPictures - this.imageFiles.length));
|
|
}
|
|
} else {
|
|
this.imageFiles = shuffleArray(pickRandom(allImages, this.numPictures));
|
|
}
|
|
|
|
this.renderUI();
|
|
|
|
try {
|
|
if (this.modalEl.requestFullscreen) {
|
|
await this.modalEl.requestFullscreen();
|
|
}
|
|
} catch (e) {
|
|
// Fullscreen not available; modal fills viewport via CSS
|
|
}
|
|
|
|
await this.showIntroSlide();
|
|
await this.showCountdown();
|
|
this.scope.register([], 'ArrowLeft', () => { this.prevImage(); return false; });
|
|
this.scope.register([], 'ArrowRight', () => { this.nextImage(); return false; });
|
|
await this.showImage(0);
|
|
this.startTimers();
|
|
|
|
this.updateTimeDisplay();
|
|
}
|
|
|
|
onClose() {
|
|
this.stopTimers();
|
|
if (this.enableLogging) {
|
|
this.writeSessionLog().catch(e => console.error('Failed to write session log:', e));
|
|
}
|
|
if (this.imageEl && this.imageEl.dataset.blobUrl) {
|
|
URL.revokeObjectURL(this.imageEl.dataset.blobUrl);
|
|
}
|
|
}
|
|
|
|
async showIntroSlide() {
|
|
const folder = this.app.vault.getAbstractFileByPath(this.sessionFolder);
|
|
if (!(folder instanceof TFolder)) return false;
|
|
const targetName = '_prompt';
|
|
const mdFile = folder.children.find(
|
|
child => child instanceof TFile && child.name === targetName + '.md'
|
|
);
|
|
if (!mdFile) return false;
|
|
|
|
const content = await this.app.vault.read(mdFile);
|
|
const slideContent = content.split('\n---\n')[0];
|
|
|
|
const introEl = this.contentEl.createDiv({ cls: 'drawing-session-intro' });
|
|
const introContent = introEl.createDiv({ cls: 'drawing-session-intro-content' });
|
|
await MarkdownRenderer.render(this.app, slideContent, introContent, this.sessionFolder, this);
|
|
|
|
const len = introContent.textContent.length;
|
|
let fontSize = '18px';
|
|
if (len < 50) fontSize = '36px';
|
|
else if (len < 150) fontSize = '28px';
|
|
else if (len < 400) fontSize = '22px';
|
|
introContent.style.fontSize = fontSize;
|
|
|
|
if (this.controlsEl) this.controlsEl.style.display = 'none';
|
|
if (this.timeDisplayEl) this.timeDisplayEl.style.display = 'none';
|
|
if (this.imageEl) this.imageEl.style.display = 'none';
|
|
|
|
const bottomBar = introEl.createDiv({ cls: 'drawing-session-intro-bottom' });
|
|
const startBtn = bottomBar.createEl('button', { cls: 'mod-cta', text: this.plugin.t('introStartBtn') });
|
|
|
|
return new Promise(resolve => {
|
|
const dismiss = () => {
|
|
if (this.controlsEl) this.controlsEl.style.display = '';
|
|
if (this.timeDisplayEl) this.timeDisplayEl.style.display = '';
|
|
if (this.imageEl) this.imageEl.style.display = '';
|
|
introEl.remove();
|
|
resolve(true);
|
|
};
|
|
startBtn.addEventListener('click', dismiss);
|
|
this.scope.register([], ' ', () => { dismiss(); return false; });
|
|
this.scope.register([], 'Enter', () => { dismiss(); return false; });
|
|
});
|
|
}
|
|
|
|
showCountdown() {
|
|
if (this.imageEl) this.imageEl.style.display = 'none';
|
|
const countdownEl = this.contentEl.createDiv({ cls: 'drawing-session-countdown' });
|
|
|
|
return new Promise(resolve => {
|
|
let count = 5;
|
|
countdownEl.textContent = String(count);
|
|
|
|
const interval = window.setInterval(() => {
|
|
count--;
|
|
if (count <= 0) {
|
|
clearInterval(interval);
|
|
countdownEl.style.opacity = '0';
|
|
setTimeout(() => {
|
|
countdownEl.remove();
|
|
resolve();
|
|
}, 300);
|
|
} else {
|
|
countdownEl.textContent = String(count);
|
|
}
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
collectImages(folder, result) {
|
|
for (const child of folder.children) {
|
|
if (child instanceof TFolder) {
|
|
this.collectImages(child, result);
|
|
} else if (child instanceof TFile && IMAGE_EXTENSIONS.includes(child.extension.toLowerCase())) {
|
|
result.push(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
async readSessionLog() {
|
|
const filePath = this.sessionFolder + '/_session_log.md';
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (file instanceof TFile) {
|
|
try {
|
|
const content = await this.app.vault.read(file);
|
|
const names = [];
|
|
for (const line of content.split('\n')) {
|
|
const m = line.match(/^- (.+)$/);
|
|
if (m) names.push(m[1]);
|
|
}
|
|
return names;
|
|
} catch (e) {}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async writeSessionLog() {
|
|
const lines = [this.plugin.t('sessionLogTitle'), '', this.plugin.t('sessionLogHeader')];
|
|
for (const name of this.shownImageNames) {
|
|
lines.push('- ' + name);
|
|
}
|
|
const content = lines.join('\n') + '\n';
|
|
const filePath = this.sessionFolder + '/_session_log.md';
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (file instanceof TFile) {
|
|
await this.app.vault.modify(file, content);
|
|
} else {
|
|
await this.app.vault.create(filePath, content);
|
|
}
|
|
}
|
|
|
|
renderUI() {
|
|
const { contentEl, modalEl } = this;
|
|
|
|
modalEl.addClass('shuffle-lab-modal');
|
|
|
|
contentEl.empty();
|
|
contentEl.classList.add('drawing-session-modal');
|
|
|
|
const topBar = contentEl.createDiv({ cls: 'drawing-session-topbar' });
|
|
this.timeDisplayEl = topBar.createSpan({ cls: 'drawing-session-time' });
|
|
this.counterDisplayEl = topBar.createSpan({ cls: 'drawing-session-counter' });
|
|
|
|
this.imageContainerEl = contentEl.createDiv({ cls: 'drawing-session-image-container' });
|
|
this.imageEl = this.imageContainerEl.createEl('img', { cls: 'drawing-session-image' });
|
|
this.imageEl.addEventListener('error', () => {
|
|
this.imageEl.alt = this.plugin.t('failedToLoad');
|
|
});
|
|
|
|
this.controlsEl = contentEl.createDiv({ cls: 'drawing-session-controls' });
|
|
const controls = this.controlsEl;
|
|
|
|
this.prevBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u25C0' });
|
|
this.prevBtn.addEventListener('click', () => this.prevImage());
|
|
|
|
this.pauseBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u23F8' });
|
|
this.pauseBtn.addEventListener('click', () => this.togglePause());
|
|
|
|
this.stopBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u23F9' });
|
|
this.stopBtn.addEventListener('click', () => this.stopSession());
|
|
|
|
this.nextBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u25B6' });
|
|
this.nextBtn.addEventListener('click', () => this.nextImage());
|
|
|
|
this.endOfDeckEl = contentEl.createDiv({ cls: 'drawing-session-endofdeck' });
|
|
this.endOfDeckEl.createEl('div', { cls: 'drawing-session-endofdeck-icon', text: '\u2713' });
|
|
this.endOfDeckEl.createEl('h2', { cls: 'drawing-session-endofdeck-title', text: this.plugin.t('endOfDeckTitle') });
|
|
this.endOfDeckEl.createEl('p', { cls: 'drawing-session-endofdeck-hint', text: this.plugin.t('endOfDeckHint') });
|
|
this.endOfDeckEl.style.display = 'none';
|
|
|
|
this.scope.register([], ' ', (e) => { e.preventDefault(); this.togglePause(); return false; });
|
|
}
|
|
|
|
async showImage(index) {
|
|
if (this.imageFiles.length === 0) return;
|
|
|
|
this.showingEndOfDeck = false;
|
|
this.endOfDeckEl.style.display = 'none';
|
|
this.imageContainerEl.style.display = '';
|
|
if (this.imageEl) this.imageEl.style.display = '';
|
|
|
|
this.currentIndex = index;
|
|
|
|
const file = this.imageFiles[this.currentIndex];
|
|
if (!this.shownImageNames.includes(file.name)) {
|
|
this.shownImageNames.push(file.name);
|
|
}
|
|
try {
|
|
const data = await this.app.vault.readBinary(file);
|
|
const mime = file.extension === 'svg' ? 'image/svg+xml' : 'image/' + file.extension;
|
|
const blob = new Blob([data], { type: mime });
|
|
const url = URL.createObjectURL(blob);
|
|
if (this.imageEl.dataset.blobUrl) {
|
|
URL.revokeObjectURL(this.imageEl.dataset.blobUrl);
|
|
}
|
|
this.imageEl.src = url;
|
|
this.imageEl.dataset.blobUrl = url;
|
|
this.imageEl.alt = file.name;
|
|
} catch (e) {
|
|
console.error('Failed to load image:', file.path, e);
|
|
this.imageEl.src = '';
|
|
this.imageEl.alt = this.plugin.t('failedToLoadName', file.name);
|
|
}
|
|
|
|
this.imageTimeRemaining = this.durationPerImage;
|
|
this.updateNavState();
|
|
this.updateTimeDisplay();
|
|
}
|
|
|
|
startTimers() {
|
|
this.sessionInterval = window.setInterval(() => {
|
|
if (this.isPaused) return;
|
|
this.sessionTimeRemaining--;
|
|
this.updateTimeDisplay();
|
|
if (this.sessionTimeRemaining <= 0) {
|
|
this.sessionComplete();
|
|
}
|
|
}, 1000);
|
|
|
|
this.imageInterval = window.setInterval(() => {
|
|
if (this.isPaused) return;
|
|
this.imageTimeRemaining--;
|
|
if (this.imageTimeRemaining <= 0) {
|
|
this.nextImage();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
updateNavState() {
|
|
if (this.sessionEnded) {
|
|
this.prevBtn.style.display = 'none';
|
|
this.nextBtn.style.display = '';
|
|
return;
|
|
}
|
|
if (this.showingEndOfDeck) {
|
|
this.prevBtn.style.display = '';
|
|
this.nextBtn.style.display = '';
|
|
return;
|
|
}
|
|
this.prevBtn.style.display = '';
|
|
this.nextBtn.style.display = '';
|
|
}
|
|
|
|
stopTimers() {
|
|
if (this.sessionInterval !== null) {
|
|
clearInterval(this.sessionInterval);
|
|
this.sessionInterval = null;
|
|
}
|
|
if (this.imageInterval !== null) {
|
|
clearInterval(this.imageInterval);
|
|
this.imageInterval = null;
|
|
}
|
|
}
|
|
|
|
showEndOfDeck() {
|
|
this.showingEndOfDeck = true;
|
|
this.endOfDeckEl.style.display = '';
|
|
this.imageTimeRemaining = this.durationPerImage;
|
|
this.updateNavState();
|
|
}
|
|
|
|
nextImage() {
|
|
if (this.sessionEnded) {
|
|
this.stopSession();
|
|
return;
|
|
}
|
|
if (this.showingEndOfDeck) {
|
|
this.sessionComplete();
|
|
return;
|
|
}
|
|
const next = this.currentIndex + 1;
|
|
if (next >= this.imageFiles.length) {
|
|
if (this.loop) {
|
|
this.imageFiles = shuffleArray(this.imageFiles);
|
|
this.showImage(0);
|
|
} else {
|
|
this.showEndOfDeck();
|
|
}
|
|
} else {
|
|
this.showImage(next);
|
|
}
|
|
}
|
|
|
|
prevImage() {
|
|
if (this.sessionEnded) return;
|
|
if (this.showingEndOfDeck) {
|
|
this.showImage(this.imageFiles.length - 1);
|
|
return;
|
|
}
|
|
const prev = this.currentIndex - 1;
|
|
if (prev >= 0) {
|
|
this.showImage(prev);
|
|
}
|
|
}
|
|
|
|
togglePause() {
|
|
this.isPaused = !this.isPaused;
|
|
this.pauseBtn.textContent = this.isPaused ? '\u25B6' : '\u23F8';
|
|
}
|
|
|
|
stopSession() {
|
|
this.stopTimers();
|
|
this.close();
|
|
}
|
|
|
|
sessionComplete() {
|
|
this.stopTimers();
|
|
this.isPaused = false;
|
|
this.sessionEnded = true;
|
|
this.showingEndOfDeck = false;
|
|
if (this.endOfDeckEl) this.endOfDeckEl.style.display = 'none';
|
|
if (this.pauseBtn) this.pauseBtn.style.display = 'none';
|
|
if (this.stopBtn) this.stopBtn.style.display = 'none';
|
|
this.updateNavState();
|
|
|
|
this.sessionTimeRemaining = 0;
|
|
this.updateTimeDisplay();
|
|
|
|
const endEl = this.contentEl.createDiv({ cls: 'drawing-session-end' });
|
|
endEl.createEl('div', { cls: 'drawing-session-end-icon', text: '\u2713' });
|
|
endEl.createEl('h2', { cls: 'drawing-session-end-title', text: this.plugin.t('sessionCompleteTitle') });
|
|
endEl.createEl('p', { cls: 'drawing-session-end-hint', text: this.plugin.t('sessionCompleteHint') });
|
|
}
|
|
|
|
updateTimeDisplay() {
|
|
if (this.timeDisplayEl) {
|
|
this.timeDisplayEl.textContent = '\u23F1 ' + formatTimeShort(this.sessionTimeRemaining);
|
|
}
|
|
if (this.counterDisplayEl) {
|
|
this.counterDisplayEl.textContent = (this.currentIndex + 1) + ' / ' + this.imageFiles.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
function shuffleArray(arr) {
|
|
const result = [...arr];
|
|
for (let i = result.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[result[i], result[j]] = [result[j], result[i]];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function pickRandom(arr, n) {
|
|
const shuffled = shuffleArray(arr);
|
|
return shuffled.slice(0, Math.min(n, shuffled.length));
|
|
}
|
|
|
|
function formatTimeShort(seconds) {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
|
}
|
|
|
|
function formatTimeLong(seconds) {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
const parts = [];
|
|
if (h > 0) parts.push(h + 'h');
|
|
if (m > 0 || h > 0) parts.push(m + 'm');
|
|
parts.push(s + 's');
|
|
return parts.join(' ');
|
|
}
|