This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
'use strict';
|
||||
|
||||
const api = window.stepforge;
|
||||
const dialogs = window.StepForgeDialogs || {};
|
||||
|
||||
class StepForgeApp {
|
||||
constructor() {
|
||||
this.view = document.getElementById('view');
|
||||
this.topbarContext = document.getElementById('topbar-context');
|
||||
this.searchInput = document.getElementById('global-search');
|
||||
this.captureStatus = document.getElementById('capture-status');
|
||||
this.homeBtn = document.getElementById('btn-home');
|
||||
|
||||
this.state = {
|
||||
view: 'library',
|
||||
query: '',
|
||||
folderFilter: 'all',
|
||||
library: { guides: [], folders: [], guideFolders: {} },
|
||||
trash: [],
|
||||
settings: null,
|
||||
info: null,
|
||||
};
|
||||
this.editorMeta = null;
|
||||
this.libraryRenderToken = 0;
|
||||
|
||||
this.view.innerHTML = `
|
||||
<div id="library-host"></div>
|
||||
<div id="editor-host" class="hidden"></div>
|
||||
`;
|
||||
this.libraryHost = document.getElementById('library-host');
|
||||
this.editorHost = document.getElementById('editor-host');
|
||||
|
||||
this.editor = new GuideEditor({
|
||||
root: this.editorHost,
|
||||
onMetaChange: (meta) => this.onEditorMeta(meta),
|
||||
onToast: (msg, opts) => toast(msg, opts),
|
||||
onBack: async (reason) => {
|
||||
if (reason === 'new') {
|
||||
await this.createGuide();
|
||||
return;
|
||||
}
|
||||
await this.showLibrary();
|
||||
},
|
||||
});
|
||||
|
||||
this.searchInput.addEventListener('input', debounce(() => {
|
||||
this.state.query = this.searchInput.value.trim();
|
||||
if (this.state.view === 'library') this.renderLibrary();
|
||||
}, 80));
|
||||
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this.state.view === 'library') this.openQuickActions();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
this.searchInput.value = '';
|
||||
this.state.query = '';
|
||||
if (this.state.view === 'library') this.renderLibrary();
|
||||
}
|
||||
});
|
||||
|
||||
this.homeBtn.addEventListener('click', () => {
|
||||
if (this.state.view === 'editor') this.showLibrary();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.openQuickActions();
|
||||
}
|
||||
});
|
||||
|
||||
api.capture.onAdded((payload) => this.onCaptureAdded(payload));
|
||||
api.capture.onState((payload) => this.updateCaptureState(payload));
|
||||
}
|
||||
|
||||
async onCaptureAdded(payload) {
|
||||
if (!payload || !payload.guideId) return;
|
||||
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
|
||||
await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId);
|
||||
return;
|
||||
}
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.refreshData();
|
||||
this.updateCaptureState(await api.capture.state());
|
||||
this.renderLibrary();
|
||||
}
|
||||
|
||||
async refreshData() {
|
||||
const [info, settings, library, trash] = await Promise.all([
|
||||
api.app.info(),
|
||||
api.settings.all(),
|
||||
api.library.list(),
|
||||
api.library.trashList(),
|
||||
]);
|
||||
this.state.info = info;
|
||||
this.state.settings = settings;
|
||||
this.state.library = {
|
||||
guides: library.guides || [],
|
||||
folders: library.folders?.folders || [],
|
||||
guideFolders: library.folders?.guideFolders || {},
|
||||
};
|
||||
this.state.trash = trash;
|
||||
}
|
||||
|
||||
async refreshLibrary({ keepFilter = true } = {}) {
|
||||
const folderFilter = keepFilter ? this.state.folderFilter : 'all';
|
||||
await this.refreshData();
|
||||
if (!this.folderExists(folderFilter) && !['all', 'favorites', 'trash'].includes(folderFilter)) {
|
||||
this.state.folderFilter = 'all';
|
||||
}
|
||||
if (this.state.view === 'library') this.renderLibrary();
|
||||
else this.renderTopbar();
|
||||
}
|
||||
|
||||
folderExists(folderId) {
|
||||
return (this.state.library.folders || []).some((f) => f.id === folderId);
|
||||
}
|
||||
|
||||
setView(view) {
|
||||
this.state.view = view;
|
||||
this.libraryHost.classList.toggle('hidden', view !== 'library');
|
||||
this.editorHost.classList.toggle('hidden', view !== 'editor');
|
||||
this.searchInput.classList.toggle('hidden', view !== 'library');
|
||||
this.renderTopbar();
|
||||
}
|
||||
|
||||
async showLibrary(reason = null) {
|
||||
this.editor.setActive(false);
|
||||
this.setView('library');
|
||||
if (reason === 'new') {
|
||||
await this.createGuide();
|
||||
return;
|
||||
}
|
||||
this.renderLibrary();
|
||||
}
|
||||
|
||||
async openGuide(guideId, stepId = null) {
|
||||
this.setView('editor');
|
||||
this.editor.setActive(true);
|
||||
await this.editor.open(guideId, stepId);
|
||||
this.renderTopbar();
|
||||
}
|
||||
|
||||
onEditorMeta(meta) {
|
||||
this.editorMeta = meta;
|
||||
if (this.state.view === 'editor') this.renderTopbar();
|
||||
this.updateCaptureState(this.captureState || null);
|
||||
}
|
||||
|
||||
updateCaptureState(state) {
|
||||
this.captureState = state || { active: false };
|
||||
clearNode(this.captureStatus);
|
||||
if (!this.captureState.active) {
|
||||
this.captureStatus.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
this.captureStatus.classList.remove('hidden');
|
||||
const pauseBtn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
const action = this.captureState.paused ? 'resume' : 'pause';
|
||||
api.capture.session({ action, guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
|
||||
.then((next) => this.updateCaptureState(next));
|
||||
},
|
||||
}, this.captureState.paused ? 'Resume' : 'Pause');
|
||||
const finishBtn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => api.capture.session({ action: 'finish', guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
|
||||
.then((next) => this.updateCaptureState(next)),
|
||||
}, 'Finish');
|
||||
this.captureStatus.append(
|
||||
el('span', {}, `Capture ${this.captureState.count || 0}`),
|
||||
pauseBtn,
|
||||
finishBtn,
|
||||
);
|
||||
}
|
||||
|
||||
renderTopbar() {
|
||||
clearNode(this.topbarContext);
|
||||
if (this.state.view === 'library') {
|
||||
this.topbarContext.append(
|
||||
el('button', { type: 'button', onClick: () => this.createGuide() }, 'New'),
|
||||
el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import'),
|
||||
el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Linked'),
|
||||
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const guide = this.editorMeta?.guide;
|
||||
this.topbarContext.append(
|
||||
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'),
|
||||
el('button', { type: 'button', onClick: () => this.renameGuide() }, 'Rename'),
|
||||
el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'),
|
||||
el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, 'Export'),
|
||||
el('button', { type: 'button', onClick: () => this.editor.openLinkedGuide() }, guide && guide.linkedSource ? 'Linked' : 'Local'),
|
||||
el('button', { type: 'button', onClick: () => this.editor.openQuickActions() }, 'Quick'),
|
||||
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'),
|
||||
el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
async renderLibrary() {
|
||||
this.setView('library');
|
||||
this.editor.setActive(false);
|
||||
clearNode(this.libraryHost);
|
||||
const q = this.state.query.trim();
|
||||
const folderLabel = this.filterLabel();
|
||||
const body = el('div.library', {},
|
||||
el('aside.lib-side', {},
|
||||
el('h3', {}, 'Library'),
|
||||
this.libraryNavItem('all', 'All guides', this.state.library.guides.length),
|
||||
this.libraryNavItem('favorites', 'Favorites', this.state.library.guides.filter((g) => g.favorite).length),
|
||||
this.libraryNavItem('trash', 'Trash', this.state.trash.length),
|
||||
el('h3', {}, 'Folders'),
|
||||
...this.renderFolderItems(this.state.library.folders || [], null, 0),
|
||||
el('div', { style: { marginTop: '8px' } },
|
||||
el('button', { type: 'button', onClick: () => this.createFolder() }, 'Add folder'),
|
||||
),
|
||||
),
|
||||
el('main.lib-main', {},
|
||||
el('div.lib-actions', {},
|
||||
el('button.primary', { type: 'button', onClick: () => this.createGuide() }, 'New guide'),
|
||||
el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import archive'),
|
||||
el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Open linked'),
|
||||
el('button', { type: 'button', onClick: () => this.openQuickActions() }, 'Quick actions'),
|
||||
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'),
|
||||
),
|
||||
el('div.row', { style: { justifyContent: 'space-between', marginBottom: '14px' } },
|
||||
el('div', {},
|
||||
el('div', { style: { fontWeight: 650 } }, folderLabel),
|
||||
q ? el('div.muted', {}, `Search: ${q}`) : el('div.muted', {}, `${this.state.library.guides.length} guides`),
|
||||
),
|
||||
el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.version}` : ''),
|
||||
),
|
||||
this.domLibraryResults = el('div', {}),
|
||||
),
|
||||
);
|
||||
this.libraryHost.append(body);
|
||||
|
||||
if (q) {
|
||||
await this.renderSearchResults();
|
||||
} else if (this.state.folderFilter === 'trash') {
|
||||
this.renderTrashView();
|
||||
} else {
|
||||
this.renderGuideGrid();
|
||||
}
|
||||
this.renderTopbar();
|
||||
}
|
||||
|
||||
libraryNavItem(id, label, count) {
|
||||
const props = {
|
||||
className: `nav-item${this.state.folderFilter === id ? ' active' : ''}`,
|
||||
onClick: () => { this.state.folderFilter = id; this.renderLibrary(); },
|
||||
};
|
||||
if (!['all', 'favorites', 'trash'].includes(id)) {
|
||||
props.onContextMenu = (e) => this.folderContextMenu(e, id);
|
||||
}
|
||||
return el('div.nav-item', props,
|
||||
el('span', {}, label),
|
||||
el('span.count', {}, count));
|
||||
}
|
||||
|
||||
renderFolderItems(folders, parentId = null, depth = 0) {
|
||||
const out = [];
|
||||
const children = folders
|
||||
.filter((folder) => (folder.parentId || null) === parentId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const folder of children) {
|
||||
const count = Object.entries(this.state.library.guideFolders || {})
|
||||
.filter(([, fid]) => fid === folder.id).length;
|
||||
out.push(el('div.nav-item', {
|
||||
className: `nav-item${this.state.folderFilter === folder.id ? ' active' : ''}`,
|
||||
style: { paddingLeft: `${8 + depth * 12}px` },
|
||||
onClick: () => { this.state.folderFilter = folder.id; this.renderLibrary(); },
|
||||
onContextMenu: (e) => this.folderContextMenu(e, folder.id),
|
||||
},
|
||||
el('span', {}, folder.name),
|
||||
el('span.count', {}, count)));
|
||||
out.push(...this.renderFolderItems(folders, folder.id, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
folderContextMenu(event, folderId) {
|
||||
event.preventDefault();
|
||||
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
|
||||
if (!folder) return;
|
||||
contextMenu(event.clientX, event.clientY, [
|
||||
{ label: 'Rename folder', action: () => this.renameFolder(folderId) },
|
||||
{ label: 'Delete folder', danger: true, action: () => this.deleteFolder(folderId) },
|
||||
]);
|
||||
}
|
||||
|
||||
filterLabel() {
|
||||
if (this.state.folderFilter === 'all') return 'All guides';
|
||||
if (this.state.folderFilter === 'favorites') return 'Favorites';
|
||||
if (this.state.folderFilter === 'trash') return 'Trash';
|
||||
const folder = (this.state.library.folders || []).find((f) => f.id === this.state.folderFilter);
|
||||
return folder ? folder.name : 'All guides';
|
||||
}
|
||||
|
||||
scopeGuide(guide) {
|
||||
if (this.state.folderFilter === 'all') return true;
|
||||
if (this.state.folderFilter === 'favorites') return Boolean(guide.favorite);
|
||||
if (this.state.folderFilter === 'trash') return false;
|
||||
return (this.state.library.guideFolders || {})[guide.guideId] === this.state.folderFilter;
|
||||
}
|
||||
|
||||
async renderSearchResults() {
|
||||
const token = ++this.libraryRenderToken;
|
||||
const results = await api.search.query({ q: this.state.query });
|
||||
if (token !== this.libraryRenderToken) return;
|
||||
const guidesById = new Map(this.state.library.guides.map((g) => [g.guideId, g]));
|
||||
const filtered = results.filter((r) => {
|
||||
const guide = guidesById.get(r.guideId);
|
||||
if (!guide) return false;
|
||||
return this.scopeGuide(guide);
|
||||
});
|
||||
clearNode(this.domLibraryResults);
|
||||
if (!filtered.length) {
|
||||
this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Search'), 'No results for this query.'));
|
||||
return;
|
||||
}
|
||||
this.domLibraryResults.append(
|
||||
el('div.guide-grid', {},
|
||||
...filtered.map((result) => {
|
||||
const guide = guidesById.get(result.guideId);
|
||||
const isStep = Boolean(result.stepId);
|
||||
return this.resultCard(result, guide, isStep);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
renderGuideGrid() {
|
||||
const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide));
|
||||
clearNode(this.domLibraryResults);
|
||||
if (!guides.length) {
|
||||
this.domLibraryResults.append(
|
||||
el('div.empty-state', {},
|
||||
el('div.big', {}, '∅'),
|
||||
this.state.folderFilter === 'trash'
|
||||
? 'Trash is empty.'
|
||||
: 'No guides in this section yet.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.domLibraryResults.append(el('div.guide-grid', {}, ...guides.map((guide) => this.guideCard(guide))));
|
||||
}
|
||||
|
||||
renderTrashView() {
|
||||
clearNode(this.domLibraryResults);
|
||||
if (!this.state.trash.length) {
|
||||
this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Trash'), 'Nothing deleted yet.'));
|
||||
return;
|
||||
}
|
||||
const items = this.state.trash.map((name) => el('div.guide-card', {
|
||||
onContextMenu: (e) => {
|
||||
e.preventDefault();
|
||||
contextMenu(e.clientX, e.clientY, [
|
||||
{ label: 'Restore', action: () => this.restoreTrashItem(name) },
|
||||
{ label: 'Empty trash', danger: true, action: () => this.purgeTrashItem() },
|
||||
]);
|
||||
},
|
||||
},
|
||||
el('h4', {}, name),
|
||||
el('div.meta', {}, 'Deleted guide archive')));
|
||||
this.domLibraryResults.append(el('div.guide-grid', {}, ...items));
|
||||
}
|
||||
|
||||
guideCard(guide) {
|
||||
const folderId = (this.state.library.guideFolders || {})[guide.guideId] || null;
|
||||
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
|
||||
const badgeText = guide.linkedSource ? 'Linked' : guide.favorite ? 'Favorite' : 'Local';
|
||||
const card = el('div.guide-card', {
|
||||
onClick: () => this.openGuide(guide.guideId),
|
||||
onContextMenu: (e) => {
|
||||
e.preventDefault();
|
||||
this.guideContextMenu(e, guide);
|
||||
},
|
||||
},
|
||||
el('div.fav', {
|
||||
className: `fav${guide.favorite ? ' on' : ''}`,
|
||||
onClick: async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite });
|
||||
await this.refreshLibrary();
|
||||
},
|
||||
}, '★'),
|
||||
el('h4', {}, guide.title || 'Untitled guide'),
|
||||
el('div.meta', {},
|
||||
el('span.badge', {}, badgeText),
|
||||
el('span', {}, `${guide.stepCount || 0} steps`),
|
||||
folder ? el('span', {}, folder.name) : null,
|
||||
guide.locked ? el('span.badge', {}, 'Locked') : null,
|
||||
),
|
||||
el('div.muted', {}, fmtDate(guide.updatedAt)));
|
||||
return card;
|
||||
}
|
||||
|
||||
resultCard(result, guide, isStep) {
|
||||
return el('div.guide-card', {
|
||||
onClick: () => this.openGuide(result.guideId, result.stepId || null),
|
||||
},
|
||||
el('h4', {}, isStep ? `${guide.title} · ${result.title}` : result.title),
|
||||
el('div.meta', {},
|
||||
el('span.badge', {}, isStep ? 'Step' : 'Guide'),
|
||||
el('span', {}, guide.favorite ? 'Favorite' : 'Local'),
|
||||
),
|
||||
el('div.muted', {}, result.snippet || ''));
|
||||
}
|
||||
|
||||
guideContextMenu(event, guide) {
|
||||
const folderItems = (this.state.library.folders || []).map((folder) => ({
|
||||
label: `Move to ${folder.name}`,
|
||||
action: () => this.moveGuideToFolder(guide.guideId, folder.id),
|
||||
}));
|
||||
const moveItems = folderItems.length ? ['sep', ...folderItems] : [];
|
||||
contextMenu(event.clientX, event.clientY, [
|
||||
{ label: 'Open guide', action: () => this.openGuide(guide.guideId) },
|
||||
{ label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) },
|
||||
{ label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) },
|
||||
{ label: 'Export', action: () => this.openGuideExport(guide.guideId) },
|
||||
...moveItems,
|
||||
{ label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) },
|
||||
'sep',
|
||||
{ label: 'Delete guide', danger: true, action: () => this.deleteGuide(guide.guideId) },
|
||||
]);
|
||||
}
|
||||
|
||||
async createGuide() {
|
||||
const title = await dialogs.promptText({
|
||||
title: 'New Guide',
|
||||
label: 'Title',
|
||||
value: 'Untitled guide',
|
||||
placeholder: 'Untitled guide',
|
||||
});
|
||||
if (title == null) return;
|
||||
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
|
||||
await this.refreshLibrary();
|
||||
await this.openGuide(guide.guideId);
|
||||
}
|
||||
|
||||
async createFolder() {
|
||||
const name = await dialogs.promptText({ title: 'New folder', label: 'Folder name', value: '' });
|
||||
if (name == null || !name.trim()) return;
|
||||
await api.folders.create({ name: name.trim(), parentId: null });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async renameFolder(folderId) {
|
||||
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
|
||||
if (!folder) return;
|
||||
const name = await dialogs.promptText({ title: 'Rename folder', label: 'Folder name', value: folder.name });
|
||||
if (name == null || !name.trim()) return;
|
||||
await api.folders.rename({ folderId, name: name.trim() });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async deleteFolder(folderId) {
|
||||
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
|
||||
if (!folder) return;
|
||||
const ok = await confirmDialog(`Delete the folder “${folder.name}”? Guides stay in the library.`);
|
||||
if (!ok) return;
|
||||
await api.folders.delete({ folderId });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async moveGuideToFolder(guideId, folderId) {
|
||||
await api.folders.moveGuide({ guideId, folderId });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async toggleFavorite(guide) {
|
||||
await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async duplicateGuide(guideId) {
|
||||
await api.library.duplicate({ guideId });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async deleteGuide(guideId) {
|
||||
const guide = this.state.library.guides.find((g) => g.guideId === guideId);
|
||||
if (!guide) return;
|
||||
const ok = await confirmDialog(`Delete “${guide.title}”?`, { danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
await api.library.delete({ guideId });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async restoreTrashItem(name) {
|
||||
await api.library.trashRestore({ name });
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async purgeTrashItem() {
|
||||
const ok = await confirmDialog('Permanently empty the trash?', { danger: true, okLabel: 'Empty trash' });
|
||||
if (!ok) return;
|
||||
await api.library.trashPurge();
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async openGuideExport(guideId) {
|
||||
const previous = this.editor.guideId;
|
||||
await this.openGuide(guideId);
|
||||
await this.editor.openExportDialog();
|
||||
if (previous && previous !== guideId) {
|
||||
// keep the newly opened guide active
|
||||
}
|
||||
}
|
||||
|
||||
async renameGuide() {
|
||||
const guide = this.editorMeta?.guide;
|
||||
if (!guide) return;
|
||||
const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title });
|
||||
if (title == null || !title.trim()) return;
|
||||
guide.title = title.trim();
|
||||
await api.guide.save({ guide });
|
||||
await this.editor.reload(this.editor.selectedStepId);
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async importArchive(mode = 'copy') {
|
||||
const result = await api.archive.open({ mode });
|
||||
if (!result || !result.ok) return;
|
||||
await this.refreshLibrary();
|
||||
await this.openGuide(result.guide.guideId);
|
||||
}
|
||||
|
||||
async openSettings() {
|
||||
const settings = await api.settings.all();
|
||||
const placeholders = await api.settings.globalPlaceholders();
|
||||
await dialogs.showSettingsDialog({
|
||||
settings,
|
||||
placeholders,
|
||||
onSave: async (next) => {
|
||||
await api.settings.set({ keyPath: 'appearance', value: next.appearance });
|
||||
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
|
||||
await api.settings.set({ keyPath: 'capture', value: next.capture });
|
||||
await api.settings.set({ keyPath: 'editor', value: next.editor });
|
||||
await api.settings.set({ keyPath: 'exports', value: next.exports });
|
||||
await api.settings.set({ keyPath: 'backups', value: next.backups });
|
||||
await api.settings.setGlobalPlaceholders(next.placeholders || {});
|
||||
this.state.settings = await api.settings.all();
|
||||
},
|
||||
});
|
||||
await this.refreshData();
|
||||
this.renderTopbar();
|
||||
if (this.state.view === 'library') this.renderLibrary();
|
||||
}
|
||||
|
||||
async openQuickActions() {
|
||||
if (this.state.view === 'editor') {
|
||||
await this.editor.openQuickActions();
|
||||
return;
|
||||
}
|
||||
const commands = [
|
||||
{ kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.createGuide() },
|
||||
{ kind: 'cmd', label: 'Import archive', description: 'Open a .sfgz guide archive', action: () => this.importArchive('copy') },
|
||||
{ kind: 'cmd', label: 'Open linked archive', description: 'Import a linked guide from .sfgz', action: () => this.importArchive('linked') },
|
||||
{ kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() },
|
||||
{ kind: 'cmd', label: 'Refresh library', description: 'Reload guides and folders', action: () => this.refreshLibrary() },
|
||||
];
|
||||
await dialogs.showQuickActions({
|
||||
commands,
|
||||
searchFn: async (query) => {
|
||||
const results = await api.search.query({ q: query });
|
||||
return results.map((result) => ({
|
||||
kind: result.stepId ? 'step' : 'guide',
|
||||
label: result.stepId ? `${result.title}` : result.title,
|
||||
description: result.snippet || '',
|
||||
action: () => this.openGuide(result.guideId, result.stepId || null),
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.StepForgeApp = StepForgeApp;
|
||||
|
||||
function boot() {
|
||||
const app = new StepForgeApp();
|
||||
app.init();
|
||||
window.stepforgeApp = app;
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,502 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* AnnotationCanvas: renders a step's working image plus its normalized
|
||||
* annotation scene graph, and provides editing interactions (create, select,
|
||||
* move, resize, nudge, crop). Geometry rules mirror core/raster.js so the
|
||||
* editor shows what exports produce.
|
||||
*/
|
||||
|
||||
const DRAW_ORDER = { blur: 0, highlight: 1, magnify: 2, rect: 3, oval: 3, line: 3, arrow: 3, cursor: 4, number: 5, text: 6, tooltip: 7 };
|
||||
const POINT_TOOLS = new Set(['line', 'arrow']);
|
||||
const HANDLE_SIZE = 8;
|
||||
|
||||
class AnnotationCanvas {
|
||||
constructor(canvasEl, callbacks = {}) {
|
||||
this.canvas = canvasEl;
|
||||
this.ctx = canvasEl.getContext('2d');
|
||||
this.cb = callbacks; // { onChange, onSelect, onCrop, onRequestText }
|
||||
this.image = null; // HTMLImageElement
|
||||
this.imgW = 0;
|
||||
this.imgH = 0;
|
||||
this.annotations = [];
|
||||
this.tool = 'select';
|
||||
this.zoomMode = 'fit';
|
||||
this.scale = 1;
|
||||
this.selectedId = null;
|
||||
this.drag = null;
|
||||
this.cropRect = null;
|
||||
|
||||
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
|
||||
canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
|
||||
canvasEl.addEventListener('pointerup', (e) => this.onUp(e));
|
||||
canvasEl.addEventListener('dblclick', (e) => this.onDblClick(e));
|
||||
}
|
||||
|
||||
setImage(image, w, h) {
|
||||
this.image = image;
|
||||
this.imgW = w || 0;
|
||||
this.imgH = h || 0;
|
||||
this.cropRect = null;
|
||||
if (!image || !this.imgW || !this.imgH) {
|
||||
this.canvas.width = 1;
|
||||
this.canvas.height = 1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.applyZoom();
|
||||
}
|
||||
|
||||
setAnnotations(annotations) {
|
||||
this.annotations = annotations || [];
|
||||
if (!this.annotations.some((a) => a.id === this.selectedId)) this.selectedId = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setTool(tool) {
|
||||
this.tool = tool;
|
||||
this.cropRect = null;
|
||||
if (tool !== 'select') this.select(null);
|
||||
this.render();
|
||||
}
|
||||
|
||||
setZoom(mode) {
|
||||
this.zoomMode = mode;
|
||||
this.applyZoom();
|
||||
}
|
||||
|
||||
applyZoom() {
|
||||
if (!this.image) return;
|
||||
const wrap = this.canvas.parentElement;
|
||||
if (this.zoomMode === 'fit') {
|
||||
const availW = Math.max(100, wrap.clientWidth - 40);
|
||||
const availH = Math.max(100, wrap.clientHeight - 40);
|
||||
this.scale = Math.min(availW / this.imgW, availH / this.imgH, 1);
|
||||
} else {
|
||||
this.scale = Number(this.zoomMode) || 1;
|
||||
}
|
||||
this.canvas.width = Math.round(this.imgW * this.scale);
|
||||
this.canvas.height = Math.round(this.imgH * this.scale);
|
||||
this.render();
|
||||
}
|
||||
|
||||
select(id) {
|
||||
this.selectedId = id;
|
||||
if (this.cb.onSelect) this.cb.onSelect(this.annotations.find((a) => a.id === id) || null);
|
||||
this.render();
|
||||
}
|
||||
|
||||
selected() {
|
||||
return this.annotations.find((a) => a.id === this.selectedId) || null;
|
||||
}
|
||||
|
||||
changed() {
|
||||
if (this.cb.onChange) this.cb.onChange(this.annotations);
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ---- coordinate helpers ----
|
||||
toNorm(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
px(ann) {
|
||||
return {
|
||||
x: ann.x * this.canvas.width,
|
||||
y: ann.y * this.canvas.height,
|
||||
w: ann.w * this.canvas.width,
|
||||
h: ann.h * this.canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
render() {
|
||||
const { ctx, canvas } = this;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!this.image) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
|
||||
for (const ann of ordered) this.drawAnnotation(ann);
|
||||
|
||||
const sel = this.selected();
|
||||
if (sel) this.drawSelection(sel);
|
||||
if (this.cropRect) this.drawCropOverlay();
|
||||
}
|
||||
|
||||
strokePx(ann) {
|
||||
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000);
|
||||
}
|
||||
|
||||
fontPx(ann) {
|
||||
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height);
|
||||
}
|
||||
|
||||
drawAnnotation(ann) {
|
||||
const { ctx } = this;
|
||||
const { x, y, w, h } = this.px(ann);
|
||||
const style = ann.style || {};
|
||||
const stroke = style.stroke || '#E5484D';
|
||||
const fill = style.fill && style.fill !== 'transparent' ? style.fill : null;
|
||||
ctx.save();
|
||||
ctx.lineWidth = this.strokePx(ann);
|
||||
ctx.strokeStyle = stroke;
|
||||
|
||||
switch (ann.type) {
|
||||
case 'rect':
|
||||
if (fill) { ctx.fillStyle = fill; ctx.fillRect(x, y, w, h); }
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
break;
|
||||
case 'oval':
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'line':
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.stroke();
|
||||
break;
|
||||
case 'arrow': {
|
||||
const len = Math.hypot(w, h) || 1;
|
||||
const head = Math.min(len * 0.4, Math.max(10, ctx.lineWidth * 4));
|
||||
const ux = w / len, uy = h / len;
|
||||
const bx = x + w - ux * head, by = y + h - uy * head;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w, y + h);
|
||||
ctx.lineTo(bx - uy * head * 0.5, by + ux * head * 0.5);
|
||||
ctx.lineTo(bx + uy * head * 0.5, by - ux * head * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = stroke; ctx.fill();
|
||||
break;
|
||||
}
|
||||
case 'blur': {
|
||||
// preview: pixelate the region by down/up-scaling
|
||||
const f = Math.max(6, (ann.radius || 8));
|
||||
try {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
const tw = Math.max(1, Math.round(w / f)), th = Math.max(1, Math.round(h / f));
|
||||
const off = document.createElement('canvas');
|
||||
off.width = tw; off.height = th;
|
||||
off.getContext('2d').drawImage(this.canvas, x, y, w, h, 0, 0, tw, th);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(off, 0, 0, tw, th, x, y, w, h);
|
||||
} catch { /* region may be degenerate while dragging */ }
|
||||
break;
|
||||
}
|
||||
case 'highlight':
|
||||
ctx.fillStyle = 'rgba(255, 235, 59, 0.41)';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
break;
|
||||
case 'magnify': {
|
||||
const zoom = ann.zoom || 2;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
const sw = w / zoom, sh = h / zoom;
|
||||
ctx.drawImage(
|
||||
this.image,
|
||||
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale,
|
||||
sw / this.scale, sh / this.scale,
|
||||
x, y, w, h
|
||||
);
|
||||
ctx.restore();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
ctx.font = `600 ${this.fontPx(ann)}px system-ui, sans-serif`;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.textBaseline = 'top';
|
||||
let ty = y;
|
||||
for (const line of String(ann.text || 'Text').split('\n')) {
|
||||
ctx.fillText(line, x, ty);
|
||||
ty += this.fontPx(ann) * 1.25;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tooltip': {
|
||||
const bg = fill || '#1F2937';
|
||||
const ts = Math.max(6, Math.min(Math.abs(w), Math.abs(h)) * 0.25);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.beginPath();
|
||||
const r = 6;
|
||||
ctx.roundRect(x, y, w, h, r);
|
||||
ctx.fill();
|
||||
const tail = style.tail || 'bottom';
|
||||
ctx.beginPath();
|
||||
if (tail === 'bottom') { ctx.moveTo(x + w / 2 - ts, y + h); ctx.lineTo(x + w / 2 + ts, y + h); ctx.lineTo(x + w / 2, y + h + ts * 1.4); }
|
||||
if (tail === 'top') { ctx.moveTo(x + w / 2 - ts, y); ctx.lineTo(x + w / 2 + ts, y); ctx.lineTo(x + w / 2, y - ts * 1.4); }
|
||||
if (tail === 'left') { ctx.moveTo(x, y + h / 2 - ts); ctx.lineTo(x, y + h / 2 + ts); ctx.lineTo(x - ts * 1.4, y + h / 2); }
|
||||
if (tail === 'right') { ctx.moveTo(x + w, y + h / 2 - ts); ctx.lineTo(x + w, y + h / 2 + ts); ctx.lineTo(x + w + ts * 1.4, y + h / 2); }
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = style.textColor || '#fff';
|
||||
ctx.font = `600 ${this.fontPx(ann)}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(ann.text || '…'), x + w / 2, y + h / 2, Math.abs(w) - 8);
|
||||
break;
|
||||
}
|
||||
case 'number': {
|
||||
const rr = Math.max(8, Math.min(Math.abs(w), Math.abs(h)) / 2);
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + w / 2, y + h / 2, rr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = style.textColor || '#fff';
|
||||
ctx.font = `700 ${rr}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(ann.value ?? '?'), x + w / 2, y + h / 2 + 1);
|
||||
break;
|
||||
}
|
||||
case 'cursor': {
|
||||
const s = Math.max(12, Math.min(Math.abs(w), Math.abs(h)));
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#111827';
|
||||
ctx.lineWidth = Math.max(1, s / 12);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y); ctx.lineTo(x, y + s); ctx.lineTo(x + s * 0.28, y + s * 0.75);
|
||||
ctx.lineTo(x + s * 0.45, y + s * 1.05); ctx.lineTo(x + s * 0.58, y + s * 0.98);
|
||||
ctx.lineTo(x + s * 0.42, y + s * 0.68); ctx.lineTo(x + s * 0.72, y + s * 0.68);
|
||||
ctx.closePath();
|
||||
ctx.fill(); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawSelection(ann) {
|
||||
const { ctx } = this;
|
||||
const { x, y, w, h } = this.px(ann);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#2563eb';
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.strokeRect(Math.min(x, x + w) - 3, Math.min(y, y + h) - 3, Math.abs(w) + 6, Math.abs(h) + 6);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#2563eb';
|
||||
for (const hd of this.handles(ann)) {
|
||||
ctx.fillRect(hd.px - HANDLE_SIZE / 2, hd.py - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
handles(ann) {
|
||||
const { x, y, w, h } = this.px(ann);
|
||||
if (POINT_TOOLS.has(ann.type)) {
|
||||
return [
|
||||
{ id: 'p1', px: x, py: y },
|
||||
{ id: 'p2', px: x + w, py: y + h },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: 'nw', px: x, py: y }, { id: 'n', px: x + w / 2, py: y }, { id: 'ne', px: x + w, py: y },
|
||||
{ id: 'w', px: x, py: y + h / 2 }, { id: 'e', px: x + w, py: y + h / 2 },
|
||||
{ id: 'sw', px: x, py: y + h }, { id: 's', px: x + w / 2, py: y + h }, { id: 'se', px: x + w, py: y + h },
|
||||
];
|
||||
}
|
||||
|
||||
drawCropOverlay() {
|
||||
const { ctx, canvas } = this;
|
||||
const r = this.cropRect;
|
||||
const x = Math.min(r.x0, r.x1) * canvas.width;
|
||||
const y = Math.min(r.y0, r.y1) * canvas.height;
|
||||
const w = Math.abs(r.x1 - r.x0) * canvas.width;
|
||||
const h = Math.abs(r.y1 - r.y0) * canvas.height;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, canvas.width, canvas.height);
|
||||
ctx.rect(x, y, w, h);
|
||||
ctx.fill('evenodd');
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- interactions ----
|
||||
hitTest(pt) {
|
||||
// topmost first (reverse draw order)
|
||||
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[b.type] ?? 3) - (DRAW_ORDER[a.type] ?? 3));
|
||||
for (const ann of ordered) {
|
||||
const x0 = Math.min(ann.x, ann.x + ann.w) - 0.008;
|
||||
const y0 = Math.min(ann.y, ann.y + ann.h) - 0.008;
|
||||
const x1 = Math.max(ann.x, ann.x + ann.w) + 0.008;
|
||||
const y1 = Math.max(ann.y, ann.y + ann.h) + 0.008;
|
||||
if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) return ann;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleAt(e) {
|
||||
const sel = this.selected();
|
||||
if (!sel) return null;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
for (const hd of this.handles(sel)) {
|
||||
if (Math.abs(px - hd.px) <= HANDLE_SIZE && Math.abs(py - hd.py) <= HANDLE_SIZE) return hd.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
onDown(e) {
|
||||
if (!this.image) return;
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
const pt = this.toNorm(e);
|
||||
|
||||
if (this.tool === 'crop') {
|
||||
this.cropRect = { x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y };
|
||||
this.drag = { kind: 'crop' };
|
||||
return;
|
||||
}
|
||||
if (this.tool === 'select') {
|
||||
const handle = this.handleAt(e);
|
||||
if (handle) {
|
||||
this.drag = { kind: 'resize', handle, start: pt, orig: { ...this.selected() } };
|
||||
return;
|
||||
}
|
||||
const hit = this.hitTest(pt);
|
||||
this.select(hit ? hit.id : null);
|
||||
if (hit) this.drag = { kind: 'move', start: pt, orig: { ...hit } };
|
||||
return;
|
||||
}
|
||||
|
||||
// creation tools
|
||||
const ann = {
|
||||
id: `ann-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`,
|
||||
type: this.tool,
|
||||
x: pt.x, y: pt.y, w: 0, h: 0,
|
||||
text: this.tool === 'tooltip' ? 'Tooltip' : this.tool === 'text' ? 'Text' : '',
|
||||
style: this.cb.defaultStyle ? this.cb.defaultStyle(this.tool) : {},
|
||||
};
|
||||
if (this.tool === 'number') ann.value = this.cb.nextNumber ? this.cb.nextNumber() : 1;
|
||||
if (this.tool === 'magnify') ann.zoom = 2;
|
||||
if (this.tool === 'blur') ann.radius = 8;
|
||||
this.annotations.push(ann);
|
||||
this.selectedId = ann.id;
|
||||
this.drag = { kind: 'create', start: pt, ann };
|
||||
}
|
||||
|
||||
onMove(e) {
|
||||
if (!this.drag) return;
|
||||
const pt = this.toNorm(e);
|
||||
const d = this.drag;
|
||||
|
||||
if (d.kind === 'crop') {
|
||||
this.cropRect.x1 = pt.x;
|
||||
this.cropRect.y1 = pt.y;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'create') {
|
||||
d.ann.w = pt.x - d.start.x;
|
||||
d.ann.h = pt.y - d.start.y;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
const sel = this.selected();
|
||||
if (!sel) return;
|
||||
if (d.kind === 'move') {
|
||||
sel.x = d.orig.x + (pt.x - d.start.x);
|
||||
sel.y = d.orig.y + (pt.y - d.start.y);
|
||||
this.render();
|
||||
} else if (d.kind === 'resize') {
|
||||
this.resizeBy(sel, d, pt);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
resizeBy(ann, d, pt) {
|
||||
const dx = pt.x - d.start.x;
|
||||
const dy = pt.y - d.start.y;
|
||||
const o = d.orig;
|
||||
const h = d.handle;
|
||||
if (h === 'p1') { ann.x = o.x + dx; ann.y = o.y + dy; ann.w = o.w - dx; ann.h = o.h - dy; return; }
|
||||
if (h === 'p2') { ann.w = o.w + dx; ann.h = o.h + dy; return; }
|
||||
if (h.includes('w')) { ann.x = o.x + dx; ann.w = o.w - dx; }
|
||||
if (h.includes('e')) { ann.w = o.w + dx; }
|
||||
if (h.includes('n')) { ann.y = o.y + dy; ann.h = o.h - dy; }
|
||||
if (h.includes('s')) { ann.h = o.h + dy; }
|
||||
}
|
||||
|
||||
onUp(e) {
|
||||
const d = this.drag;
|
||||
this.drag = null;
|
||||
if (!d) return;
|
||||
if (d.kind === 'crop') {
|
||||
const r = this.cropRect;
|
||||
this.cropRect = null;
|
||||
const rect = {
|
||||
x: Math.min(r.x0, r.x1), y: Math.min(r.y0, r.y1),
|
||||
w: Math.abs(r.x1 - r.x0), h: Math.abs(r.y1 - r.y0),
|
||||
};
|
||||
this.render();
|
||||
if (rect.w > 0.02 && rect.h > 0.02 && this.cb.onCrop) this.cb.onCrop(rect);
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'create') {
|
||||
// degenerate drags get a sensible default size
|
||||
if (Math.abs(d.ann.w) < 0.01 && Math.abs(d.ann.h) < 0.01) {
|
||||
const defaults = { number: [0.05, 0.08], text: [0.2, 0.05], tooltip: [0.18, 0.07], cursor: [0.04, 0.06] };
|
||||
const [dw, dh] = defaults[d.ann.type] || [0.15, 0.1];
|
||||
d.ann.w = dw; d.ann.h = dh;
|
||||
}
|
||||
this.normalizeRect(d.ann);
|
||||
this.changed();
|
||||
this.select(d.ann.id);
|
||||
if ((d.ann.type === 'text' || d.ann.type === 'tooltip') && this.cb.onRequestText) {
|
||||
this.cb.onRequestText(d.ann);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'move' || d.kind === 'resize') {
|
||||
const sel = this.selected();
|
||||
if (sel) this.normalizeRect(sel);
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
|
||||
normalizeRect(ann) {
|
||||
if (POINT_TOOLS.has(ann.type)) return; // lines keep direction
|
||||
if (ann.w < 0) { ann.x += ann.w; ann.w = -ann.w; }
|
||||
if (ann.h < 0) { ann.y += ann.h; ann.h = -ann.h; }
|
||||
}
|
||||
|
||||
onDblClick(e) {
|
||||
const hit = this.hitTest(this.toNorm(e));
|
||||
if (hit && (hit.type === 'text' || hit.type === 'tooltip') && this.cb.onRequestText) {
|
||||
this.select(hit.id);
|
||||
this.cb.onRequestText(hit);
|
||||
}
|
||||
}
|
||||
|
||||
nudgeSelected(dx, dy) {
|
||||
const sel = this.selected();
|
||||
if (!sel) return false;
|
||||
sel.x += dx / this.canvas.width;
|
||||
sel.y += dy / this.canvas.height;
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
if (!this.selectedId) return false;
|
||||
this.annotations = this.annotations.filter((a) => a.id !== this.selectedId);
|
||||
this.select(null);
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
window.AnnotationCanvas = AnnotationCanvas;
|
||||
@@ -0,0 +1,440 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Small modal factories used by the renderer. They stay intentionally plain:
|
||||
* a modal title, a few form rows, and action buttons. No decorative clutter.
|
||||
*/
|
||||
|
||||
function labeledRow(labelText, control, { stacked = false } = {}) {
|
||||
return el(stacked ? 'div.form-row.stacked' : 'div.form-row', {},
|
||||
el('label', {}, labelText),
|
||||
control
|
||||
);
|
||||
}
|
||||
|
||||
function makeInput(value = '', type = 'text', attrs = {}) {
|
||||
return el('input', { type, value, ...attrs });
|
||||
}
|
||||
|
||||
function makeSelect(value, options) {
|
||||
return el('select', {},
|
||||
options.map((opt) => el('option', { value: opt.value, selected: opt.value === value }, opt.label))
|
||||
);
|
||||
}
|
||||
|
||||
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const field = multiline
|
||||
? el('textarea', { rows: 6, placeholder }, value)
|
||||
: el('input', { type: 'text', value, placeholder });
|
||||
|
||||
const { close } = openModal({
|
||||
title,
|
||||
body: labeledRow(label, field, { stacked: multiline }),
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(null); } }, 'Cancel'),
|
||||
el('button.primary', { onClick: () => { close(); resolve(field.value); } }, 'OK'),
|
||||
],
|
||||
onClose: () => resolve(null),
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (!multiline && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
resolve(field.value);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => field.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onClose } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const input = el('input', {
|
||||
type: 'search',
|
||||
value: query,
|
||||
placeholder: 'Search guides, steps, and commands',
|
||||
autocomplete: 'off',
|
||||
spellcheck: false,
|
||||
});
|
||||
const results = el('div.qa-results');
|
||||
const hint = el('div.muted', {}, 'Type to search, arrows to move, Enter to open.');
|
||||
let items = [];
|
||||
let active = 0;
|
||||
|
||||
function renderItems() {
|
||||
clearNode(results);
|
||||
if (!items.length) {
|
||||
results.append(el('div.muted', { style: { padding: '8px 2px' } }, 'No matches.'));
|
||||
return;
|
||||
}
|
||||
items.forEach((item, idx) => {
|
||||
results.append(el('div.qa-item', {
|
||||
className: `qa-item${idx === active ? ' active' : ''}`,
|
||||
onMouseenter: () => { active = idx; renderItems(); },
|
||||
onClick: () => choose(idx),
|
||||
},
|
||||
el('span.kind', {}, item.kind || 'cmd'),
|
||||
el('div', {},
|
||||
el('div', { style: { fontWeight: 600 } }, item.label),
|
||||
item.description ? el('div.snippet', {}, item.description) : null,
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
function choose(idx = active) {
|
||||
const item = items[idx];
|
||||
if (!item) return;
|
||||
close();
|
||||
if (item.action) item.action();
|
||||
if (onOpenItem) onOpenItem(item);
|
||||
resolve(item);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const q = input.value.trim();
|
||||
const commandMatches = commands.filter((cmd) => {
|
||||
if (!q) return true;
|
||||
const needle = q.toLowerCase();
|
||||
return `${cmd.label} ${cmd.description || ''}`.toLowerCase().includes(needle);
|
||||
}).map((cmd) => ({ ...cmd, kind: cmd.kind || 'cmd' }));
|
||||
const searchResults = q && searchFn ? await searchFn(q) : [];
|
||||
items = [...commandMatches, ...searchResults];
|
||||
if (active >= items.length) active = 0;
|
||||
renderItems();
|
||||
}
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Quick Actions',
|
||||
body: el('div.quick-actions', {},
|
||||
input,
|
||||
hint,
|
||||
results,
|
||||
),
|
||||
wide: true,
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(null); } }, 'Close'),
|
||||
],
|
||||
onClose: () => {
|
||||
if (onClose) onClose();
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
|
||||
const debounced = debounce(refresh, 60);
|
||||
input.addEventListener('input', debounced);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(items.length - 1, active + 1); renderItems(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); renderItems(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); choose(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); close(); resolve(null); }
|
||||
});
|
||||
|
||||
refresh();
|
||||
setTimeout(() => input.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function showSettingsDialog({
|
||||
settings,
|
||||
placeholders = {},
|
||||
onSave,
|
||||
} = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const form = el('form', { className: 'settings-form' });
|
||||
|
||||
const appearance = makeSelect(settings.appearance || 'system', [
|
||||
{ value: 'system', label: 'System' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]);
|
||||
const spellcheck = el('input', { type: 'checkbox', checked: Boolean(settings.spellcheck) });
|
||||
const delayMs = makeInput(settings.capture?.delayMs ?? 0, 'number', { min: 0, step: 50 });
|
||||
const captureMode = makeSelect(settings.capture?.mode || 'fullscreen', [
|
||||
{ value: 'fullscreen', label: 'Fullscreen' },
|
||||
{ value: 'window', label: 'Window' },
|
||||
{ value: 'region', label: 'Region' },
|
||||
]);
|
||||
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
|
||||
const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text');
|
||||
const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text');
|
||||
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
|
||||
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
|
||||
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
|
||||
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
|
||||
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
|
||||
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
|
||||
|
||||
const placeholderRows = el('div', { className: 'placeholder-rows' });
|
||||
const rows = [];
|
||||
const addPlaceholderRow = (key = '', value = '') => {
|
||||
const keyInput = makeInput(key);
|
||||
const valueInput = makeInput(value);
|
||||
const removeBtn = el('button.icon', {
|
||||
type: 'button',
|
||||
title: 'Remove placeholder',
|
||||
onClick: () => {
|
||||
row.remove();
|
||||
rows.splice(rows.indexOf(row), 1);
|
||||
},
|
||||
}, '−');
|
||||
const row = el('div.placeholder-row', {},
|
||||
keyInput,
|
||||
valueInput,
|
||||
removeBtn,
|
||||
);
|
||||
rows.push(row);
|
||||
placeholderRows.append(row);
|
||||
return row;
|
||||
};
|
||||
Object.entries(placeholders || {}).forEach(([k, v]) => addPlaceholderRow(k, v));
|
||||
|
||||
const addPlaceholderBtn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => addPlaceholderRow(),
|
||||
}, 'Add placeholder');
|
||||
|
||||
form.append(
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Appearance'),
|
||||
labeledRow('Theme', appearance),
|
||||
labeledRow('Spellcheck', spellcheck),
|
||||
labeledRow('Open folder after export', openFolder),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Capture'),
|
||||
labeledRow('Default mode', captureMode),
|
||||
labeledRow('Delay (ms)', delayMs),
|
||||
labeledRow('Click marker', clickMarker),
|
||||
labeledRow('Capture outside clicks', captureOutside),
|
||||
labeledRow('Confirm simple capture', confirmSimple),
|
||||
labeledRow('Capture hotkey', captureHotkey),
|
||||
labeledRow('Pause / resume hotkey', pauseHotkey),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Editor'),
|
||||
labeledRow('Focused view for new steps', focusedDefault),
|
||||
labeledRow('Preview step count', previewCount),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Backups'),
|
||||
labeledRow('Keep last snapshots', keepLast),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Global placeholders'),
|
||||
placeholderRows,
|
||||
el('div.row', { style: { justifyContent: 'flex-start' } }, addPlaceholderBtn),
|
||||
),
|
||||
);
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Settings',
|
||||
body: form,
|
||||
wide: true,
|
||||
footer: [
|
||||
el('button', { type: 'button', onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button.primary', {
|
||||
type: 'submit',
|
||||
onClick: async (e) => {
|
||||
e.preventDefault();
|
||||
const next = {
|
||||
appearance: appearance.value,
|
||||
spellcheck: spellcheck.checked,
|
||||
capture: {
|
||||
...settings.capture,
|
||||
delayMs: Number(delayMs.value || 0),
|
||||
mode: captureMode.value,
|
||||
clickMarker: clickMarker.checked,
|
||||
hotkeyCapture: captureHotkey.value.trim(),
|
||||
hotkeyPauseResume: pauseHotkey.value.trim(),
|
||||
captureOutsideClicks: captureOutside.checked,
|
||||
confirmSimpleCapture: confirmSimple.checked,
|
||||
},
|
||||
editor: {
|
||||
...settings.editor,
|
||||
focusedViewDefaultForNewSteps: focusedDefault.checked,
|
||||
},
|
||||
exports: {
|
||||
...settings.exports,
|
||||
previewStepCount: Number(previewCount.value || 3),
|
||||
openFolderAfterExport: openFolder.checked,
|
||||
},
|
||||
backups: {
|
||||
...settings.backups,
|
||||
keepLast: Number(keepLast.value || 0),
|
||||
},
|
||||
placeholders: rows.reduce((acc, row) => {
|
||||
const inputs = row.querySelectorAll('input');
|
||||
const key = inputs[0].value.trim();
|
||||
const value = inputs[1].value;
|
||||
if (key) acc[key] = value;
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
await onSave(next);
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
}, 'Save'),
|
||||
],
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => e.preventDefault());
|
||||
});
|
||||
}
|
||||
|
||||
function showExportDialog({
|
||||
formats,
|
||||
templatesByFormat = {},
|
||||
defaultFormat = 'pdf',
|
||||
defaultOutDir = '',
|
||||
onChooseDir,
|
||||
onExport,
|
||||
onPreview,
|
||||
} = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const formatOptions = (formats || []).map((f) => {
|
||||
if (typeof f === 'string') return { value: f, label: f };
|
||||
return { value: f.id || f.value || f.name, label: f.label || f.id || f.value || f.name };
|
||||
});
|
||||
const formatSelect = makeSelect(defaultFormat, formatOptions);
|
||||
const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]);
|
||||
const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' });
|
||||
const info = el('div.muted', {}, 'Templates are optional. If no template is selected, exporter defaults are used.');
|
||||
|
||||
function refreshTemplates() {
|
||||
const list = templatesByFormat[formatSelect.value] || [];
|
||||
clearNode(templateSelect);
|
||||
templateSelect.append(el('option', { value: '' }, 'Default template'));
|
||||
for (const name of list) templateSelect.append(el('option', { value: name }, name));
|
||||
}
|
||||
|
||||
formatSelect.addEventListener('change', refreshTemplates);
|
||||
refreshTemplates();
|
||||
|
||||
const body = el('div.export-dialog', {},
|
||||
labeledRow('Format', formatSelect),
|
||||
labeledRow('Template', templateSelect),
|
||||
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
|
||||
type: 'button',
|
||||
disabled: typeof onChooseDir !== 'function',
|
||||
onClick: async () => {
|
||||
if (typeof onChooseDir !== 'function') return;
|
||||
const chosen = await onChooseDir(formatSelect.value);
|
||||
if (chosen) outDirInput.value = chosen;
|
||||
},
|
||||
}, 'Choose…'))),
|
||||
info,
|
||||
);
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Export',
|
||||
body,
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button', {
|
||||
onClick: async () => {
|
||||
if (typeof onPreview !== 'function') return;
|
||||
const ok = await onPreview({
|
||||
format: formatSelect.value,
|
||||
templateName: templateSelect.value || null,
|
||||
outDir: outDirInput.value.trim() || null,
|
||||
});
|
||||
if (ok !== false) {
|
||||
close();
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
}, 'Preview'),
|
||||
el('button.primary', {
|
||||
onClick: async () => {
|
||||
if (typeof onExport !== 'function') return;
|
||||
const ok = await onExport({
|
||||
format: formatSelect.value,
|
||||
templateName: templateSelect.value || null,
|
||||
outDir: outDirInput.value.trim() || null,
|
||||
});
|
||||
if (ok !== false) {
|
||||
close();
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
}, 'Export'),
|
||||
],
|
||||
wide: true,
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const linked = guide.linkedSource || {};
|
||||
const conflict = lock && !lock.acquired;
|
||||
const conflictInfo = lock && lock.conflict ? lock.conflict : {};
|
||||
const lockInfo = conflict
|
||||
? `Locked by ${conflictInfo.user || 'another user'}@${conflictInfo.host || 'another host'}`
|
||||
: 'No active conflict';
|
||||
|
||||
const body = el('div', { className: 'linked-guide' },
|
||||
el('div', { className: 'card-list' },
|
||||
el('div.row', {}, el('span.muted', {}, 'Archive'), el('strong', {}, linked.path || 'Not linked')),
|
||||
el('div.row', {}, el('span.muted', {}, 'Opened'), el('span', {}, fmtDate(linked.openedAt) || 'Unknown')),
|
||||
el('div.row', {}, el('span.muted', {}, 'Last saved'), el('span', {}, fmtDate(linked.lastSavedAt) || 'Never')),
|
||||
el('div.row', {}, el('span.muted', {}, 'Lock'), el('span', {}, lockInfo)),
|
||||
),
|
||||
conflict ? el('div', { className: 'warn-banner' }, 'Another editor has the archive locked. You can force-save if you intend to overwrite it.') : null,
|
||||
);
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Linked Guide',
|
||||
body,
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Close'),
|
||||
el('button', {
|
||||
onClick: async () => {
|
||||
await onOpenArchive?.(guide);
|
||||
},
|
||||
}, 'Show file'),
|
||||
conflict ? el('button.primary', {
|
||||
onClick: async () => {
|
||||
await onForceSave?.(guide);
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
}, 'Force save') : el('button.primary', {
|
||||
onClick: async () => {
|
||||
await onSave?.(guide);
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
}, 'Save now'),
|
||||
],
|
||||
wide: true,
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showInfoDialog(title, bodyText) {
|
||||
return new Promise((resolve) => {
|
||||
const { close } = openModal({
|
||||
title,
|
||||
body: el('div', {}, bodyText),
|
||||
footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'OK')],
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.StepForgeDialogs = {
|
||||
promptText,
|
||||
showQuickActions,
|
||||
showSettingsDialog,
|
||||
showExportDialog,
|
||||
showLinkedGuideDialog,
|
||||
showInfoDialog,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src file: data:; font-src 'self';">
|
||||
<title>StepForge</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="topbar">
|
||||
<div class="brand" id="btn-home" title="Guide library">StepForge</div>
|
||||
<div id="topbar-context"></div>
|
||||
<div class="spacer"></div>
|
||||
<div id="capture-status" class="hidden"></div>
|
||||
<input id="global-search" type="search" placeholder="Search guides… (Ctrl+/ for quick actions)">
|
||||
</header>
|
||||
<main id="view"></main>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<div id="toast-root"></div>
|
||||
<script src="util.js"></script>
|
||||
<script src="canvas.js"></script>
|
||||
<script src="dialogs.js"></script>
|
||||
<script src="editor.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Select region</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; cursor: crosshair; background: rgba(0,0,0,0.25); user-select: none; }
|
||||
#hint { position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(17,24,39,.9); color: #fff; padding: 6px 14px; border-radius: 6px;
|
||||
font: 13px system-ui, sans-serif; pointer-events: none; }
|
||||
#sel { position: fixed; border: 2px solid #2563eb; background: rgba(37,99,235,.15); display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="hint">Drag to select a region — Esc to cancel</div>
|
||||
<div id="sel"></div>
|
||||
<script>
|
||||
const sel = document.getElementById('sel');
|
||||
let start = null;
|
||||
function rectFrom(a, b) {
|
||||
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
|
||||
}
|
||||
window.addEventListener('mousedown', (e) => { start = { x: e.clientX, y: e.clientY }; });
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!start) return;
|
||||
const r = rectFrom(start, { x: e.clientX, y: e.clientY });
|
||||
sel.style.display = 'block';
|
||||
sel.style.left = r.x + 'px'; sel.style.top = r.y + 'px';
|
||||
sel.style.width = r.w + 'px'; sel.style.height = r.h + 'px';
|
||||
});
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (!start) return;
|
||||
const r = rectFrom(start, { x: e.clientX, y: e.clientY });
|
||||
window.regionPicker.done(r.w > 3 && r.h > 3 ? r : null);
|
||||
});
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') window.regionPicker.done(null);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,673 @@
|
||||
/* StepForge UI
|
||||
* Minimal desktop shell with a clean, Logitech-like surface:
|
||||
* neutral panels, subtle borders, blue accent, and generous spacing.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--panel: rgba(255, 255, 255, 0.92);
|
||||
--panel-solid: #ffffff;
|
||||
--panel-2: #eef2f6;
|
||||
--text: #18212b;
|
||||
--muted: #657181;
|
||||
--border: #d9e1e8;
|
||||
--accent: #0068ff;
|
||||
--accent-strong: #0054cc;
|
||||
--accent-fg: #ffffff;
|
||||
--danger: #c52d2d;
|
||||
--warn: #ffe7b7;
|
||||
--shadow: 0 14px 40px rgba(15, 23, 42, 0.08);
|
||||
--radius: 14px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f141b;
|
||||
--panel: rgba(20, 27, 35, 0.94);
|
||||
--panel-solid: #141b23;
|
||||
--panel-2: #1b2430;
|
||||
--text: #e7eef7;
|
||||
--muted: #9ba8b7;
|
||||
--border: #273241;
|
||||
--accent: #3b8cff;
|
||||
--accent-strong: #69a1ff;
|
||||
--accent-fg: #08101a;
|
||||
--danger: #ff6b6b;
|
||||
--warn: #4a3410;
|
||||
--shadow: 0 14px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
font: 13px/1.45 "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(0, 104, 255, 0.05), transparent 34%),
|
||||
linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 88%, #000 12%));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*::selection { background: rgba(0, 104, 255, 0.2); }
|
||||
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
#view { flex: 1; min-height: 0; display: flex; }
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 140ms ease, border-color 140ms ease, transform 120ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
button:hover { background: var(--panel-2); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
button.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
||||
button.danger { color: var(--danger); }
|
||||
button.icon { padding: 5px 9px; min-width: 34px; }
|
||||
button.tool {
|
||||
padding: 5px 10px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
button.tool:hover { background: var(--panel-2); color: var(--text); }
|
||||
button.tool.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 78%, transparent); }
|
||||
input:focus, select:focus, textarea:focus, button:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, white 40%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
label { user-select: none; }
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0 5px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-solid) 78%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#topbar-context { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
#topbar-context .muted { max-width: 44vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#global-search { width: min(360px, 32vw); margin-left: auto; }
|
||||
#capture-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #b42318;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
#capture-status button {
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.library, .editor { flex: 1; min-height: 0; display: flex; }
|
||||
|
||||
.lib-side {
|
||||
width: 248px;
|
||||
min-width: 248px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-solid) 84%, transparent);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.lib-side h3,
|
||||
.pane-props h3 {
|
||||
margin: 12px 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.lib-side .nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.lib-side .nav-item:hover { background: var(--panel-2); }
|
||||
.lib-side .nav-item.active {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel-solid));
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.lib-side .nav-item .count {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.lib-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.lib-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.guide-card {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
min-height: 116px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.guide-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||
.guide-card h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
padding-right: 22px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.guide-card .meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.guide-card .badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.guide-card .fav {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
opacity: 0.35;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.guide-card .fav.on {
|
||||
opacity: 1;
|
||||
color: #f5a524;
|
||||
}
|
||||
.guide-card .muted { font-size: 12px; }
|
||||
.guide-card .snippet,
|
||||
.qa-item .snippet { color: var(--muted); }
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-state .big { font-size: 40px; margin-bottom: 10px; color: var(--text); }
|
||||
|
||||
.pane-steps {
|
||||
width: 270px;
|
||||
min-width: 270px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: color-mix(in srgb, var(--panel-solid) 86%, transparent);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.pane-canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg) 76%, white 24%), var(--bg));
|
||||
}
|
||||
.pane-props {
|
||||
width: 330px;
|
||||
min-width: 330px;
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-solid) 88%, transparent);
|
||||
}
|
||||
.pane-head,
|
||||
.pane-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.pane-head { justify-content: space-between; border-bottom: 1px solid var(--border); }
|
||||
.pane-foot { border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-item:hover { background: var(--panel-2); }
|
||||
.step-item.selected {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel-solid));
|
||||
}
|
||||
.step-item.sub { margin-left: 18px; }
|
||||
.step-item .num {
|
||||
min-width: 28px;
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
.step-item .t {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.step-item .flags {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.step-item.skipped .t { text-decoration: line-through; opacity: 0.68; }
|
||||
.step-item.hiddenstep .t { opacity: 0.5; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 999px; flex: none; }
|
||||
.status-todo { background: #9aa7b8; }
|
||||
.status-in-progress { background: #f0a500; }
|
||||
.status-done { background: #22a06b; }
|
||||
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-solid) 70%, transparent);
|
||||
}
|
||||
.canvas-toolbar .sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
.canvas-wrap canvas {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.canvas-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pane-props section + section { margin-top: 16px; }
|
||||
.pane-props input[type="text"],
|
||||
.pane-props input[type="number"],
|
||||
.pane-props input[type="color"],
|
||||
.pane-props select,
|
||||
.pane-props textarea,
|
||||
.pane-props .rich-editor {
|
||||
width: 100%;
|
||||
}
|
||||
.pane-props .row,
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pane-props .row > label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-row {
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-row > label:first-child {
|
||||
width: 160px;
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
.form-row.stacked {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.form-row.stacked > label:first-child {
|
||||
width: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rich-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rich-toolbar button { padding: 4px 8px; font-size: 12px; }
|
||||
.rich-editor {
|
||||
min-height: 110px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-solid);
|
||||
}
|
||||
.rich-editor:focus { outline: 2px solid color-mix(in srgb, var(--accent) 60%, white 40%); }
|
||||
.rich-editor table,
|
||||
.rich-editor th,
|
||||
.rich-editor td {
|
||||
border: 1px solid var(--border);
|
||||
border-collapse: collapse;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.rich-editor pre {
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.block-card,
|
||||
.annotation-editor-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.block-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: var(--panel-solid);
|
||||
}
|
||||
.annotation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.annotation-editor .form-row > label:first-child {
|
||||
width: 118px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
}
|
||||
fieldset legend {
|
||||
padding: 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.placeholder-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.placeholder-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.placeholder-row input { width: 100%; }
|
||||
|
||||
.quick-actions {
|
||||
width: min(760px, 92vw);
|
||||
}
|
||||
.quick-actions input {
|
||||
width: 100%;
|
||||
font-size: 15px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
.qa-results {
|
||||
margin-top: 10px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.qa-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qa-item:hover,
|
||||
.qa-item.active {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.qa-item .kind {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.export-dialog,
|
||||
.linked-guide,
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.warn-banner {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--warn);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#modal-root:not(:empty) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(10, 15, 20, 0.42);
|
||||
}
|
||||
.modal {
|
||||
width: min(720px, 92vw);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal.wide { width: min(1020px, 96vw); }
|
||||
.modal header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 650;
|
||||
}
|
||||
.modal header .close {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal .body {
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#toast-root {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.toast {
|
||||
max-width: 360px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--text);
|
||||
color: var(--panel-solid);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toast.error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
min-width: 190px;
|
||||
padding: 5px;
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.ctx-menu .mi {
|
||||
padding: 7px 10px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-menu .mi:hover { background: var(--panel-2); }
|
||||
.ctx-menu .mi.danger { color: var(--danger); }
|
||||
.ctx-menu hr {
|
||||
margin: 6px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
/* Small DOM + app helpers shared by all renderer modules. */
|
||||
|
||||
/** Element builder: el('div.cls#id', {attrs/on*}, ...children) */
|
||||
function el(spec, props = {}, ...children) {
|
||||
const [tag, ...rest] = spec.split(/(?=[.#])/);
|
||||
const node = document.createElement(tag || 'div');
|
||||
for (const part of rest) {
|
||||
if (part.startsWith('.')) node.classList.add(part.slice(1));
|
||||
if (part.startsWith('#')) node.id = part.slice(1);
|
||||
}
|
||||
for (const [key, value] of Object.entries(props || {})) {
|
||||
if (key.startsWith('on') && typeof value === 'function') {
|
||||
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else if (key === 'dataset') {
|
||||
Object.assign(node.dataset, value);
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(node.style, value);
|
||||
} else if (key in node && key !== 'list') {
|
||||
node[key] = value;
|
||||
} else {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child == null || child === false) continue;
|
||||
node.append(child.nodeType ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function clearNode(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t = null;
|
||||
const wrapped = (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
wrapped.flush = (...args) => { clearTimeout(t); fn(...args); };
|
||||
wrapped.cancel = () => clearTimeout(t);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function toast(message, { error = false, ms = 2600 } = {}) {
|
||||
const root = document.getElementById('toast-root');
|
||||
const node = el('div.toast', { className: `toast${error ? ' error' : ''}` }, message);
|
||||
root.append(node);
|
||||
setTimeout(() => node.remove(), ms);
|
||||
}
|
||||
|
||||
/** Modal helper. Returns { close, node }. Esc and ✕ close it. */
|
||||
function openModal({ title, body, footer, wide = false, onClose }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
clearNode(root);
|
||||
const close = () => {
|
||||
clearNode(root);
|
||||
document.removeEventListener('keydown', escHandler, true);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); close(); }
|
||||
};
|
||||
document.addEventListener('keydown', escHandler, true);
|
||||
const modal = el('div.modal', { className: `modal${wide ? ' wide' : ''}` },
|
||||
el('header', {}, title, el('span.close', { onClick: close, title: 'Close (Esc)' }, '✕')),
|
||||
el('div.body', {}, body),
|
||||
footer ? el('footer', {}, footer) : null,
|
||||
);
|
||||
modal.addEventListener('click', (e) => e.stopPropagation());
|
||||
root.append(modal);
|
||||
root.onclick = close;
|
||||
return { close, node: modal };
|
||||
}
|
||||
|
||||
/** Simple confirm dialog returning a promise<boolean>. */
|
||||
function confirmDialog(message, { danger = false, okLabel = 'OK' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { close } = openModal({
|
||||
title: 'Confirm',
|
||||
body: el('div', {}, message),
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button', {
|
||||
className: `primary${danger ? ' danger' : ''}`,
|
||||
onClick: () => { close(); resolve(true); },
|
||||
}, okLabel),
|
||||
],
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promptDialog(title, { value = '', label = 'Name' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const input = el('input', { type: 'text', value });
|
||||
const done = (v) => { close(); resolve(v); };
|
||||
const { close } = openModal({
|
||||
title,
|
||||
body: el('div.form-row', {}, el('label', {}, label), input),
|
||||
footer: [
|
||||
el('button', { onClick: () => done(null) }, 'Cancel'),
|
||||
el('button.primary', { onClick: () => done(input.value.trim() || null) }, 'OK'),
|
||||
],
|
||||
onClose: () => resolve(null),
|
||||
});
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(input.value.trim() || null); });
|
||||
setTimeout(() => input.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */
|
||||
function contextMenu(x, y, items) {
|
||||
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
|
||||
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
|
||||
for (const item of items) {
|
||||
if (item === 'sep') { menu.append(el('hr')); continue; }
|
||||
menu.append(el('div.mi', {
|
||||
className: `mi${item.danger ? ' danger' : ''}`,
|
||||
onClick: () => { menu.remove(); item.action(); },
|
||||
}, item.label));
|
||||
}
|
||||
document.body.append(menu);
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
|
||||
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', () => menu.remove(), { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
Reference in New Issue
Block a user