add context move

This commit is contained in:
Ben Floyd 2025-09-30 10:50:45 -06:00
parent d469bace0d
commit 4804d224c3
5 changed files with 455 additions and 0 deletions

View file

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

View file

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

View file

@ -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<void>;
constructor(
app: App,
streams: Stream[],
moveOptions: MoveTextOptions,
onMove: (options: {
stream: Stream;
date: string;
prepend: boolean;
addDivider: boolean;
text: string;
sourceEditor: any;
sourceView: any;
}) => Promise<void>
) {
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<void> {
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;
}

View file

@ -0,0 +1,3 @@
export { ContextMenuService } from './ContextMenuService';
export { MoveTextToStreamModal } from './MoveTextToStreamModal';
export type { MoveTextOptions } from './MoveTextToStreamModal';

View file

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