diff --git a/src/shared/service-loader.ts b/src/shared/service-loader.ts index a45799f..cdb919d 100644 --- a/src/shared/service-loader.ts +++ b/src/shared/service-loader.ts @@ -12,6 +12,7 @@ import { StreamManagementService } from '../slices/stream-management'; import { MobileIntegrationService } from '../slices/mobile-integration'; import { APIService } from '../slices/api'; import { CommandRegistrationService } from '../slices/command-registration'; +import { ContextMenuService } from '../slices/context-menu'; export class ServiceLoader { /** @@ -28,6 +29,7 @@ export class ServiceLoader { sliceContainer.register('ribbon-integration', new RibbonService()); sliceContainer.register('mobile-integration', new MobileIntegrationService()); sliceContainer.register('command-registration', new CommandRegistrationService()); + sliceContainer.register('context-menu', new ContextMenuService()); } /** diff --git a/src/slices/context-menu/ContextMenuService.ts b/src/slices/context-menu/ContextMenuService.ts new file mode 100644 index 0000000..cfa5ffa --- /dev/null +++ b/src/slices/context-menu/ContextMenuService.ts @@ -0,0 +1,164 @@ +import { PluginAwareSliceService } from '../../shared/base-slice'; +import { StreamManagementService } from '../stream-management/StreamManagementService'; +import { centralizedLogger } from '../../shared/centralized-logger'; +import { MoveTextToStreamModal, MoveTextOptions } from './MoveTextToStreamModal'; +import { MarkdownView, Notice, Menu, Editor, TFile, TAbstractFile } from 'obsidian'; +import { Stream } from '../../shared/types'; + +export class ContextMenuService extends PluginAwareSliceService { + private registeredEvents: Array<() => void> = []; + + async initialize(): Promise { + if (this.initialized) return; + + this.registerEditorContextMenu(); + this.registerFileContextMenu(); + this.initialized = true; + } + + cleanup(): void { + this.registeredEvents.forEach(unregister => unregister()); + this.registeredEvents = []; + this.initialized = false; + } + + private registerEditorContextMenu(): void { + const plugin = this.getPlugin(); + + const unregister = plugin.registerEvent( + plugin.app.workspace.on('editor-menu', (menu: Menu, editor: Editor) => { + const selectedText = editor.getSelection(); + + if (selectedText && this.hasStreams()) { + this.addMoveTextMenuItem(menu, selectedText, editor); + } + }) + ) as unknown as () => void; + + this.registeredEvents.push(unregister); + } + + private registerFileContextMenu(): void { + // File context menu functionality removed as requested + } + + + private addMoveTextMenuItem(menu: Menu, selectedText: string, editor: Editor): void { + menu.addItem((item) => { + item + .setTitle('Move selected text to stream') + .setIcon('arrow-right') + .onClick(async () => { + await this.showMoveTextModal(selectedText, editor); + }); + }); + } + + + private hasStreams(): boolean { + return this.getStreams().length > 0; + } + + private async showMoveTextModal(selectedText: string, editor: Editor): Promise { + const streams = this.getStreams(); + if (streams.length === 0) { + new Notice('No streams available'); + return; + } + + const moveOptions: MoveTextOptions = { + selectedText, + sourceEditor: editor, + sourceView: this.getPlugin().app.workspace.getActiveViewOfType(MarkdownView) + }; + + const modal = new MoveTextToStreamModal( + this.getPlugin().app, + streams, + moveOptions, + (options) => this.moveTextToStream(options) + ); + + modal.open(); + } + + private async moveTextToStream(options: { + stream: Stream; + date: string; + prepend: boolean; + addDivider: boolean; + text: string; + sourceEditor: Editor; + sourceView: MarkdownView | null; + }): Promise { + try { + const { stream, date, prepend, addDivider, text, sourceEditor } = options; + + const fileName = `${date}.md`; + const filePath = `${stream.folder}/${fileName}`; + + let targetFile = this.getPlugin().app.vault.getAbstractFileByPath(filePath); + let targetContent = ''; + + if (targetFile) { + targetContent = await this.getPlugin().app.vault.read(targetFile as TFile); + } else { + targetContent = `# ${date}\n\n`; + targetFile = await this.getPlugin().app.vault.create(filePath, targetContent); + } + + const textToAdd = this.prepareTextToAdd(text, addDivider, prepend); + const newContent = this.insertTextIntoContent(targetContent, textToAdd, prepend); + + await this.getPlugin().app.vault.modify(targetFile as TFile, newContent); + + if (sourceEditor.getSelection() === text) { + sourceEditor.replaceSelection(''); + } + + new Notice(`Text moved to ${stream.name} (${date})`); + + } catch (error) { + centralizedLogger.error('Error moving text to stream:', error); + throw error; + } + } + + private prepareTextToAdd(text: string, addDivider: boolean, prepend: boolean): string { + if (!addDivider) { + return `\n\n${text}\n`; + } + + return prepend + ? `\n\n${text}\n\n---\n` + : `\n\n---\n\n${text}\n`; + } + + private insertTextIntoContent(content: string, textToAdd: string, prepend: boolean): string { + if (!prepend) { + return content + textToAdd; + } + + const headingMatch = content.match(/^# .+$/m); + if (headingMatch) { + const insertIndex = headingMatch.index! + headingMatch[0].length; + return content.slice(0, insertIndex) + textToAdd + content.slice(insertIndex); + } + + return textToAdd + content; + } + + private getStreams(): Stream[] { + const plugin = this.getPlugin() as any; + return plugin.settings?.streams || []; + } + + private getStreamService(): StreamManagementService | null { + return this.getService('stream-management') as StreamManagementService | null; + } + + private getService(serviceName: string): unknown { + const container = (this.getPlugin() as any).sliceContainer; + return container?.get(serviceName); + } +} diff --git a/src/slices/context-menu/MoveTextToStreamModal.ts b/src/slices/context-menu/MoveTextToStreamModal.ts new file mode 100644 index 0000000..0a9cda6 --- /dev/null +++ b/src/slices/context-menu/MoveTextToStreamModal.ts @@ -0,0 +1,243 @@ +import { App, Modal, Setting, TFile, MarkdownView, Notice } from 'obsidian'; +import { Stream } from '../../shared/types'; +import { centralizedLogger } from '../../shared/centralized-logger'; + +export interface MoveTextOptions { + selectedText: string; + sourceEditor: any; + sourceView: any; +} + +export class MoveTextToStreamModal extends Modal { + private streams: Stream[]; + private selectedStream: Stream | null = null; + private useToday: boolean = true; + private selectedDate: string = new Date().toISOString().split('T')[0]; + private prependMode: boolean = false; // true = prepend, false = append + private addDivider: boolean = true; // true = add --- divider, false = no divider + private selectedText: string; + private sourceEditor: any; + private sourceView: any; + private onMove: (options: { + stream: Stream; + date: string; + prepend: boolean; + addDivider: boolean; + text: string; + sourceEditor: any; + sourceView: any; + }) => Promise; + + constructor( + app: App, + streams: Stream[], + moveOptions: MoveTextOptions, + onMove: (options: { + stream: Stream; + date: string; + prepend: boolean; + addDivider: boolean; + text: string; + sourceEditor: any; + sourceView: any; + }) => Promise + ) { + super(app); + this.streams = streams; + this.onMove = onMove; + this.selectedStream = streams.length > 0 ? streams[0] : null; + this.selectedText = moveOptions.selectedText; + this.sourceEditor = moveOptions.sourceEditor; + this.sourceView = moveOptions.sourceView; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + // Title + contentEl.createEl('h2', { text: 'Move Text to Stream' }); + + // Stream selection + new Setting(contentEl) + .setName('Target Stream') + .setDesc('Select the stream to move the text to') + .addDropdown(dropdown => { + this.streams.forEach(stream => { + dropdown.addOption(stream.id, stream.name); + }); + + if (this.selectedStream) { + dropdown.setValue(this.selectedStream.id); + } + + dropdown.onChange(value => { + this.selectedStream = this.streams.find(s => s.id === value) || null; + }); + }); + + // Date selection toggle + new Setting(contentEl) + .setName('Date Selection') + .setDesc('Choose when to add the text') + .addToggle(toggle => { + toggle + .setValue(this.useToday) + .onChange(value => { + this.useToday = value; + this.updateDateSetting(); + }); + }) + .addText(text => { + text + .setValue('Today') + .setDisabled(true); + }); + + // Custom date setting (stubbed for future calendar implementation) + this.createDateSetting(contentEl); + + // Prepend/Append toggle + new Setting(contentEl) + .setName('Text Position') + .setDesc('Choose where to place the text in the target file') + .addToggle(toggle => { + toggle + .setValue(this.prependMode) + .onChange(value => { + this.prependMode = value; + }); + }) + .addText(text => { + text + .setValue(this.prependMode ? 'Prepend (add to top)' : 'Append (add to bottom)') + .setDisabled(true); + }); + + // Divider toggle + new Setting(contentEl) + .setName('Add Divider') + .setDesc('Add --- separator around the moved text') + .addToggle(toggle => { + toggle + .setValue(this.addDivider) + .onChange(value => { + this.addDivider = value; + }); + }) + .addText(text => { + text + .setValue(this.addDivider ? 'Add --- separator' : 'No separator') + .setDisabled(true); + }); + + + // Action buttons + const buttonContainer = contentEl.createEl('div', { + cls: 'move-text-buttons' + }); + + const moveButton = buttonContainer.createEl('button', { + text: 'Move Text', + cls: 'mod-cta' + }); + + const cancelButton = buttonContainer.createEl('button', { + text: 'Cancel', + cls: 'mod-secondary' + }); + + // Event handlers + moveButton.addEventListener('click', async () => { + await this.handleMove(); + }); + + cancelButton.addEventListener('click', () => { + this.close(); + }); + + // Update date setting initially + this.updateDateSetting(); + } + + private createDateSetting(container: HTMLElement): void { + this.dateSetting = new Setting(container) + .setName('Custom Date') + .setDesc('Select a specific date (future: calendar picker)') + .addText(text => { + text + .setValue(this.selectedDate) + .setPlaceholder('YYYY-MM-DD') + .onChange(value => { + this.selectedDate = value; + }); + }) + .addButton(button => { + button + .setButtonText('📅') + .setTooltip('Open calendar picker (coming soon)') + .onClick(() => { + // TODO: Implement calendar picker + this.showCalendarStub(); + }); + }); + + this.dateSetting.settingEl.style.display = this.useToday ? 'none' : 'block'; + } + + private updateDateSetting(): void { + if (this.dateSetting) { + this.dateSetting.settingEl.style.display = this.useToday ? 'none' : 'block'; + } + } + + private showCalendarStub(): void { + // TODO: Implement calendar picker + // For now, just show a notice + new Notice('Calendar picker coming soon!'); + } + + private getSelectedText(): string { + return this.selectedText; + } + + private async handleMove(): Promise { + if (!this.selectedStream) { + new Notice('Please select a stream'); + return; + } + + if (!this.useToday && !this.selectedDate) { + new Notice('Please select a date'); + return; + } + + try { + const targetDate = this.useToday ? + new Date().toISOString().split('T')[0] : + this.selectedDate; + + await this.onMove({ + stream: this.selectedStream, + date: targetDate, + prepend: this.prependMode, + addDivider: this.addDivider, + text: this.getSelectedText(), + sourceEditor: this.sourceEditor, + sourceView: this.sourceView + }); + + this.close(); + } catch (error) { + centralizedLogger.error('Error moving text to stream:', error); + new Notice('Failed to move text to stream'); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + + private dateSetting: Setting | null = null; +} diff --git a/src/slices/context-menu/index.ts b/src/slices/context-menu/index.ts new file mode 100644 index 0000000..ab077ba --- /dev/null +++ b/src/slices/context-menu/index.ts @@ -0,0 +1,3 @@ +export { ContextMenuService } from './ContextMenuService'; +export { MoveTextToStreamModal } from './MoveTextToStreamModal'; +export type { MoveTextOptions } from './MoveTextToStreamModal'; diff --git a/styles.css b/styles.css index 480aad4..ed72520 100644 --- a/styles.css +++ b/styles.css @@ -682,4 +682,47 @@ .is-phone .streams-create-file-date { font-size: 1.5em; +} + +/********************************************************* + * MOVE TEXT MODAL STYLES + *********************************************************/ + +.move-text-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--background-modifier-border); +} + +.move-text-buttons button { + min-width: 80px; + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.move-text-buttons button.mod-cta { + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; +} + +.move-text-buttons button.mod-cta:hover { + background: var(--interactive-accent-hover); +} + +.move-text-buttons button.mod-secondary { + background: var(--interactive-normal); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); +} + +.move-text-buttons button.mod-secondary:hover { + background: var(--interactive-hover); + border-color: var(--text-accent); } \ No newline at end of file