Fork Notesium source and restructure into Go package layout
Initial fork of github.com/alonswartz/notesium into librenotes: - Source moved to internal/notesium/ (package notesium) - Thin entry point at cmd/librenotes/main.go - Module renamed to git.librete.ch/public/librenotes - main() exposed as notesium.Run() - LICENSE preserved (MIT), NOTICE added with attribution - Web assets and completion.bash co-located with embedding code to satisfy go:embed path constraints Closes #3, #34, #35. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.vendor/
|
||||
vendor.js
|
||||
vendor.css
|
||||
tailwind.css
|
||||
@@ -0,0 +1,40 @@
|
||||
var t = `
|
||||
<div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div v-if="alert.type == 'error'" class="text-red-400"><Icon name="outline-exclamation-circle" size="h-6 w-6" /></div>
|
||||
<div v-if="alert.type == 'success'" class="text-green-400"><Icon name="outline-check-circle" size="h-6 w-6" /></div>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-gray-900" v-text="alert.title"></p>
|
||||
<p v-show="alert.body" class="mt-1 text-sm text-gray-500" v-text="alert.body"></p>
|
||||
</div>
|
||||
<div v-show="alert.sticky" class="ml-4 flex flex-shrink-0">
|
||||
<button @click="dismiss()" type="button" class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Close</span>
|
||||
<Icon name="mini-x-mark" size="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { Icon },
|
||||
props: ['alert'],
|
||||
emits: ['alert-dismiss'],
|
||||
methods: {
|
||||
dismiss() {
|
||||
this.$emit('alert-dismiss', this.alert.id);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.alert.sticky) {
|
||||
setTimeout(() => { this.dismiss(); }, 2000);
|
||||
}
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
var t = `
|
||||
<div class="relative flex max-h-screen h-screen overflow-hidden">
|
||||
|
||||
<Ribbon :versionCheck=versionCheck :showPeriodic=showPeriodic
|
||||
@note-new="newNote" @finder-open="openFinder" @graph-open="showGraph=true" @settings-open="showSettings=true" @periodic-open="showPeriodic=true" />
|
||||
|
||||
<SidePanel v-if="$notesiumState.showLabelsPanel || $notesiumState.showNotesPanel"
|
||||
:lastSave="lastSave" @note-open="openNote" @note-new="newNote" @finder-open="openFinder" />
|
||||
|
||||
<GraphPanel v-if="$notesiumState.showGraphPanel" :lastSave=graphPanelWatcher :activeTabId=activeTabId @note-open="openNote" />
|
||||
|
||||
<div class="flex flex-col h-full w-full overflow-x-auto">
|
||||
<nav class="flex bg-gray-200 text-gray-800">
|
||||
<NavTabs :tabs=tabs :activeTabId=activeTabId :previousTabId=previousTabId :notes=notes
|
||||
@tab-activate="activateTab" @tab-move="moveTab" @tab-close="closeTab" @note-close="closeNote" />
|
||||
</nav>
|
||||
<main class="h-full overflow-hidden bg-gray-50">
|
||||
<Empty v-if="tabs.length == 0" @note-new="newNote" @note-daily="dailyNote" @finder-open="openFinder" @graph-open="showGraph=true" />
|
||||
<Note v-show="note.Filename == activeTabId" :note=note v-for="note in notes" :key="note.Filename" :activeTabId=activeTabId
|
||||
@note-open="openNote" @note-close="closeNote" @note-save="saveNote" @note-delete="deleteNote" @finder-open="openFinder" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Periodic v-if="showPeriodic" @note-daily="dailyNote" @note-weekly="weeklyNote" @periodic-close="showPeriodic=false" />
|
||||
<Graph v-if="showGraph" @graph-close="showGraph=false" @note-open="openNote" />
|
||||
<Settings v-if="showSettings" :versionCheck=versionCheck @settings-close="showSettings=false" @version-check="checkVersion" @finder-open="openFinder" />
|
||||
<Finder v-if="showFinder" :uri=finderUri :initialQuery=finderQuery @finder-selection="handleFinderSelection" />
|
||||
<Confirm ref="confirmDialog" />
|
||||
<div v-show="keySequence.length" v-text="keySequence.join(' ')" class="absolute bottom-6 right-4"></div>
|
||||
|
||||
<div aria-live="assertive" class="pointer-events-none fixed inset-0 flex items-end sm:items-start p-2 z-50">
|
||||
<div class="flex w-full flex-col items-center space-y-2 sm:items-end">
|
||||
<Alert :alert=alert v-for="alert in alerts" :key="alert.id" @alert-dismiss="dismissAlert" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
import Finder from './finder.js'
|
||||
import NavTabs from './nav-tabs.js'
|
||||
import Ribbon from './ribbon.js'
|
||||
import SidePanel from './sidepanel.js'
|
||||
import GraphPanel from './graph-panel.js'
|
||||
import Note from './note.js'
|
||||
import Periodic from './periodic.js'
|
||||
import Graph from './graph.js'
|
||||
import Empty from './empty.js'
|
||||
import Alert from './alert.js'
|
||||
import Confirm from './confirm.js'
|
||||
import Settings from './settings.js'
|
||||
import { formatDate } from './dateutils.js';
|
||||
import { initCodeMirrorVimEx } from './cm-vim.js'
|
||||
export default {
|
||||
components: { Finder, NavTabs, Ribbon, SidePanel, GraphPanel, Note, Periodic, Graph, Empty, Alert, Confirm, Settings },
|
||||
data() {
|
||||
return {
|
||||
notes: [],
|
||||
tabs: [],
|
||||
tabHistory: [],
|
||||
finderUri: '',
|
||||
finderQuery: '',
|
||||
showGraph: false,
|
||||
showFinder: false,
|
||||
showPeriodic: false,
|
||||
showSettings: false,
|
||||
versionCheck: {},
|
||||
keySequence: [],
|
||||
alerts: [],
|
||||
lastSave: null,
|
||||
graphPanelWatcher: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openFinder(uri, query) {
|
||||
this.finderUri = uri;
|
||||
this.finderQuery = query;
|
||||
this.showFinder = true;
|
||||
},
|
||||
handleFinderSelection(value) {
|
||||
this.showFinder = false;
|
||||
this.finderQuery = '';
|
||||
if (value === null) {
|
||||
this.refocusActiveTab();
|
||||
} else {
|
||||
const note = this.notes.find(note => note.Filename === value.Filename);
|
||||
if (note) {
|
||||
note.Linenum = value.Linenum;
|
||||
this.activateTab(value.Filename);
|
||||
} else {
|
||||
this.fetchNote(value.Filename, value.Linenum);
|
||||
}
|
||||
}
|
||||
},
|
||||
fetchNote(filename, linenum, insertAfterActive = false) {
|
||||
fetch("/api/notes/" + filename)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(note => {
|
||||
note.Linenum = linenum;
|
||||
this.notes.push(note);
|
||||
this.addTab('note', note.Filename, insertAfterActive);
|
||||
this.activateTab(note.Filename);
|
||||
})
|
||||
.catch(e => {
|
||||
this.addAlert({type: 'error', title: 'Error fetching note', body: e.Error, sticky: true})
|
||||
});
|
||||
},
|
||||
saveNote(filename, content, timestamp, isGhost) {
|
||||
let uri;
|
||||
let params = { method: null, body: null, headers: {"Content-type": "application/json"} }
|
||||
if (isGhost) {
|
||||
uri = "/api/notes/";
|
||||
params.method = "POST";
|
||||
params.body = JSON.stringify({Content: content, Ctime: timestamp});
|
||||
} else {
|
||||
uri = "/api/notes/" + filename;
|
||||
params.method = "PATCH"
|
||||
params.body = JSON.stringify({ Content: content, LastMtime: timestamp });
|
||||
}
|
||||
fetch(uri, params)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(note => {
|
||||
const index = this.notes.findIndex(n => n.Filename === filename);
|
||||
|
||||
// trigger graph panel refresh if needed
|
||||
if (this.$notesiumState.showGraphPanel) {
|
||||
if (params.method == "POST") {
|
||||
this.graphPanelWatcher = note.Mtime;
|
||||
} else if (this.notes[index].Title !== note.Title) {
|
||||
this.graphPanelWatcher = note.Mtime;
|
||||
} else {
|
||||
const stringifyLinks = (links) => { return JSON.stringify(links?.map(link => link.Filename).sort() || []); }
|
||||
if (stringifyLinks(this.notes[index].OutgoingLinks) !== stringifyLinks(note.OutgoingLinks)) {
|
||||
this.graphPanelWatcher = note.Mtime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.notes[index] = note;
|
||||
this.activateTab(note.Filename);
|
||||
|
||||
// track lastSave to force sidepanel refresh
|
||||
this.lastSave = note.Mtime;
|
||||
|
||||
// update other notes IncomingLinks due to potential changes
|
||||
this.notes.forEach(openNote => {
|
||||
if (openNote.Filename == note.Filename) return;
|
||||
if (openNote.ghost) return;
|
||||
fetch("/api/notes/" + openNote.Filename)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(fetchedNote => { openNote.IncomingLinks = fetchedNote.IncomingLinks; })
|
||||
.catch(e => { console.error('Error fetching note for IncomingLinks: ', e); });
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
this.addAlert({type: 'error', title: 'Error saving note', body: e.Error, sticky: true})
|
||||
});
|
||||
},
|
||||
async deleteNote(filename, timestamp) {
|
||||
const note = this.notes.find(note => note.Filename === filename);
|
||||
if (!note) return;
|
||||
if (note.ghost) return;
|
||||
if (note.isModified) { this.addAlert({type: 'error', title: 'Note has unsaved changes'}); return; }
|
||||
if ((note.IncomingLinks?.length || 0) > 0) { this.addAlert({type: 'error', title: 'Refusing deletion, note has incoming links'}); return; }
|
||||
|
||||
const confirmCfg = {
|
||||
title: 'Delete note',
|
||||
body: `Are you sure you want to delete this note? This action cannot be undone.\n\n${note.Filename}: ${note.Title}`,
|
||||
button: 'Delete note',
|
||||
};
|
||||
if (!await this.$refs.confirmDialog.open(confirmCfg)) return;
|
||||
|
||||
let params = {};
|
||||
params.method = 'DELETE';
|
||||
params.body = JSON.stringify({ LastMtime: timestamp });
|
||||
params.headers = {"Content-type": "application/json"};
|
||||
fetch("/api/notes/" + filename, params)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(response => {
|
||||
this.addAlert({type: 'success', title: 'Note deleted successfully'});
|
||||
this.closeNote(filename);
|
||||
|
||||
// update lastSave to force sidepanel refresh
|
||||
this.lastSave = new Date().toISOString();
|
||||
|
||||
// trigger graph panel refresh if needed
|
||||
this.graphPanelWatcher = this.lastSave;
|
||||
|
||||
// update other notes IncomingLinks due to potential changes
|
||||
this.notes.forEach(openNote => {
|
||||
if (openNote.ghost) return;
|
||||
fetch("/api/notes/" + openNote.Filename)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(fetchedNote => { openNote.IncomingLinks = fetchedNote.IncomingLinks; })
|
||||
.catch(e => { console.error('Error fetching note for IncomingLinks: ', e); });
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
this.addAlert({type: 'error', title: 'Error deleting note', body: e.Error, sticky: true})
|
||||
});
|
||||
},
|
||||
newNote(ctime, content) {
|
||||
const baseUri = '/api/raw/new';
|
||||
let params = new URLSearchParams({ verbose: 'true' });
|
||||
if (ctime) params.append('ctime', ctime);
|
||||
const uri = `${baseUri}?${params.toString()}`;
|
||||
fetch(uri)
|
||||
.then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e)))
|
||||
.then(text => {
|
||||
const noteInfo = text.trim().split('\n').reduce((dict, line) => {
|
||||
const [key, value] = line.split(/:(.+)/); dict[key] = value;
|
||||
return dict;
|
||||
}, {});
|
||||
|
||||
if (noteInfo.exists === "true") { this.openNote(noteInfo.filename, 1); return; }
|
||||
|
||||
const index = this.notes.findIndex(note => note.Filename === noteInfo.filename);
|
||||
if (index !== -1) { this.activateTab(noteInfo.filename); return; }
|
||||
|
||||
const ghost = {
|
||||
Filename: noteInfo.filename,
|
||||
Title: 'untitled',
|
||||
Content: content ? content : '',
|
||||
isModified: content ? true : false,
|
||||
Mtime: '0',
|
||||
Ctime: noteInfo.ctime,
|
||||
ghost: true,
|
||||
};
|
||||
this.notes.push(ghost);
|
||||
this.addTab('note', ghost.Filename);
|
||||
this.activateTab(ghost.Filename);
|
||||
})
|
||||
.catch(e => {
|
||||
this.addAlert({type: 'error', title: 'Error retrieving new note metadata', body: e.Error, sticky: true});
|
||||
});
|
||||
},
|
||||
dailyNote(customDate = null) {
|
||||
const date = customDate ? new Date(customDate) : new Date();
|
||||
const ctime = formatDate(date, '%Y-%m-%dT00:00:00');
|
||||
const content = `# ${formatDate(date, '%b %d, %Y (%A)')}`;
|
||||
this.newNote(ctime, content);
|
||||
},
|
||||
weeklyNote(customDate = null) {
|
||||
const date = customDate ? new Date(customDate) : new Date();
|
||||
const epoch = date.getTime() / 1000;
|
||||
const day = date.getDay() === 0 ? 7 : date.getDay();
|
||||
const diff = (day - this.$notesiumState.startOfWeek + 7) % 7;
|
||||
const weekBegEpoch = epoch - (diff * 86400);
|
||||
const weekBegDate = new Date(weekBegEpoch * 1000);
|
||||
const weekBegStr = formatDate(weekBegDate, '%a %b %d');
|
||||
const weekEndEpoch = weekBegEpoch + (6 * 86400);
|
||||
const weekEndDate = new Date(weekEndEpoch * 1000);
|
||||
const weekEndStr = formatDate(weekEndDate, '%a %b %d');
|
||||
const year = formatDate(weekBegDate, '%Y');
|
||||
const weekFmt = this.$notesiumState.startOfWeek === 0 ? '%U' : '%V';
|
||||
const weekNum = formatDate(weekBegDate, weekFmt);
|
||||
|
||||
const ctime = formatDate(weekBegDate, '%Y-%m-%dT00:00:01');
|
||||
const content = `# ${year}: Week${weekNum} (${weekBegStr} - ${weekEndStr})`;
|
||||
this.newNote(ctime, content);
|
||||
},
|
||||
openNote(filename, linenum) {
|
||||
const index = this.notes.findIndex(note => note.Filename === filename);
|
||||
if (index !== -1) {
|
||||
this.notes[index].Linenum = linenum;
|
||||
this.activateTab(filename);
|
||||
} else {
|
||||
this.fetchNote(filename, linenum, true);
|
||||
}
|
||||
},
|
||||
addTab(tabType, tabId, insertAfterActive = false) {
|
||||
const tab = {type: tabType, id: tabId}
|
||||
const index = this.tabs.findIndex(t => t.id === this.activeTabId);
|
||||
if (insertAfterActive && index !== -1) {
|
||||
this.tabs.splice(index + 1, 0, tab);
|
||||
} else {
|
||||
this.tabs.push(tab);
|
||||
}
|
||||
},
|
||||
activateTab(tabId) {
|
||||
if (tabId == this.activeTabId) return;
|
||||
this.tabHistory = this.tabHistory.filter(id => id !== tabId);
|
||||
this.tabHistory.push(tabId);
|
||||
},
|
||||
refocusActiveTab() {
|
||||
// required for cancelled keybind and finder
|
||||
if (!this.activeTabId) return;
|
||||
const tabId = this.activeTabId;
|
||||
this.tabHistory = this.tabHistory.filter(id => id !== tabId);
|
||||
this.$nextTick(() => { this.activateTab(tabId) });
|
||||
},
|
||||
moveTab(tabId, newIndex) {
|
||||
const index = this.tabs.findIndex(t => t.id === tabId);
|
||||
if (index === -1) return;
|
||||
this.tabs.splice(newIndex, 0, this.tabs.splice(index, 1)[0]);
|
||||
},
|
||||
closeTab(tabId) {
|
||||
const index = this.tabs.findIndex(t => t.id === tabId);
|
||||
if (index !== -1) this.tabs.splice(index, 1);
|
||||
this.tabHistory = this.tabHistory.filter(id => id !== tabId);
|
||||
},
|
||||
async closeNote(filename, confirmIfModified = true) {
|
||||
const index = this.notes.findIndex(note => note.Filename === filename);
|
||||
if (index === -1) return;
|
||||
if (this.notes[index].isModified && !this.notes[index].ghost && confirmIfModified) {
|
||||
const confirmCfg = {
|
||||
title: 'Note has unsaved changes',
|
||||
body: `Are you sure you want to discard changes and close? This action cannot be undone.\n\n${filename}: ${this.notes[index].Title}`,
|
||||
button: 'Close without saving',
|
||||
};
|
||||
if (!await this.$refs.confirmDialog.open(confirmCfg)) return;
|
||||
}
|
||||
|
||||
this.notes.splice(index, 1);
|
||||
this.closeTab(filename);
|
||||
},
|
||||
addAlert({type, title, body, sticky = false}) {
|
||||
this.alerts.push({type, title, body, sticky, id: Date.now().toString(36)});
|
||||
},
|
||||
dismissAlert(id) {
|
||||
const index = this.alerts.findIndex(alert => alert.id === id);
|
||||
if (index !== -1) this.alerts.splice(index, 1);
|
||||
},
|
||||
checkVersion() {
|
||||
this.versionCheck.error = '';
|
||||
this.versionCheck.comparison = '';
|
||||
this.versionCheck.latestVersion = '';
|
||||
this.versionCheck.inprogress = true;
|
||||
fetch('/api/raw/version?verbose=true&check=true')
|
||||
.then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e)))
|
||||
.then(text => {
|
||||
if (text.toLowerCase().startsWith('error')) {
|
||||
this.versionCheck.error = text.trim();
|
||||
return;
|
||||
}
|
||||
const lines = text.split('\n');
|
||||
lines.forEach(line => {
|
||||
const [key, ...rest] = line.split(':');
|
||||
const value = rest.join(':').trim();
|
||||
switch (key) {
|
||||
case 'comparison': this.versionCheck.comparison = value; break;
|
||||
case 'latest.version': this.versionCheck.latestVersion = value; break;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
this.versionCheck.error = e.Error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.versionCheck.inprogress = false;
|
||||
this.versionCheck.date = new Date();
|
||||
});
|
||||
},
|
||||
handleBeforeUnload(event) {
|
||||
if (this.notes.some(note => note.isModified)) {
|
||||
const message = 'You have unsaved changes.';
|
||||
event.returnValue = message;
|
||||
return message;
|
||||
}
|
||||
},
|
||||
handleKeyPress(event) {
|
||||
if (event.target.tagName !== 'BODY') return
|
||||
|
||||
const leaderKey = 'Space'
|
||||
if (this.keySequence.length == 0 && event.code == leaderKey) {
|
||||
this.keySequence = [leaderKey];
|
||||
event.preventDefault();
|
||||
setTimeout(() => {
|
||||
if (this.keySequence.length > 0) {
|
||||
this.keySequence = [];
|
||||
this.refocusActiveTab();
|
||||
}
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keySequence[0] == leaderKey) {
|
||||
this.keySequence.push(event.code)
|
||||
event.preventDefault();
|
||||
|
||||
switch(this.keySequence.join(' ')) {
|
||||
case `${leaderKey} KeyN KeyL`:
|
||||
this.keySequence = [];
|
||||
this.openFinder('/api/raw/list?color=true&prefix=label&sort=alpha');
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyC`:
|
||||
this.keySequence = [];
|
||||
this.openFinder('/api/raw/list?color=true&prefix=ctime&sort=ctime');
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyM`:
|
||||
this.keySequence = [];
|
||||
this.openFinder('/api/raw/list?color=true&prefix=mtime&sort=mtime');
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyK`:
|
||||
this.keySequence = [];
|
||||
const tab = this.tabs.find(t => t.id === this.activeTabId);
|
||||
const extraParams = tab?.type === 'note' ? `&filename=${tab.id}` : '';
|
||||
this.openFinder('/api/raw/links?color=true' + extraParams);
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyS`:
|
||||
this.keySequence = [];
|
||||
this.openFinder('/api/raw/lines?color=true&prefix=title');
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyN`:
|
||||
this.keySequence = [];
|
||||
this.newNote();
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyD`:
|
||||
this.keySequence = [];
|
||||
this.dailyNote();
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyW`:
|
||||
this.keySequence = [];
|
||||
this.weeklyNote();
|
||||
break;
|
||||
case `${leaderKey} KeyN KeyG`:
|
||||
this.keySequence = [];
|
||||
this.showGraph = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
handleHeartbeat(action) {
|
||||
const sendHeartbeat = () => {
|
||||
fetch("/api/heartbeat").then(r => { !r.ok && this.handleHeartbeat('stop') });
|
||||
};
|
||||
switch (action) {
|
||||
case 'start':
|
||||
!this.heartbeatInterval && (this.heartbeatInterval = setInterval(sendHeartbeat, 5000));
|
||||
break;
|
||||
case 'stop':
|
||||
this.heartbeatInterval && clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
break;
|
||||
}
|
||||
},
|
||||
handleCheckVersion(action) {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
this.checkVersion();
|
||||
!this.checkVersionInterval && (this.checkVersionInterval = setInterval(this.checkVersion, 86400000)); // 24 hours
|
||||
break;
|
||||
case 'stop':
|
||||
this.checkVersionInterval && clearInterval(this.checkVersionInterval);
|
||||
this.checkVersionInterval = null;
|
||||
break;
|
||||
}
|
||||
},
|
||||
handleRuntimeWebOpts() {
|
||||
fetch("/api/runtime")
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(runtime => {
|
||||
if (runtime.web["stop-on-idle"]) this.handleHeartbeat('start');
|
||||
if (runtime.web["daily-version-check"]) this.handleCheckVersion('start');
|
||||
})
|
||||
.catch(e => { console.error(e); });
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
activeTabId() {
|
||||
return this.tabHistory[this.tabHistory.length - 1] || '';
|
||||
},
|
||||
previousTabId() {
|
||||
return this.tabHistory[this.tabHistory.length - 2] || '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.handleKeyPress);
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
||||
this.handleRuntimeWebOpts();
|
||||
initCodeMirrorVimEx(this.$notesiumState);
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeyPress);
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||||
this.handleHeartbeat('stop');
|
||||
this.handleCheckVersion('stop');
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
function isTableRow(cm, lineNum) {
|
||||
return cm.getLine(lineNum).trim().startsWith('|');
|
||||
}
|
||||
|
||||
function findTableBoundaries(cm, lineNum) {
|
||||
let startLine = lineNum, endLine = lineNum;
|
||||
while (startLine > 0 && isTableRow(cm, startLine - 1)) startLine--;
|
||||
while (endLine < cm.lineCount() - 1 && isTableRow(cm, endLine + 1)) endLine++;
|
||||
return { startLine, endLine };
|
||||
}
|
||||
|
||||
function getColumnAlignments(cm, lineNum) {
|
||||
return cm.getLine(lineNum).split('|').slice(1, -1).map(col => {
|
||||
const trimmedCol = col.trim();
|
||||
if (trimmedCol.startsWith(':') && trimmedCol.endsWith(':')) return 'center'
|
||||
if (trimmedCol.endsWith(':')) return 'right';
|
||||
return 'left';
|
||||
});
|
||||
}
|
||||
|
||||
function getColumnPositions(cm, lineNum) {
|
||||
const lineText = cm.getLine(lineNum);
|
||||
let positions = [];
|
||||
let pos = lineText.indexOf('|');
|
||||
while (pos !== -1) { positions.push(pos); pos = lineText.indexOf('|', pos + 1); }
|
||||
return positions;
|
||||
}
|
||||
|
||||
function getColumnMaxLengths(cm, startLine, endLine, conceal) {
|
||||
let maxLengths = [];
|
||||
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
|
||||
if (lineNum == startLine + 1) continue;
|
||||
const columns = cm.getLine(lineNum).trim().split('|').map(col => col.trim());
|
||||
columns.slice(1, -1).forEach((col, index) => {
|
||||
const colLength = conceal ? getConcealLength(col) : col.length;
|
||||
if (!maxLengths[index] || colLength > maxLengths[index]) {
|
||||
maxLengths[index] = colLength;
|
||||
}
|
||||
});
|
||||
}
|
||||
return maxLengths;
|
||||
}
|
||||
|
||||
function formatRowSep(cm, lineNum, colMaxLengths, colAlignments) {
|
||||
const line = cm.getLine(lineNum);
|
||||
let columns = line.split('|');
|
||||
columns = columns.map((col, index) => {
|
||||
if (index === 0 || index === columns.length - 1) return col;
|
||||
switch (colAlignments[index - 1]) {
|
||||
case 'center': return ` :${"-".repeat(colMaxLengths[index - 1] - 2)}: `;
|
||||
case 'right': return ` ${"-".repeat(colMaxLengths[index - 1] - 1)}: `;
|
||||
default: return ` ${"-".repeat(colMaxLengths[index - 1])} `;
|
||||
}
|
||||
});
|
||||
cm.replaceRange(columns.join('|'), {line: lineNum, ch: 0}, {line: lineNum, ch: line.length});
|
||||
}
|
||||
|
||||
function formatRow(cm, lineNum, colMaxLengths, colAlignments, conceal) {
|
||||
const line = cm.getLine(lineNum);
|
||||
let columns = line.split('|');
|
||||
columns = columns.map((col, index) => {
|
||||
if (index === 0 || index === columns.length - 1) return col;
|
||||
const colTrimmed = col.trim();
|
||||
const colLength = conceal ? getConcealLength(colTrimmed) : colTrimmed.length;
|
||||
const paddingLength = Math.max(0, colMaxLengths[index - 1] - colLength);
|
||||
const halfPadding = Math.floor(paddingLength / 2);
|
||||
switch (colAlignments[index - 1]) {
|
||||
case 'center': return ` ${' '.repeat(halfPadding)}${colTrimmed}${' '.repeat(paddingLength - halfPadding)} `;
|
||||
case 'right': return ` ${' '.repeat(paddingLength)}${colTrimmed} `;
|
||||
default: return ` ${colTrimmed}${' '.repeat(paddingLength)} `;
|
||||
}
|
||||
});
|
||||
cm.replaceRange(columns.join('|'), {line: lineNum, ch: 0}, {line: lineNum, ch: line.length});
|
||||
}
|
||||
|
||||
function formatRows(cm, conceal) {
|
||||
const cursorPos = cm.getCursor();
|
||||
if (!isTableRow(cm, cursorPos.line)) return;
|
||||
|
||||
const { startLine, endLine } = findTableBoundaries(cm, cursorPos.line);
|
||||
const colAlignments = getColumnAlignments(cm, startLine + 1);
|
||||
const colMaxLengths = getColumnMaxLengths(cm, startLine, endLine, conceal);
|
||||
|
||||
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
|
||||
if (lineNum == startLine + 1) {
|
||||
formatRowSep(cm, lineNum, colMaxLengths, colAlignments);
|
||||
} else {
|
||||
formatRow(cm, lineNum, colMaxLengths, colAlignments, conceal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addOrUpdateRowSep(cm) {
|
||||
const cursorPos = cm.getCursor();
|
||||
const { startLine, endLine } = findTableBoundaries(cm, cursorPos.line);
|
||||
if (startLine !== cursorPos.line) return;
|
||||
|
||||
const columnsCount = getColumnPositions(cm, startLine).length;
|
||||
if (startLine == endLine) {
|
||||
cm.replaceRange("\n" + `${"|".repeat(columnsCount)}`, {line: startLine, ch: cm.getLine(startLine).length});
|
||||
return;
|
||||
}
|
||||
|
||||
const sepLineNum = startLine + 1;
|
||||
const sepLineText = cm.getLine(sepLineNum);
|
||||
const columnsCountSep = getColumnPositions(cm, sepLineNum).length;
|
||||
if (columnsCount > columnsCountSep) {
|
||||
cm.replaceRange(`${"|".repeat(columnsCount - columnsCountSep)}`, {line: sepLineNum, ch: sepLineText.length});
|
||||
} else {
|
||||
const newSepLineText = sepLineText.split('|').slice(0, columnsCount).join('|') + '|';
|
||||
cm.replaceRange(newSepLineText, {line: sepLineNum, ch: 0}, {line: sepLineNum, ch: sepLineText.length});
|
||||
}
|
||||
}
|
||||
|
||||
export function isCursorInTable(cm) {
|
||||
const cursorPos = cm.getCursor();
|
||||
return isTableRow(cm, cursorPos.line);
|
||||
}
|
||||
|
||||
export function formatTableAndAdvance(cm, conceal) {
|
||||
const cursorPos = cm.getCursor();
|
||||
if (!isTableRow(cm, cursorPos.line)) return;
|
||||
|
||||
const currentPositions = getColumnPositions(cm, cursorPos.line);
|
||||
const currentColumn = currentPositions.filter(pos => pos < cursorPos.ch).length;
|
||||
|
||||
if (currentColumn == currentPositions.length) {
|
||||
cm.replaceRange('|', {line: cursorPos.line, ch: cm.getLine(cursorPos.line).length});
|
||||
addOrUpdateRowSep(cm);
|
||||
formatRows(cm, conceal);
|
||||
cm.setCursor(cursorPos.line, cm.getLine(cursorPos.line).length);
|
||||
} else {
|
||||
formatRows(cm, conceal);
|
||||
const newPositions = getColumnPositions(cm, cursorPos.line);
|
||||
cm.setCursor(cursorPos.line, newPositions[currentColumn] + 2);
|
||||
}
|
||||
}
|
||||
|
||||
export function navigateTable(cm, direction) {
|
||||
const cursorPos = cm.getCursor();
|
||||
if (!isTableRow(cm, cursorPos.line)) return CodeMirror.Pass;
|
||||
|
||||
const currentPositions = getColumnPositions(cm, cursorPos.line);
|
||||
const currentColumn = currentPositions.filter(pos => pos < cursorPos.ch).length;
|
||||
|
||||
const moveCursorVertically = (targetLine) => {
|
||||
const targetPositions = getColumnPositions(cm, targetLine);
|
||||
const targetCh = currentColumn <= targetPositions.length
|
||||
? targetPositions[currentColumn - 1] + 2
|
||||
: cm.getLine(targetLine).length;
|
||||
cm.setCursor(targetLine, targetCh);
|
||||
};
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
if (currentColumn > 1) {
|
||||
cm.setCursor(cursorPos.line, currentPositions[currentColumn - 2] + 2);
|
||||
}
|
||||
break;
|
||||
case 'right':
|
||||
if (currentColumn < currentPositions.length) {
|
||||
cm.setCursor(cursorPos.line, currentPositions[currentColumn] + 2);
|
||||
}
|
||||
break;
|
||||
case 'up':
|
||||
const {startLine} = findTableBoundaries(cm, cursorPos.line);
|
||||
if (cursorPos.line > startLine) moveCursorVertically(cursorPos.line - 1);
|
||||
break;
|
||||
case 'down':
|
||||
const {endLine} = findTableBoundaries(cm, cursorPos.line);
|
||||
if (cursorPos.line < endLine) moveCursorVertically(cursorPos.line + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function getConcealLength(s) {
|
||||
s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
|
||||
s = s.replace(/(\*\*\*|___)(.*?)\1/g, '$2'); // Bold + Italic
|
||||
s = s.replace(/(\*\*|__)(.*?)\1/g, '$2'); // Bold
|
||||
s = s.replace(/(\*|_)(.*?)\1/g, '$2'); // Italic
|
||||
s = s.replace(/(~~)(.*?)\1/g, '$2'); // Strikethrough
|
||||
s = s.replace(/(`)(.*?)\1/g, '$2'); // Inline code
|
||||
return s.length;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export function initCodeMirrorVimEx(notesiumState) {
|
||||
CodeMirror.Vim.defineEx('quit', 'q', (cm, cmd) => {
|
||||
const confirmIfModified = cmd.argString !== '!';
|
||||
if (cm.quit) cm.quit(confirmIfModified);
|
||||
});
|
||||
|
||||
CodeMirror.Vim.defineEx('wq', '', (cm) => {
|
||||
if (cm.writequit) cm.writequit();
|
||||
});
|
||||
|
||||
CodeMirror.Vim.defineEx('cmExecCommand', '', (cm, cmd) => {
|
||||
cm.execCommand(cmd.args[0]);
|
||||
});
|
||||
CodeMirror.Vim.map('zo', ':cmExecCommand unfold', 'normal');
|
||||
CodeMirror.Vim.map('zc', ':cmExecCommand fold', 'normal');
|
||||
CodeMirror.Vim.map('zR', ':cmExecCommand unfoldAll', 'normal');
|
||||
CodeMirror.Vim.map('zM', ':cmExecCommand foldAll', 'normal');
|
||||
CodeMirror.Vim.map('za', ':cmExecCommand toggleFold', 'normal');
|
||||
|
||||
CodeMirror.Vim.defineEx('OpenLinkUnderCursor', '', (cm) => {
|
||||
if (!cm.openlink) return;
|
||||
const cursor = cm.getCursor();
|
||||
const lineContent = cm.getLine(cursor.line);
|
||||
const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const urlLinkRegex = /(?:https?:\/\/|www\.)[^\s)]+/g;
|
||||
let match;
|
||||
let link = null;
|
||||
while ((match = mdLinkRegex.exec(lineContent)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (cursor.ch >= start && cursor.ch <= end) { link = match[2]; break; }
|
||||
}
|
||||
if (!link) {
|
||||
while ((match = urlLinkRegex.exec(lineContent)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (cursor.ch >= start && cursor.ch <= end) { link = match[0]; break; }
|
||||
}
|
||||
}
|
||||
if (link) cm.openlink(link);
|
||||
});
|
||||
CodeMirror.Vim.map('ge', ':OpenLinkUnderCursor', 'normal');
|
||||
CodeMirror.Vim.map('gx', ':OpenLinkUnderCursor', 'normal');
|
||||
|
||||
CodeMirror.Vim.defineEx('BodyKeyEvent', '', (cm, cmd) => {
|
||||
const key = cmd.args[0];
|
||||
const code = cmd.args[1];
|
||||
const ctrlKey = key.startsWith('<C')
|
||||
cm.display.input.blur();
|
||||
document.body.focus();
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: key, code: code, ctrlKey: ctrlKey, bubbles: true, cancelable: true, composed: true }));
|
||||
});
|
||||
CodeMirror.Vim.map('<Space>', ':BodyKeyEvent <Space> Space', 'normal');
|
||||
CodeMirror.Vim.map('<C-h>', ':BodyKeyEvent <C-h> KeyH', 'normal');
|
||||
CodeMirror.Vim.map('<C-l>', ':BodyKeyEvent <C-l> KeyL', 'normal');
|
||||
CodeMirror.Vim.map('<C-6>', ':BodyKeyEvent <C-o> Digit6', 'normal');
|
||||
CodeMirror.Vim.map('<C-h>', ':BodyKeyEvent <C-h> KeyH', 'insert');
|
||||
CodeMirror.Vim.map('<C-l>', ':BodyKeyEvent <C-l> KeyL', 'insert');
|
||||
CodeMirror.Vim.map('<C-6>', ':BodyKeyEvent <C-6> Digit6', 'insert');
|
||||
|
||||
CodeMirror.Vim.defineOption('wrap', notesiumState.editorLineWrapping, 'boolean', [], (value, cm) => {
|
||||
if (cm) return; // option is global, do nothing for local
|
||||
if (value === undefined) return notesiumState.editorLineWrapping;
|
||||
notesiumState.editorLineWrapping = value;
|
||||
return value;
|
||||
});
|
||||
|
||||
CodeMirror.Vim.defineOption('conceal', notesiumState.editorConcealFormatting, 'boolean', [], (value, cm) => {
|
||||
if (cm) return; // option is global, do nothing for local
|
||||
if (value === undefined) return notesiumState.editorConcealFormatting;
|
||||
notesiumState.editorConcealFormatting = value;
|
||||
return value;
|
||||
});
|
||||
|
||||
CodeMirror.Vim.defineOption('fold', notesiumState.editorFoldGutter, 'boolean', [], (value, cm) => {
|
||||
if (cm) return; // option is global, do nothing for local
|
||||
if (value === undefined) return notesiumState.editorFoldGutter;
|
||||
notesiumState.editorFoldGutter = value;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
var t = `
|
||||
<div v-if="visible" @keyup.esc="close(false)" class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20" role="dialog" aria-modal="true" >
|
||||
<div @click="close(false)" class="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" aria-hidden="true"></div>
|
||||
<div class="max-w-xl overflow-y-auto mx-auto transform overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black ring-opacity-5">
|
||||
<input ref="modalAutoFocus" autofocus type="text" class="sr-only" aria-hidden="true" />
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div class="relative w-full transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all">
|
||||
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900" v-text="config.title"></h3>
|
||||
<pre class="mt-3 text-sm text-gray-500 font-sans whitespace-pre-wrap break-words" v-text="config.body"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button @click="close(true)" type="button" class="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto" v-text="config.button"></button>
|
||||
<button @click="close(false)" type="button" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
config: {},
|
||||
visible: false,
|
||||
resolve: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open(config) {
|
||||
this.config = config;
|
||||
this.visible = true;
|
||||
this.$nextTick(() => { this.$refs.modalAutoFocus.focus(); });
|
||||
return new Promise((resolve) => {
|
||||
this.resolve = resolve;
|
||||
});
|
||||
},
|
||||
close(resolve) {
|
||||
this.visible = false;
|
||||
this.resolve(resolve);
|
||||
},
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
var t = `
|
||||
<div>
|
||||
|
||||
<div class="flex items-center pl-1 pr-2">
|
||||
<h2 class="flex-auto text-sm text-gray-900" v-text="formattedMonthYear"></h2>
|
||||
<div class="flex space-x-4 -mr-2">
|
||||
<button @click="changeMonth(-1)" type="button" class="flex flex-none items-center justify-center text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Previous month</span>
|
||||
<Icon name="chevron-right" size="h-5 w-5" class="rotate-180"/>
|
||||
</button>
|
||||
<button @click="setSelectedDate(this.today)" type="button" class="flex flex-none items-center justify-center text-gray-400 hover:text-gray-500">
|
||||
<span class="text-xs mt-1">Today</span>
|
||||
</button>
|
||||
<button @click="changeMonth(1)" type="button" class="flex flex-none items-center justify-center text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Next month</span>
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-7 gap-2 text-center text-gray-500" style="font-size: 0.65rem;">
|
||||
<div v-for="(day, index) in sortedDaysOfWeek" :key="day" v-text="day" class="hover:cursor-pointer hover:underline"
|
||||
title="set as start of week" @click="$notesiumState.startOfWeek = ($notesiumState.startOfWeek + index) % 7"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-7 gap-2 text-center pb-4 select-none items-center justify-items-center justify-center" style="font-size: 0.65rem;">
|
||||
<div v-for="day in displayedMonthDates" :key="day.date"
|
||||
@click="setSelectedDate(day.date)"
|
||||
@dblclick="$emit('date-dblclick', day.date)"
|
||||
:class="selectedDate === day.date ? (day.isToday ? 'bg-indigo-500' : 'bg-gray-500') : 'hover:bg-gray-200'"
|
||||
class="flex flex-col h-6 w-6 hover:cursor-pointer rounded-full">
|
||||
<span v-text="day.day" class="mt-1" :class="[
|
||||
selectedDate === day.date ? 'text-white' : day.isToday ? 'text-indigo-500' : day.isCurrentMonth ? 'text-gray-900' : 'text-gray-400',
|
||||
(selectedDate === day.date || day.isToday) ? 'font-semibold' : '']"></span>
|
||||
<span class="-mt-1.5">
|
||||
<span v-text="getDotForType(day.date, 'weekly')" :class="selectedDate == day.date ? 'text-white' : 'text-emerald-500'"></span>
|
||||
<span v-text="getDotForType(day.date, 'daily')" :class="selectedDate == day.date ? 'text-white' : 'text-indigo-300'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
import { formatDate } from './dateutils.js';
|
||||
export default {
|
||||
props: {
|
||||
dottedDates: { type: Object, default: {} },
|
||||
},
|
||||
emits: ['date-selected', 'date-dblclick'],
|
||||
components: { Icon },
|
||||
data() {
|
||||
return {
|
||||
today: null,
|
||||
selectedDate: null,
|
||||
displayedMonth: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDotForType(dateStr, type) {
|
||||
const noteTypes = this.dottedDates[dateStr];
|
||||
if (noteTypes && noteTypes.includes(type)) return '•';
|
||||
return '';
|
||||
},
|
||||
getCalendarDays(year, month) {
|
||||
const startDate = new Date(year, month, 1);
|
||||
const endDate = new Date(year, month + 1, 0); // Last day of the month
|
||||
const days = [];
|
||||
|
||||
// Previous month days
|
||||
let startDayOfWeek = startDate.getDay() - this.$notesiumState.startOfWeek;
|
||||
if (startDayOfWeek < 0) startDayOfWeek += 7;
|
||||
for (let i = startDayOfWeek; i > 0; i--) {
|
||||
const date = new Date(year, month, 1 - i);
|
||||
days.push({
|
||||
date: formatDate(date, '%Y-%m-%d'),
|
||||
day: date.getDate(),
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let day = 1; day <= endDate.getDate(); day++) {
|
||||
const date = new Date(year, month, day);
|
||||
const dateStr = formatDate(date, '%Y-%m-%d');
|
||||
days.push({
|
||||
date: dateStr,
|
||||
day: day,
|
||||
isCurrentMonth: true,
|
||||
isToday: dateStr === this.today,
|
||||
});
|
||||
}
|
||||
|
||||
// Next month days to complete the week
|
||||
let endDayOfWeek = endDate.getDay();
|
||||
let daysToAdd = 6 - ((endDayOfWeek - this.$notesiumState.startOfWeek + 7) % 7);
|
||||
if ((days.length + daysToAdd) == 35) daysToAdd += 7;
|
||||
for (let i = 1; i <= daysToAdd; i++) {
|
||||
const date = new Date(year, month + 1, i);
|
||||
days.push({
|
||||
date: formatDate(date, '%Y-%m-%d'),
|
||||
day: date.getDate(),
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
},
|
||||
setSelectedDate(dateStr) {
|
||||
this.selectedDate = dateStr;
|
||||
this.$emit('date-selected', this.selectedDate);
|
||||
const dateParts = this.selectedDate.split('-');
|
||||
const selectedYear = parseInt(dateParts[0], 10);
|
||||
const selectedMonth = parseInt(dateParts[1], 10);
|
||||
const displayedMonthDate = new Date(this.displayedMonth);
|
||||
if (selectedYear !== displayedMonthDate.getFullYear() || selectedMonth !== displayedMonthDate.getMonth() + 1) {
|
||||
this.displayedMonth = new Date(selectedYear, selectedMonth - 1, 1);
|
||||
}
|
||||
},
|
||||
changeMonth(increment) {
|
||||
this.displayedMonth = new Date(this.displayedMonth.getFullYear(), this.displayedMonth.getMonth() + increment, 1);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
formattedMonthYear() {
|
||||
return this.displayedMonth.toLocaleString('default', { month: 'short', year: 'numeric' });
|
||||
},
|
||||
displayedMonthDates() {
|
||||
const year = this.displayedMonth.getFullYear();
|
||||
const month = this.displayedMonth.getMonth(); // getMonth is 0-indexed
|
||||
return this.getCalendarDays(year, month)
|
||||
},
|
||||
sortedDaysOfWeek() {
|
||||
const daysOfWeek = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||
return [...daysOfWeek.slice(this.$notesiumState.startOfWeek), ...daysOfWeek.slice(0, this.$notesiumState.startOfWeek)];
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.displayedMonth = new Date();
|
||||
this.today = formatDate(this.displayedMonth, '%Y-%m-%d');
|
||||
},
|
||||
mounted() {
|
||||
this.setSelectedDate(this.today);
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export function formatDate(date, format) {
|
||||
const padZero = (num) => { return num.toString().padStart(2, '0'); }
|
||||
const replacements = {
|
||||
'%Y': date.getFullYear(),
|
||||
'%m': padZero(date.getMonth() + 1),
|
||||
'%d': padZero(date.getDate()),
|
||||
'%H': padZero(date.getHours()),
|
||||
'%M': padZero(date.getMinutes()),
|
||||
'%S': padZero(date.getSeconds()),
|
||||
'%A': date.toLocaleString('en-US', { weekday: 'long' }),
|
||||
'%a': date.toLocaleString('en-US', { weekday: 'short' }),
|
||||
'%b': date.toLocaleString('en-US', { month: 'short' }),
|
||||
'%u': date.getDay() === 0 ? 7 : date.getDay(),
|
||||
'%U': padZero(getSunWeekNum(date)),
|
||||
'%V': padZero(getISOWeekNum(date)),
|
||||
};
|
||||
return format.replace(/%[a-zA-Z]/g, match => replacements[match]);
|
||||
}
|
||||
|
||||
function getISOWeekNum(date) {
|
||||
const tempDate = new Date(date.getTime());
|
||||
tempDate.setHours(0, 0, 0, 0);
|
||||
tempDate.setDate(tempDate.getDate() + 3 - (tempDate.getDay() + 6) % 7);
|
||||
const week1 = new Date(tempDate.getFullYear(), 0, 4);
|
||||
return 1 + Math.round(((tempDate.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
|
||||
}
|
||||
|
||||
function getSunWeekNum(date) {
|
||||
const sunday = new Date(date);
|
||||
sunday.setDate(date.getDate() - date.getDay());
|
||||
const firstSunday = new Date(sunday.getFullYear(), 0, 1);
|
||||
if (firstSunday.getDay() !== 0) firstSunday.setMonth(0, 1 + (7 - firstSunday.getDay()) % 7);
|
||||
return 1 + Math.floor((sunday - firstSunday) / 604800000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
var t = `
|
||||
<div class="mx-auto w-96">
|
||||
<h3 class="mt-40 font-medium text-gray-400 text-xl mx-auto text-center">notesium</h3>
|
||||
<div class="mt-10">
|
||||
<template v-for="entry in entries">
|
||||
<div class="flex items-center justify-between p-4 text-sm font-medium cursor-pointer hover:bg-gray-100 rounded-xl"
|
||||
@click="$emit(entry.emit[0], entry.emit[1])">
|
||||
<div class="flex items-center space-x-4 text-gray-500">
|
||||
<Icon :name="entry.icon" size="h-4 w-4" />
|
||||
<span v-text="entry.title"></span>
|
||||
</div>
|
||||
<span class="whitespace-nowrap text-gray-900 font-mono mt-1" v-text="entry.keybind"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { Icon },
|
||||
emits: ['note-new', 'note-daily', 'finder-open'],
|
||||
data() {
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
title: 'New note',
|
||||
keybind: 'space n n',
|
||||
icon: 'outline-plus',
|
||||
emit: ['note-new'],
|
||||
},
|
||||
{
|
||||
title: 'Daily note',
|
||||
keybind: 'space n d',
|
||||
icon: 'outline-calendar',
|
||||
emit: ['note-daily'],
|
||||
},
|
||||
{
|
||||
title: 'List notes',
|
||||
keybind: 'space n l',
|
||||
icon: 'mini-bars-three-bottom-left',
|
||||
emit: ['finder-open', '/api/raw/list?color=true&prefix=label&sort=alpha'],
|
||||
},
|
||||
{
|
||||
title: 'Search notes',
|
||||
keybind: 'space n s',
|
||||
icon: 'mini-magnifying-glass',
|
||||
emit: ['finder-open', '/api/raw/lines?color=true&prefix=title'],
|
||||
},
|
||||
{
|
||||
title: 'Graph view',
|
||||
keybind: 'space n g',
|
||||
icon: 'graph',
|
||||
emit: ['graph-open'],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="-25 -25 562 562"><path fill-rule="nonzero" d="M256.002 0c70.69 0 134.688 28.658 181.017 74.981C483.343 121.311 512 185.309 512 255.998c0 70.69-28.657 134.694-74.981 181.018C390.693 483.342 326.692 512 256.002 512s-134.694-28.655-181.017-74.984C28.655 390.692 0 326.688 0 255.998c0-70.689 28.658-134.69 74.985-181.017C121.308 28.658 185.312 0 256.002 0zm26.853 349.763l-45.906-66.608c-1.601-2.202-2.601-7.001-3.001-14.401h-1.201v81.009H172.74V162.24h56.406l45.906 66.608c1.601 2.202 2.601 7.001 3 14.401h1.202V162.24h60.006v187.523h-56.405zm126.972-247.589C370.462 62.812 316.074 38.46 256.002 38.46c-60.073 0-114.464 24.352-153.825 63.714-39.365 39.364-63.716 93.752-63.716 153.824 0 60.073 24.351 114.464 63.716 153.825 39.361 39.365 93.752 63.717 153.825 63.717 60.072 0 114.46-24.352 153.825-63.717 39.362-39.361 63.713-93.752 63.713-153.825 0-60.072-24.351-114.46-63.713-153.824z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,107 @@
|
||||
var t = `
|
||||
<div @keyup.esc="handleSelection(null)" class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20" role="dialog" aria-modal="true" >
|
||||
<div @click="handleSelection(null)" class="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" aria-hidden="true"></div>
|
||||
<div :class="(small && !preview) ? 'max-w-2xl h-96' : 'w-full h-full'"
|
||||
class="mx-auto flex flex-col transform overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black ring-opacity-5">
|
||||
<div class="relative group flex items-center justify-items-center justify-between space-x-2 border-b border-gray-200">
|
||||
<input ref="queryInput" v-model="query" autofocus placeholder="filter..." autocomplete="off" spellcheck="false"
|
||||
@blur="$refs.queryInput && $refs.queryInput.focus()"
|
||||
@keydown.down.prevent="selectDown()"
|
||||
@keydown.up.prevent="selectUp()"
|
||||
@keydown.ctrl.j.prevent="selectDown()"
|
||||
@keydown.ctrl.k.prevent="selectUp()"
|
||||
@keydown.ctrl.p.prevent="preview=!preview"
|
||||
@keyup.enter.prevent="(filteredItems.length === 0) ? undefined : handleSelection(selected)"
|
||||
@keydown.tab.prevent
|
||||
class="h-12 bg-gray-100 w-full border-0 m-2 rounded-lg px-4 text-gray-900 placeholder:text-gray-400 ring-0 focus:outline-none text-sm" />
|
||||
<p class="text-sm px-4">{{ filteredItems.length }}/{{ itemsLength }}</p>
|
||||
</div>
|
||||
<div :class="(preview) ? 'grid-cols-2' : 'grid-cols-1'" class="h-full overflow-y-auto grid divide-x divide-gray-200">
|
||||
<ul role="listbox" class="max-h-full overflow-y-auto py-1 text-sm font-mono text-gray-800 focus:outline-none">
|
||||
<template v-for="item, index in filteredItems">
|
||||
<li :id="'item'+index" @click="selected = index" @dblclick="handleSelection(index)" role="option"
|
||||
:class="{'!bg-blue-500 text-white': index === selected }"
|
||||
class="hover:bg-indigo-600/10 cursor-pointer select-none px-4 py-1 whitespace-nowrap truncate">
|
||||
<b v-if="item.Colored" v-text="item.Colored"></b> {{ item.Content }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="max-h-full overflow-y-auto p-1 text-sm font-mono text-gray-800">
|
||||
<template v-if="filteredItems.length > 0 && preview">
|
||||
<Preview :filename=filteredItems[selected].Filename :lineNumber=filteredItems[selected].Linenum />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
import Preview from './preview.js'
|
||||
export default {
|
||||
components: { Preview },
|
||||
props: ['uri', 'small', 'initialQuery'],
|
||||
emits: ['finder-selection'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
items: [],
|
||||
selected: 0,
|
||||
preview: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleSelection(selected) {
|
||||
this.$emit('finder-selection', selected !== null ? this.filteredItems[selected] : null);
|
||||
},
|
||||
selectUp() {
|
||||
if (this.selected !== 0) {
|
||||
this.selected -= 1; this.scrollIntoView(`item${this.selected}`)
|
||||
}
|
||||
},
|
||||
selectDown() {
|
||||
if (this.selected !== this.filteredItems.length - 1) {
|
||||
this.selected += 1; this.scrollIntoView(`item${this.selected}`)
|
||||
}
|
||||
},
|
||||
scrollIntoView(id) {
|
||||
document.getElementById(id).scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
fetchRaw(uri) {
|
||||
this.query = '';
|
||||
this.selected = 0;
|
||||
fetch(uri)
|
||||
.then(response => response.text())
|
||||
.then(text => {
|
||||
const PATTERN = /^(.*?):(.*?):\s*(?:\x1b\[0;36m(.*?)\x1b\[0m\s*)?(.*)$/
|
||||
this.items = text.trim().split('\n').map(line => {
|
||||
const matches = PATTERN.exec(line);
|
||||
if (!matches) return null;
|
||||
const Filename = matches[1];
|
||||
const Linenum = parseInt(matches[2], 10);
|
||||
const Colored = matches[3] || '';
|
||||
const Content = matches[4];
|
||||
const SearchStr = Colored ? `${Colored} ${Content}`.toLowerCase() : Content.toLowerCase();
|
||||
return { Filename, Linenum, Colored, Content, SearchStr };
|
||||
}).filter(Boolean);
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
itemsLength() {
|
||||
return this.items.length;
|
||||
},
|
||||
filteredItems() {
|
||||
this.selected = 0;
|
||||
const maxItems = 300;
|
||||
const queryWords = this.query.toLowerCase().split(' ');
|
||||
return !this.query
|
||||
? this.items.slice(0, maxItems)
|
||||
: this.items.filter(item => (queryWords.every(queryWord => item.SearchStr.includes(queryWord)))).slice(0, maxItems);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.preview = this.small ? false : this.preview;
|
||||
this.fetchRaw(this.uri);
|
||||
this.$nextTick(() => { this.query = this.initialQuery ? this.initialQuery : ''; this.$refs.queryInput.focus(); });
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
var t = `
|
||||
<div class="forcegraph h-full w-full overflow-hidden text-gray-200" ref="forcegraph"></div>
|
||||
`
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'graphData',
|
||||
'emphasizeNodeIds',
|
||||
'initialTransform',
|
||||
'display',
|
||||
'forces',
|
||||
],
|
||||
emits: ['title-click'],
|
||||
data() {
|
||||
return {
|
||||
zoomTransform: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initGraph() {
|
||||
const vm = this;
|
||||
vm.$refs.forcegraph.innerHTML = "";
|
||||
|
||||
const links = this.graphData.links.map(d => Object.create(d));
|
||||
const nodes = this.graphData.nodes.map(d => Object.create(d));
|
||||
|
||||
const svg = d3.select(vm.$refs.forcegraph).append("svg")
|
||||
.style("height", "inherit")
|
||||
.style("width", "inherit")
|
||||
.attr("viewBox", [-140, -180, 320, 360]);
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force("link", d3.forceLink(links).id(d => d.id))
|
||||
.force("charge", d3.forceManyBody())
|
||||
.force("collide", d3.forceCollide())
|
||||
.force("center", d3.forceCenter())
|
||||
.force("x", d3.forceX())
|
||||
.force("y", d3.forceY());
|
||||
|
||||
const link = svg.append("g")
|
||||
.classed("link", true)
|
||||
.attr("stroke", "currentColor")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.join("line");
|
||||
|
||||
const node = svg.append("g")
|
||||
.selectAll("circle")
|
||||
.data(nodes)
|
||||
.join("circle")
|
||||
.attr("r", 2)
|
||||
.classed("node", true)
|
||||
.classed("node-label", n => (n.isLabel))
|
||||
.call(drag(simulation));
|
||||
|
||||
const title = svg.append("g")
|
||||
.selectAll("circle")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("text")
|
||||
.classed("title", true)
|
||||
.on("click", function(event, node) { event.stopPropagation(); vm.$emit('title-click', node.id); })
|
||||
.text(node => node.title);
|
||||
|
||||
const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', function(event) {
|
||||
svg.selectAll('g').attr('transform', event.transform);
|
||||
if (vm.display.scaleTitles.value) scaleTitlesByZoomLevel(event.transform.k);
|
||||
vm.zoomTransform = {
|
||||
k: event.transform.k,
|
||||
x: event.transform.x,
|
||||
y: event.transform.y
|
||||
}
|
||||
});
|
||||
svg.call(zoom);
|
||||
|
||||
if (vm.initialTransform) {
|
||||
const {k, x, y} = vm.initialTransform;
|
||||
const transform = d3.zoomIdentity.translate(x, y).scale(k);
|
||||
svg.selectAll('g').attr('transform', transform);
|
||||
svg.call(zoom.transform, transform);
|
||||
if (vm.display.scaleTitles.value) scaleTitlesByZoomLevel(k);
|
||||
}
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
title
|
||||
.attr('x', d => d.x + 4).attr('y', d => d.y);
|
||||
});
|
||||
|
||||
function drag(simulation) {
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
return d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended);
|
||||
}
|
||||
|
||||
function scaleTitlesByZoomLevel(k) {
|
||||
const titleSize = k > 0.9 ? 5 - k : 0;
|
||||
svg.selectAll('.title').transition().style("font-size", titleSize + "px");
|
||||
}
|
||||
|
||||
vm.$watch('display.scaleTitles.value', function(enabled) {
|
||||
const k = enabled ? d3.zoomTransform(svg.node()).k : 1;
|
||||
scaleTitlesByZoomLevel(k);
|
||||
});
|
||||
|
||||
vm.$watch('display.showTitles.value', function(enabled) {
|
||||
svg.selectAll('.title').classed("hidden", !enabled);
|
||||
});
|
||||
|
||||
function emphasizeNodes(nodeIds) {
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
const linkedNodeIds = Array.from(new Set(vm.graphData.links
|
||||
.filter(l => nodeIds.includes(l.source) || nodeIds.includes(l.target))
|
||||
.flatMap(l => [l.source, l.target])));
|
||||
|
||||
node.attr("fill-opacity", 0.05);
|
||||
title.attr("fill-opacity", 0.15).attr("font-weight", "normal");
|
||||
link.attr("stroke", "currentColor").attr("stroke-opacity", 0.15);
|
||||
|
||||
node.filter(n => linkedNodeIds.includes(n.id)).attr("fill-opacity", 0.3);
|
||||
title.filter(t => linkedNodeIds.includes(t.id)).attr("fill-opacity", 1);
|
||||
|
||||
node.filter(n => nodeIds.includes(n.id)).attr("fill-opacity", 1);
|
||||
title.filter(t => nodeIds.includes(t.id)).attr("fill-opacity", 1).attr("font-weight", "bold");
|
||||
link.filter(l => nodeIds.includes(l.source.id) || nodeIds.includes(l.target.id)).attr("stroke-opacity", 1);
|
||||
|
||||
} else {
|
||||
node.attr("fill-opacity", 1)
|
||||
title.attr("fill-opacity", 1).attr("font-weight", "normal");
|
||||
link.attr("stroke", "currentColor").attr("stroke-opacity", 1);
|
||||
}
|
||||
}
|
||||
|
||||
vm.$watch('emphasizeNodeIds', nodeIds => emphasizeNodes(nodeIds));
|
||||
if (this.emphasizeNodeIds?.length > 0) emphasizeNodes(this.emphasizeNodeIds);
|
||||
|
||||
vm.$watch('display.dynamicNodeRadius.value', function(enabled) {
|
||||
function getLinkCount(nodeId) {
|
||||
return vm.graphData.links.reduce((count, link) => (link.source === nodeId || link.target === nodeId) ? count + 1 : count, 0);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
const totalNodes = vm.graphData.nodes.length;
|
||||
const linkCounts = vm.graphData.nodes.map(n => getLinkCount(n.id));
|
||||
const maxLinks = Math.max(...linkCounts);
|
||||
|
||||
const minBaseRadius = 1;
|
||||
const maxBaseRadius = 5;
|
||||
const baseRadius = Math.max(minBaseRadius, Math.min(maxBaseRadius, 5 - totalNodes / 5));
|
||||
|
||||
const minRadiusIncrement = 0.1;
|
||||
const maxRadiusIncrement = 0.5;
|
||||
const radiusIncrement = Math.max(minRadiusIncrement, Math.min(maxRadiusIncrement, 5 / maxLinks));
|
||||
|
||||
node.attr("r", n => baseRadius + (getLinkCount(n.id) * radiusIncrement));
|
||||
} else {
|
||||
node.attr("r", 2);
|
||||
}
|
||||
});
|
||||
|
||||
vm.$watch('forces.chargeStrength.value', function(value) {
|
||||
simulation.force("charge", d3.forceManyBody().strength(value));
|
||||
simulation.alpha(1).restart();
|
||||
});
|
||||
|
||||
vm.$watch('forces.collideRadius.value', function(value) {
|
||||
simulation.force("collide").radius(value);
|
||||
simulation.alpha(1).restart();
|
||||
});
|
||||
|
||||
vm.$watch('forces.collideStrength.value', function(value) {
|
||||
simulation.force("collide").strength(value);
|
||||
simulation.alpha(1).restart();
|
||||
});
|
||||
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initGraph();
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
var t = `
|
||||
<Pane name="graphPanel" :defaultWidth="540" :minWidth="100">
|
||||
<div class="h-full w-full border-r border-gray-300 bg-gray-50">
|
||||
|
||||
<div class="flex flex-nowrap max-w-full w-full h-9 overflow-x-hidden items-center content-center px-2 mr-6 bg-gray-200">
|
||||
<div class="relative h-full text-gray-50">
|
||||
<svg class="absolute right-0 bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z"></path></svg>
|
||||
</div>
|
||||
<div class="flex rounded-t-lg justify-between basis-52 truncate text-xs h-full items-center pl-3 pr-2 bg-gray-50 text-gray-800">
|
||||
<span class="truncate pt-px">graph view</span>
|
||||
<span title="close" @click="$notesiumState.showGraphPanel=false" class="hover:bg-gray-300 hover:rounded-full cursor-pointer">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative h-full text-gray-50">
|
||||
<svg class="absolute bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!showSettings" class="absolute top-9 right-0 p-3">
|
||||
<div class="border border-gray-200 rounded-md backdrop-blur-md">
|
||||
<span title="settings" @click="showSettings=true"
|
||||
class="h-8 px-2 cursor-pointer inline-flex items-center justify-items-center text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showSettings" class="absolute top-9 right-0 p-3 w-60">
|
||||
<div class="flex flex-1 flex-col border border-gray-200 rounded-md backdrop-blur-md space-y-1">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<input ref="queryInput" v-model="query" placeholder="filter..." autocomplete="off" spellcheck="false"
|
||||
@keyup.esc="query=''"
|
||||
@keyup.enter.prevent
|
||||
@keydown.tab.prevent
|
||||
class="h-8 w-full p-2 focus:outline-none backdrop-blur-md bg-inherit text-sm text-gray-700 placeholder:text-gray-400" />
|
||||
<span title="settings" @click="showSettings=false"
|
||||
class="h-8 pr-2 cursor-pointer inline-flex items-center justify-items-center text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<details class="w-full cursor-pointer [&_svg]:open:rotate-90">
|
||||
<summary class="flex py-1 px-2 items-center justify-items-center justify-between hover:cursor-pointer focus:outline-none">
|
||||
<span class="text-sm font-medium leading-6 text-gray-700">display</span>
|
||||
<span class="text-gray-400 -mr-px"><Icon name="chevron-right" size="h-5 w-5" /></span>
|
||||
</summary>
|
||||
<ul class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in display" :key="key" @click="option.value=!option.value"
|
||||
class="flex items-center justify-items-center justify-between block p-2 pr-0">
|
||||
<label v-text="option.title"></label>
|
||||
<input v-model="option.value" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500" />
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<details class="w-full cursor-pointer [&_svg]:open:rotate-90">
|
||||
<summary class="flex py-1 px-2 items-center justify-items-center justify-between hover:cursor-pointer focus:outline-none">
|
||||
<span class="text-sm font-medium leading-6 text-gray-700">forces</span>
|
||||
<span class="text-gray-400 -mr-px"><Icon name="chevron-right" size="h-5 w-5" /></span>
|
||||
</summary>
|
||||
<ul class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in forces" :key="key"
|
||||
class="items-center justify-items-center justify-between block p-2 pr-0">
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span v-text="option.title"></span>
|
||||
<span v-text="option.value"></span>
|
||||
</div>
|
||||
<input class="w-full cursor-pointer" type="range" v-model="option.value" :min="option.min" :max="option.max" :step="option.step" />
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphD3 ref="forcegraph" v-if="graphData"
|
||||
:graphData=graphData
|
||||
:display=display
|
||||
:forces=forces
|
||||
:emphasizeNodeIds=emphasizeNodeIds
|
||||
:initialTransform=initialTransform
|
||||
@title-click="$emit('note-open', $event)"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</Pane>
|
||||
`
|
||||
|
||||
import Pane from './pane.js'
|
||||
import Icon from './icon.js'
|
||||
import GraphD3 from './graph-d3.js'
|
||||
export default {
|
||||
components: { Pane, Icon, GraphD3 },
|
||||
props: ['activeTabId', 'lastSave'],
|
||||
emits: ['note-open'],
|
||||
data() {
|
||||
return {
|
||||
graphData: null,
|
||||
initialTransform: null,
|
||||
query: '',
|
||||
showSettings: false,
|
||||
display: {
|
||||
showTitles: { value: true, title: 'show titles' },
|
||||
scaleTitles: { value: true, title: 'auto-scale titles' },
|
||||
dynamicNodeRadius: { value: false, title: 'size nodes per links' },
|
||||
emphasizeActive: { value: true, title: 'emphasize active note' },
|
||||
},
|
||||
forces: {
|
||||
chargeStrength: { value: -30, min: -100, max: 0, step: 1, title: 'repel force' },
|
||||
collideRadius: { value: 1, min: 1, max: 50, step: 1, title: 'collide radius' },
|
||||
collideStrength: { value: 0.5, min: 0, max: 1, step: 0.05, title: 'collide strength' },
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchGraph() {
|
||||
fetch("/api/notes")
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(response => {
|
||||
let nodes = [];
|
||||
let links = [];
|
||||
const notes = Object.values(response);
|
||||
notes.forEach(note => {
|
||||
nodes.push({ id: note.Filename, title: note.Title, isLabel: note.IsLabel });
|
||||
if (note.OutgoingLinks) {
|
||||
note.OutgoingLinks.forEach(link => {
|
||||
if (link.Title !== '') links.push({ source: note.Filename, target: link.Filename });
|
||||
});
|
||||
}
|
||||
});
|
||||
this.graphData = { nodes, links };
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
emphasizeNodeIds() {
|
||||
if (this.showSettings && this.query) {
|
||||
const queryWords = this.query.toLowerCase().split(' ');
|
||||
return this.graphData.nodes.filter(node => queryWords.every(queryWord => node.title.toLowerCase().includes(queryWord))).map(node => node.id);
|
||||
}
|
||||
return (this.display.emphasizeActive.value && this.activeTabId) ? [this.activeTabId] : null;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchGraph();
|
||||
},
|
||||
watch: {
|
||||
'lastSave': function() {
|
||||
this.initialTransform = this.$refs.forcegraph.zoomTransform;
|
||||
this.graphData = null;
|
||||
this.fetchGraph();
|
||||
},
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
var t = `
|
||||
<div class="relative inset-0 z-50" aria-labelledby="graph" role="dialog" aria-modal="true">
|
||||
<div @click="$emit('graph-close');" class="fixed inset-0" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex">
|
||||
<div class="w-screen pointer-events-auto">
|
||||
<div class="flex flex-col h-full bg-white pb-6 shadow-xl">
|
||||
|
||||
<div class="absolute top-0 right-0 flex items-center justify-items-center space-x-3 p-2">
|
||||
<div title="close" @click="$emit('graph-close')"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-x-mark" size="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 left-0 w-60 p-3">
|
||||
<div class="flex flex-1 flex-col overflow-y-auto border border-gray-200 rounded-md backdrop-blur-md bg-gray-50/10 space-y-1">
|
||||
<div class="flex space-x-1" :class="{ 'border-b border-gray-200': showSettings }">
|
||||
<span title="settings" @click="showSettings=!showSettings"
|
||||
class="h-10 px-2 cursor-pointer inline-flex items-center justify-items-center text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</span>
|
||||
<input ref="queryInput" v-model="query" autofocus placeholder="filter notes..." autocomplete="off" spellcheck="false"
|
||||
@keyup.esc="selectedNodeId ? (query = '', selectedNodeId = '') : $emit('graph-close');"
|
||||
@keyup.enter.prevent="(emphasizeNodeIds.length === 1) ? selectedNodeId = emphasizeNodeIds[0] : undefined"
|
||||
@keydown.tab.prevent
|
||||
@blur="$refs.queryInput && $refs.queryInput.focus()"
|
||||
class="h-10 w-full py-2 pr-2 focus:outline-none backdrop-blur-md bg-inherit text-sm text-gray-700 placeholder:text-gray-400" />
|
||||
</div>
|
||||
<div v-show="showSettings">
|
||||
<div @click="showSettingsDisplay=!showSettingsDisplay"
|
||||
class="hover:bg-gray-50 cursor-pointer flex items-center w-full text-left p-2 space-x-3">
|
||||
<span class="text-gray-400 shrink-0" :class="{ 'rotate-90' : showSettingsDisplay }">
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm leading-6 text-gray-700">display</span>
|
||||
</div>
|
||||
<ul v-show="showSettingsDisplay" class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in display" :key="key" @click="option.value=!option.value"
|
||||
class="flex items-center justify-items-center justify-between hover:bg-gray-50 block rounded-md py-2 pr-2 pl-8">
|
||||
<label v-text="option.title"></label>
|
||||
<input v-model="option.value" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500" />
|
||||
</li>
|
||||
</ul>
|
||||
<div @click="showSettingsForces=!showSettingsForces"
|
||||
class="hover:bg-gray-50 cursor-pointer flex items-center w-full text-left p-2 gap-x-3">
|
||||
<span class="text-gray-400 shrink-0" :class="{ 'rotate-90' : showSettingsForces }">
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm leading-6 text-gray-700">forces</span>
|
||||
</div>
|
||||
<ul v-show="showSettingsForces" class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in forces" :key="key"
|
||||
class="items-center justify-items-center justify-between block rounded-md py-2 pr-2 pl-8">
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span v-text="option.title"></span>
|
||||
<span v-text="option.value"></span>
|
||||
</div>
|
||||
<input class="w-full" type="range" v-model="option.value" :min="option.min" :max="option.max" :step="option.step" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNodeId" class="absolute top-0 right-0 w-[38rem] flex flex-col h-full bg-white shadow-xl">
|
||||
<div class="flex items-center justify-end mx-2 pt-2 space-x-2">
|
||||
<span title="open for editing" @click="$emit('note-open', selectedNodeId); $emit('graph-close')"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="pencil-solid" size="h-5 w-5 p-px" />
|
||||
</span>
|
||||
<span title="close preview" @click="selectedNodeId=''"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-x-mark" size="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-full pl-4 pb-4 mr-1 overflow-y-auto">
|
||||
<Preview clickableLinks=true appendIncomingLinks=true :filename=selectedNodeId @note-open="selectedNodeId = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphD3 v-if="graphData"
|
||||
:graphData=graphData
|
||||
:emphasizeNodeIds=emphasizeNodeIds
|
||||
:display=display
|
||||
:forces=forces
|
||||
@click="(query = '', selectedNodeId = '')"
|
||||
@title-click="(query = '', selectedNodeId = $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
import GraphD3 from './graph-d3.js'
|
||||
import Preview from './preview.js'
|
||||
export default {
|
||||
components: { Icon, GraphD3, Preview },
|
||||
emits: ['graph-close', 'note-open'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
nodes: [],
|
||||
graphData: null,
|
||||
selectedNodeId: '',
|
||||
showSettings: false,
|
||||
showSettingsDisplay: true,
|
||||
showSettingsForces: false,
|
||||
display: {
|
||||
showTitles: { value: true, title: 'show titles' },
|
||||
scaleTitles: { value: true, title: 'auto-scale titles' },
|
||||
dynamicNodeRadius: { value: false, title: 'size nodes per links' },
|
||||
},
|
||||
forces: {
|
||||
chargeStrength: { value: -30, min: -100, max: 0, step: 1, title: 'repel force' },
|
||||
collideRadius: { value: 1, min: 1, max: 50, step: 1, title: 'collide radius' },
|
||||
collideStrength: { value: 0.5, min: 0, max: 1, step: 0.05, title: 'collide strength' },
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchGraph() {
|
||||
fetch("/api/notes")
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(response => {
|
||||
let nodes = [];
|
||||
let links = [];
|
||||
const notes = Object.values(response);
|
||||
notes.forEach(note => {
|
||||
nodes.push({ id: note.Filename, title: note.Title, isLabel: note.IsLabel });
|
||||
if (note.OutgoingLinks) {
|
||||
note.OutgoingLinks.forEach(link => {
|
||||
if (link.Title !== '') links.push({ source: note.Filename, target: link.Filename });
|
||||
});
|
||||
}
|
||||
});
|
||||
this.nodes = nodes;
|
||||
this.graphData = { nodes, links };
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
emphasizeNodeIds() {
|
||||
if (this.query) {
|
||||
const queryWords = this.query.toLowerCase().split(' ');
|
||||
return this.nodes.filter(node => queryWords.every(queryWord => node.title.toLowerCase().includes(queryWord))).map(node => node.id);
|
||||
}
|
||||
return this.selectedNodeId ? [this.selectedNodeId] : [];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchGraph();
|
||||
},
|
||||
created() {
|
||||
this.$nextTick(() => { this.$refs.queryInput.focus(); });
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
var t = `
|
||||
<svg v-if="name == 'mini-bars-three-bottom-left'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-arrows-right-left'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M13.2 2.24a.75.75 0 00.04 1.06l2.1 1.95H6.75a.75.75 0 000 1.5h8.59l-2.1 1.95a.75.75 0 101.02 1.1l3.5-3.25a.75.75 0 000-1.1l-3.5-3.25a.75.75 0 00-1.06.04zm-6.4 8a.75.75 0 00-1.06-.04l-3.5 3.25a.75.75 0 000 1.1l3.5 3.25a.75.75 0 101.02-1.1l-2.1-1.95h8.59a.75.75 0 000-1.5H4.66l2.1-1.95a.75.75 0 00.04-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-magnifying-glass'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-x-mark'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-plus'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-check-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-exclamation-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-information-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-code'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-external-link'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-link-slash'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.181 8.68a4.503 4.503 0 0 1 1.903 6.405m-9.768-2.782L3.56 14.06a4.5 4.5 0 0 0 6.364 6.365l3.129-3.129m5.614-5.615 1.757-1.757a4.5 4.5 0 0 0-6.364-6.365l-4.5 4.5c-.258.26-.479.541-.661.84m1.903 6.405a4.495 4.495 0 0 1-1.242-.88 4.483 4.483 0 0 1-1.062-1.683m6.587 2.345 5.907 5.907m-5.907-5.907L8.898 8.898M2.991 2.99 8.898 8.9" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-ellipsis-vertical'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'panel-right'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" :class="size">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path><rect x="4" y="4" width="16" height="16" rx="2"></rect><line x1="15" y1="4" x2="15" y2="20"></line>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'graph'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" :class="size">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path><circle cx="12" cy="5" r="2"></circle><circle cx="5" cy="19" r="2"></circle><circle cx="19" cy="19" r="2"></circle><path d="M6.5 17.5l5.5 -4.5l5.5 4.5"></path><line x1="12" y1="7" x2="12" y2="13"></line>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-bars-arrow-down'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-tag'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-view-columns'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-check'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-queue-list'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-adjustments-horizontal'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'chevron-right'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'pencil-solid'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-calendar'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-trash'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'spinner-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="animate-spin" :class="size">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'micro-arrow-uturn-right'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M3.5 9.75A2.75 2.75 0 0 1 6.25 7h5.19L9.22 9.22a.75.75 0 1 0 1.06 1.06l3.5-3.5a.75.75 0 0 0 0-1.06l-3.5-3.5a.75.75 0 1 0-1.06 1.06l2.22 2.22H6.25a4.25 4.25 0 0 0 0 8.5h1a.75.75 0 0 0 0-1.5h-1A2.75 2.75 0 0 1 3.5 9.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
export default {
|
||||
props: ['name', 'size'],
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Notesium Web</title>
|
||||
<script src="vendor.js"></script>
|
||||
<link rel="stylesheet" href="vendor.css" />
|
||||
<link rel="stylesheet" href="tailwind.css" />
|
||||
<link rel="shortcut icon" href="favicon.svg">
|
||||
<style>
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { margin: 6px 0px 6px 0px; }
|
||||
::-webkit-scrollbar-thumb { background-color: #e5e7eb; border-radius: 5px; }
|
||||
::-webkit-scrollbar:horizontal { height: 10px; }
|
||||
.dark-scroll::-webkit-scrollbar-thumb { background-color: #64748b !important; }
|
||||
|
||||
.forcegraph .node { fill: #ff7f0e; }
|
||||
.forcegraph .node-label { fill: #1f77b4; }
|
||||
.forcegraph .link { stroke-width: 0.5; }
|
||||
.forcegraph .title { fill: #475569; font-size: 4px; cursor: pointer; alignment-baseline: middle; }
|
||||
.forcegraph .title:hover { text-decoration: underline; }
|
||||
|
||||
.cm-s-notesium-light.CodeMirror { color: #1f2937; background: transparent; min-height: 100%; }
|
||||
.cm-s-notesium-light pre.CodeMirror-placeholder { color: #999; }
|
||||
.cm-s-notesium-light span.cm-comment { color: #8f5536; }
|
||||
.cm-s-notesium-light span.cm-quote { color: #64748b; }
|
||||
.cm-s-notesium-light span.cm-formatting-quote { color: #64748b; }
|
||||
.cm-s-notesium-light span.cm-formatting-list-ul { color: #94a3b8; }
|
||||
.cm-s-notesium-light span.cm-url,
|
||||
.cm-s-notesium-light span.cm-link { color: #4338ca; text-decoration: none; }
|
||||
|
||||
.cm-links-hover span.cm-url:hover,
|
||||
.cm-links-hover span.cm-link:hover { cursor: pointer; text-decoration: underline; }
|
||||
.cm-fat-cursor .CodeMirror-cursor { background: #cbd5e1; max-width: 8px; }
|
||||
|
||||
.cm-conceal .CodeMirror span.cm-url,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-em,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-link,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-code,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-strong,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-strikethrough { font-size: 0px; transition: font-size 0.1s ease-in-out; }
|
||||
.cm-unconceal .CodeMirror-activeline span,
|
||||
.cm-unconceal .CodeMirror-selectedline span { font-size: inherit !important; }
|
||||
|
||||
span.cm-hr { color: #94a3b8; }
|
||||
.cm-conceal span.cm-hr { color: transparent; background: #d1d5db; display: inline-block; width: 100%; line-height: 1px; }
|
||||
.cm-unconceal .CodeMirror-activeline span.cm-hr,
|
||||
.cm-unconceal .CodeMirror-selectedline span.cm-hr { color: #94a3b8; background: initial; display: initial; width: initial; line-height: initial;}
|
||||
|
||||
.CodeMirror-dialog {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: inherit;
|
||||
z-index: 15;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px #d1d5db solid;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.CodeMirror-dialog input {
|
||||
border: none;
|
||||
outline: 0;
|
||||
background: 0 0;
|
||||
width: 40em;
|
||||
color: inherit;
|
||||
font-family: monospace;
|
||||
caret-color: black;
|
||||
}
|
||||
|
||||
.cm-s-notesium-light .CodeMirror-gutters { background-color: transparent; border-right: 0px; }
|
||||
.cm-s-notesium-light .CodeMirror-guttermarker-subtle { color: #d1d5db; cursor: pointer; }
|
||||
.cm-s-notesium-light .CodeMirror-guttermarker-subtle:hover { color: black; }
|
||||
.CodeMirror-foldgutter { width: 1.5em; }
|
||||
.CodeMirror-foldgutter-open:after,
|
||||
.CodeMirror-foldgutter-folded:after {
|
||||
content: "\203A";
|
||||
display: inline-block;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
margin-top: -3px;
|
||||
}
|
||||
.CodeMirror-foldgutter-open:after {
|
||||
margin-left: 1px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.cm-foldmarker {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 98%;
|
||||
background-color: #e4e4e790;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.cm-foldmarker:hover {
|
||||
background-color: #e4e4e7;
|
||||
}
|
||||
.cm-foldmarker-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cm-foldmarker-lines {
|
||||
padding-left: 10px;
|
||||
opacity: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body tabindex="-1">
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
const { createApp } = Vue
|
||||
import App from './app.js'
|
||||
import { notesiumState } from './state.js'
|
||||
const app = createApp(App)
|
||||
app.config.globalProperties.$notesiumState = notesiumState;
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
var t = `
|
||||
<div class="text-sm my-1">
|
||||
<div class="flex justify-between">
|
||||
<div class="overflow-hidden truncate">
|
||||
<span class="cursor-pointer hover:font-bold text-gray-500 font-mono pr-2" v-text="expanded ? '-' : '+'" @click="toggle" ></span>
|
||||
<span class="cursor-pointer hover:underline text-indigo-700"
|
||||
v-text="title" :title="title + ' (line: ' + linenum + ')'"
|
||||
@click="$emit('note-open', filename, linenum)"></span>
|
||||
</div>
|
||||
<span v-if="direction" v-text="direction"
|
||||
:class="direction == 'incoming' ? 'bg-green-50 text-green-600 ring-green-500/10' : 'bg-yellow-50 text-yellow-600 ring-yellow-500/10'"
|
||||
class="mr-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset"></span>
|
||||
</div>
|
||||
|
||||
<div class="ml-5" v-if="expanded">
|
||||
<LinkTree v-for="child in children"
|
||||
:title="child.title" :filename="child.filename" :linenum="child.linenum" :direction="child.direction" :key="child.filename + child.linenum"
|
||||
@note-open="(...args) => $emit('note-open', ...args)" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'LinkTree',
|
||||
props: ['filename', 'title', 'linenum', 'direction'],
|
||||
emits: ['note-open'],
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
children: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.expanded = !this.expanded;
|
||||
if (this.expanded && this.children.length === 0) {
|
||||
this.fetchChildren();
|
||||
}
|
||||
},
|
||||
fetchChildren() {
|
||||
fetch('/api/raw/links?color=true&filename=' + this.filename)
|
||||
.then(response => response.text())
|
||||
.then(text => {
|
||||
const PATTERN = /^(.*?):(.*?):\s*(?:\x1b\[0;36m(.*?)\x1b\[0m\s*)?(.*)$/
|
||||
this.children = text.trim().split('\n').map(line => {
|
||||
const matches = PATTERN.exec(line);
|
||||
if (!matches) return null;
|
||||
const Filename = matches[1];
|
||||
const Linenum = parseInt(matches[2], 10);
|
||||
const Colored = matches[3] || '';
|
||||
const Content = matches[4];
|
||||
return { title: Content, filename: Filename, linenum: (Colored == 'outgoing') ? 1 : Linenum, direction: Colored };
|
||||
}).filter(Boolean);
|
||||
});
|
||||
},
|
||||
},
|
||||
template: t
|
||||
}
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
fatal() { echo "Fatal: $*" 1>&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat<<EOF
|
||||
Usage: $0 COMMAND [OPTIONS]
|
||||
|
||||
Commands:
|
||||
all handle vendor, tailwind
|
||||
vendor download, verify and concatenate vendor files
|
||||
tailwind [--watch] build tailwind.css
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
_vendor_files() {
|
||||
CM="https://github.com/alonswartz/notesium-cm5/releases/download"
|
||||
D3="https://cdnjs.cloudflare.com/ajax/libs/d3"
|
||||
cat<<EOF
|
||||
628497cb69df7b1d31236479cad68c9bb3f265060afd5506a0c004b394dfa47e https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js
|
||||
9571811ec12dbfe62834171349cf64bfa206ebd3d2519c16d2b3be4b5862b966 $CM/v5.65.18-2/notesium-cm5.min.js
|
||||
4d507d755e1d3188bd1e95d67b8bc9efd0094576135006fce68c5e9d44303061 $CM/v5.65.18-2/notesium-cm5.min.css
|
||||
d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c $D3/7.8.5/d3.min.js
|
||||
EOF
|
||||
}
|
||||
|
||||
_vendor_get_verify() {
|
||||
SRC="$1"
|
||||
DST="$2"
|
||||
HASH="$3"
|
||||
if [ -f "$DST" ]; then
|
||||
echo -n "$HASH $DST" | sha256sum --strict --check -
|
||||
return 0
|
||||
fi
|
||||
curl -qsL $SRC -o $DST.tmp
|
||||
echo -n "$HASH $DST.tmp" | sha256sum --strict --check -
|
||||
mv $DST.tmp $DST
|
||||
}
|
||||
|
||||
_vendor() {
|
||||
mkdir -p .vendor
|
||||
rm -f vendor.js vendor.css
|
||||
command -v curl >/dev/null || fatal "curl not found"
|
||||
command -v sha256sum >/dev/null || fatal "sha256sum not found"
|
||||
|
||||
while IFS=' ' read -r HASH SRC; do
|
||||
local DST=".vendor/$(basename $SRC)"
|
||||
_vendor_get_verify "$SRC" "$DST" "$HASH"
|
||||
case "$DST" in
|
||||
*.js) cat "$DST" >> vendor.js ;;
|
||||
*.css) cat "$DST" >> vendor.css ;;
|
||||
esac
|
||||
done < <(_vendor_files)
|
||||
sha256sum vendor.js
|
||||
sha256sum vendor.css
|
||||
}
|
||||
|
||||
_tailwind() {
|
||||
# tailwindcss v3.1.6
|
||||
OPTS="$@"
|
||||
command -v tailwindcss >/dev/null || fatal "tailwindcss not found"
|
||||
[ -e "tailwind.input.css" ] || fatal "tailwind.input.css not found"
|
||||
[ -e "tailwind.config.js" ] || fatal "tailwind.config.js not found"
|
||||
tailwindcss $OPTS --minify -i tailwind.input.css -o tailwind.css
|
||||
}
|
||||
|
||||
main() {
|
||||
cd $(dirname $(realpath $0))
|
||||
case $1 in
|
||||
""|-h|--help|help) usage;;
|
||||
all) _vendor; _tailwind;;
|
||||
vendor) _vendor;;
|
||||
tailwind) shift; _tailwind $@;;
|
||||
*) fatal "unrecognized command: $1";;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,122 @@
|
||||
var t = `
|
||||
<TransitionGroup name="nav-tabs" tag="div"
|
||||
leave-active-class="transition-none"
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
move-class="transition-transform duration-250 ease-in-out"
|
||||
class="flex flex-nowrap max-w-full w-full h-9 overflow-x-hidden items-center px-2 mr-6">
|
||||
<div v-for="(tab, index) in getTabs" :key="tab.id" class="flex items-center h-full flex-1 min-w-0 max-w-[13rem]">
|
||||
<div :class="isActive(tab.id) ? 'text-gray-50' : 'text-transparent'" class="relative h-full">
|
||||
<svg class="absolute right-0 bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z"></path></svg>
|
||||
</div>
|
||||
<div
|
||||
@click="$emit('tab-activate', tab.id)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@drop="onDrop"
|
||||
@dragend="onDragEnd"
|
||||
:title="tab.titleHover"
|
||||
:class="isActive(tab.id) ? 'bg-gray-50 text-gray-800' : 'hover:bg-gray-100/75 hover:text-gray-700 text-gray-500'"
|
||||
class="flex items-center justify-between rounded-t-lg text-xs h-full pl-3 pr-2 cursor-pointer w-full truncate">
|
||||
<div class="flex items-center gap-2 truncate min-w-0">
|
||||
<span v-show="tab.isModified" class="inline-block h-2 w-2 rounded-full bg-yellow-400"></span>
|
||||
<span class="truncate" v-text="tab.title"></span>
|
||||
</div>
|
||||
<span @click.stop="handleClose(tab.id, tab.type)" class="hover:bg-gray-300 hover:rounded-full">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div :class="isActive(tab.id) ? 'text-gray-50' : 'text-transparent'" class="relative h-full">
|
||||
<svg class="absolute bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z"></path></svg>
|
||||
</div>
|
||||
<span :class="!isActive(tab.id) ? 'text-gray-300' : 'text-transparent'" class="z-1 -mr-1 -mt-[3px]">|</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { Icon },
|
||||
props: ['tabs', 'activeTabId', 'previousTabId', 'notes'],
|
||||
emits: ['tab-activate', 'tab-move', 'tab-close', 'note-close'],
|
||||
data() {
|
||||
return {
|
||||
dragIndex: null,
|
||||
dragOverEnabled: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDragStart(index) {
|
||||
this.$emit('tab-activate', this.tabs[index].id)
|
||||
this.dragIndex = index;
|
||||
},
|
||||
onDragOver(overIndex) {
|
||||
if (!this.dragOverEnabled) return;
|
||||
if (this.dragIndex === null || this.dragIndex === overIndex) return;
|
||||
|
||||
this.$emit('tab-move', this.tabs[this.dragIndex].id, overIndex);
|
||||
this.dragIndex = overIndex;
|
||||
|
||||
this.dragOverEnabled = false;
|
||||
setTimeout(() => { this.dragOverEnabled = true; }, 300);
|
||||
},
|
||||
onDrop() {
|
||||
this.dragIndex = null;
|
||||
},
|
||||
onDragEnd() {
|
||||
this.dragIndex = null;
|
||||
},
|
||||
isActive(tabId) {
|
||||
return this.activeTabId == tabId;
|
||||
},
|
||||
handleClose(tabId, tabType) {
|
||||
if (tabType === 'note') {
|
||||
this.$emit('note-close', tabId);
|
||||
return;
|
||||
}
|
||||
this.$emit('tab-close', tabId);
|
||||
},
|
||||
handleKeyPress(event) {
|
||||
if (event.target.tagName !== 'BODY') return
|
||||
|
||||
if (event.ctrlKey && event.code == 'Digit6') {
|
||||
this.previousTabId && this.$emit('tab-activate', this.previousTabId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey && (event.code == 'KeyH' || event.code == 'KeyL')) {
|
||||
const index = this.tabs.findIndex(t => t.id === this.activeTabId);
|
||||
if (index === -1) return;
|
||||
const movement = event.code === 'KeyL' ? 1 : -1;
|
||||
const newIndex = (index + movement + this.tabs.length) % this.tabs.length;
|
||||
this.$emit('tab-activate', this.tabs[newIndex].id);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
getTabs() {
|
||||
return this.tabs.map(tab => {
|
||||
if (tab.type === 'note') {
|
||||
const note = this.notes.find(n => n.Filename === tab.id);
|
||||
return {
|
||||
id: tab.id,
|
||||
type: tab.type,
|
||||
title: note.Title,
|
||||
titleHover: `${note.Title} (${note.Filename})`,
|
||||
isModified: note.isModified || false,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.handleKeyPress);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyPress);
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
var t = `
|
||||
<Pane name="noteSidebar" :defaultWidth="384" :minWidth="245" direction="left">
|
||||
<aside class="h-full overflow-y-auto mt-2 mr-2 rounded-t-lg border border-gray-200 bg-white">
|
||||
|
||||
<div class="flex gap-x-5 gap-y-2 p-2 border-b sticky top-0 z-10 bg-white">
|
||||
<button type="button" :disabled="!note.isModified" @click="$emit('note-save')"
|
||||
:class="note.isModified ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-300 text-gray-400'"
|
||||
class="w-28 rounded px-2 py-1.5 text-xs shadow-sm">Save</button>
|
||||
<div class="flex gap-x-5 gap-y-2 mt-0 ml-auto items-center justify-center">
|
||||
<span title="links" @click="$emit('finder-open', '/api/raw/links?color=true&filename=' + note.Filename)"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-arrows-right-left" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="graph panel" @click="$notesiumState.showGraphPanel=!$notesiumState.showGraphPanel"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="graph" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="delete note" @click="$emit('note-delete', note.Filename, note.Mtime)"
|
||||
class="cursor-pointer text-gray-400 hover:text-red-700">
|
||||
<Icon name="outline-trash" size="h-4 w-4" />
|
||||
</span>
|
||||
<a title="open via xdg" :href="'notesium://' + note.Path"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-external-link" size="h-4 w-4" />
|
||||
</a>
|
||||
<span title="close" @click="$notesiumState.showNoteSidebar=false"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="m-2 grid grid-cols-3 gap-2">
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-2">
|
||||
<dd class="text-sm font-semibold text-gray-700" v-text="note.Lines"></dd>
|
||||
<dt class="text-sm text-gray-400">Lines</dt>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-2">
|
||||
<dd class="text-sm font-semibold text-gray-700" v-text="note.Words"></dd>
|
||||
<dt class="text-sm text-gray-400">Words</dt>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-2">
|
||||
<dd class="text-sm font-semibold text-gray-700" v-text="note.Chars"></dd>
|
||||
<dt class="text-sm text-gray-400">Chars</dt>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl class="m-2 grid grid-cols-1 gap-2">
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<dd class="text-sm font-semibold tracking-tight text-gray-700" v-text="formattedDate(note.Mtime)"></dd>
|
||||
<dt class="text-sm text-gray-400 hover:underline cursor-pointer flex items-center space-x-2"
|
||||
title="list notes modified same day"
|
||||
@click="$emit('finder-open', '/api/raw/list?color=true&date=2006-01-02&prefix=mtime&sort=mtime', note.Mtime.split('T')[0] + ' ')">
|
||||
<span>Modified</span>
|
||||
<Icon name="mini-bars-three-bottom-left" size="h-3 w-3" />
|
||||
</dt>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<dd class="text-sm font-semibold tracking-tight text-gray-700" v-text="formattedDate(note.Ctime)"></dd>
|
||||
<dt class="text-sm text-gray-400 hover:underline cursor-pointer flex items-center space-x-2"
|
||||
title="list notes created same day"
|
||||
@click="$emit('finder-open', '/api/raw/list?color=true&date=2006-01-02&prefix=ctime&sort=ctime', note.Ctime.split('T')[0] + ' ')">
|
||||
<span>Created</span>
|
||||
<Icon name="mini-bars-three-bottom-left" size="h-3 w-3" />
|
||||
</dt>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="m-2 overflow-hidden rounded-lg bg-gray-50 pl-4 pr-2 py-3">
|
||||
<div class="flex justify-between items-center mt-1 mb-2 text-sm">
|
||||
<h3 class="leading-6 font-semibold tracking-tight text-gray-700">Links incoming</h3>
|
||||
<span class="text-gray-400 mr-2" v-text="countIncomingLinks"></span>
|
||||
</div>
|
||||
<LinkTree v-for="link in sortedIncomingLinks"
|
||||
:title="link.Title" :filename="link.Filename" :linenum="link.LineNumber" :key="link.Filename + link.LineNumber"
|
||||
@note-open="(...args) => $emit('note-open', ...args)" />
|
||||
|
||||
<div class="flex justify-between items-center mt-4 mb-2 text-sm">
|
||||
<h3 class="leading-6 font-semibold tracking-tight text-gray-700">Links outgoing</h3>
|
||||
<span class="text-gray-400 mr-2" v-text="countOutgoingLinks"></span>
|
||||
</div>
|
||||
<LinkTree v-for="link in existingOutgoingLinks"
|
||||
:title="link.Title" :filename="link.Filename" linenum="1" :key="link.Filename + link.LineNumber"
|
||||
@note-open="(...args) => $emit('note-open', ...args)" />
|
||||
|
||||
<div v-for="link in danglingOutgoingLinks" class="flex justify-between text-sm text-red-700 my-1">
|
||||
<div class="overflow-hidden truncate">
|
||||
<span class="font-mono pr-2">!</span>
|
||||
<span class="cursor-pointer hover:underline"
|
||||
@click="$emit('note-open', note.Filename, link.LineNumber)"
|
||||
v-text="link.Filename + ' (line ' + link.LineNumber + ')'">
|
||||
</span>
|
||||
</div>
|
||||
<span class="mr-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset bg-red-50 ring-red-500/10">dangling</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</Pane>
|
||||
`
|
||||
|
||||
import Pane from './pane.js'
|
||||
import Icon from './icon.js'
|
||||
import LinkTree from './link-tree.js'
|
||||
import { formatDate } from './dateutils.js';
|
||||
export default {
|
||||
components: { Pane, Icon, LinkTree },
|
||||
props: ['note'],
|
||||
emits: ['note-open', 'note-save', 'note-delete', 'finder-open'],
|
||||
methods: {
|
||||
formattedDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return formatDate(date, '%b %d %Y at %H:%M');
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortedIncomingLinks() {
|
||||
return this.note.IncomingLinks?.sort((a, b) => a.Title.localeCompare(b.Title)) || [];
|
||||
},
|
||||
countIncomingLinks() {
|
||||
return this.note.IncomingLinks?.length || 0;
|
||||
},
|
||||
existingOutgoingLinks() {
|
||||
return this.note.OutgoingLinks?.filter(l => l.Title !== '') || [];
|
||||
},
|
||||
danglingOutgoingLinks() {
|
||||
return this.note.OutgoingLinks?.filter(l => l.Title == '') || [];
|
||||
},
|
||||
countOutgoingLinks() {
|
||||
return this.note.OutgoingLinks?.length || 0;
|
||||
},
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
var t = `
|
||||
<div class="flex h-7 justify-between items-center justify-items-center bg-gray-200 text-xs">
|
||||
<div class="flex h-full">
|
||||
<span v-if="vimMode" class="pl-3 pr-4 pt-1.5 pb-1 uppercase font-semibold text-white rounded-r-full" :class="vimModeCls" v-text="vimModeText"></span>
|
||||
<span v-else class="pl-3 pr-4 pt-1.5 pb-1 uppercase text-gray-500 truncate" v-text="hasFocus ? 'focused' : 'not focused'"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full bg-gray-900/5 rounded-l-full text-gray-500">
|
||||
<div class="flex h-full space-x-5 px-4 items-center justify-items-center">
|
||||
<span title="toggle vim mode" @click="$notesiumState.editorVimMode = !$notesiumState.editorVimMode"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorVimMode ? 'mode:vim' : 'mode:default'" />
|
||||
</div>
|
||||
<div class="flex h-full space-x-5 px-4 items-center justify-items-center bg-gray-300 rounded-l-full border-l border-gray-400">
|
||||
<span title="line wrapping" @click="$notesiumState.editorLineWrapping = !$notesiumState.editorLineWrapping"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorLineWrapping ? 'wrap' : 'nowrap'" />
|
||||
<span title="conceal formatting" @click="$notesiumState.editorConcealFormatting = !$notesiumState.editorConcealFormatting"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorConcealFormatting ? 'conceal' : 'noconceal'" />
|
||||
<span title="fold gutter" @click="$notesiumState.editorFoldGutter = !$notesiumState.editorFoldGutter"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorFoldGutter ? 'fold' : 'nofold'" />
|
||||
<template v-if="!note.ghost && !$notesiumState.showNoteSidebar">
|
||||
<span title="incoming links" class="cursor-pointer hover:text-gray-700 -mb-px"
|
||||
@click="$emit('finder-open', '/api/raw/links?color=true&incoming=true&filename=' + note.Filename)">
|
||||
{{note.IncomingLinks?.length || 0}}↙
|
||||
</span>
|
||||
<span title="outgoing links" class="cursor-pointer hover:text-gray-700 -mb-px"
|
||||
@click="$emit('finder-open', '/api/raw/links?color=true&outgoing=true&filename=' + note.Filename)">
|
||||
{{note.OutgoingLinks?.length || 0}}↗
|
||||
</span>
|
||||
<span title="links" @click="$emit('finder-open', '/api/raw/links?color=true&filename=' + note.Filename)"
|
||||
class="cursor-pointer hover:text-gray-700">
|
||||
<Icon name="mini-arrows-right-left" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="graph panel" @click="$notesiumState.showGraphPanel=!$notesiumState.showGraphPanel"
|
||||
class="cursor-pointer hover:text-gray-700">
|
||||
<Icon name="graph" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="delete note" @click="$emit('note-delete', note.Filename, note.Mtime)"
|
||||
class="cursor-pointer hover:text-red-700">
|
||||
<Icon name="outline-trash" size="h-4 w-4" />
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { Icon },
|
||||
props: ['note', 'vimMode', 'hasFocus' ],
|
||||
emits: ['note-delete', 'finder-open'],
|
||||
computed: {
|
||||
vimModeText() {
|
||||
const modeText = { 'visual-linewise': 'v-line', 'visual-blockwise': 'v-block' };
|
||||
return modeText[`${this.vimMode.mode}-${this.vimMode.subMode}`] || this.vimMode.mode;
|
||||
},
|
||||
vimModeCls() {
|
||||
const modeCls = { normal: 'bg-slate-500', insert: 'bg-yellow-500', visual: 'bg-pink-500', replace: 'bg-red-500', command: 'bg-sky-500' };
|
||||
return modeCls[this.vimMode.mode]
|
||||
},
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
var t = `
|
||||
<div class="relative flex h-full">
|
||||
<div class="flex flex-col grow overflow-y-auto">
|
||||
<div ref="codemirror"
|
||||
@keydown.[.exact="handleLeftBracket"
|
||||
class="h-full p-2 pr-1 pb-px cm-links-hover"
|
||||
:class="{'cm-conceal cm-unconceal': $notesiumState.editorConcealFormatting, 'cm-fat-cursor': fatCursor}"></div>
|
||||
<NoteStatusbar :note=note :vimMode=vimMode :hasFocus=hasFocus
|
||||
@note-delete="(...args) => $emit('note-delete', ...args)"
|
||||
@finder-open="(...args) => $emit('finder-open', ...args)" />
|
||||
</div>
|
||||
|
||||
<div v-if="!$notesiumState.showNoteSidebar || note.ghost" class="absolute right-0 mt-2 mr-4 h-7 z-10 inline-flex items-center">
|
||||
<button type="button" :disabled="!this.note.isModified" @click="handleSave()"
|
||||
:class="this.note.isModified ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-300 text-gray-400'"
|
||||
class="rounded px-10 py-1.5 text-xs">Save</button>
|
||||
<span v-if="!note.ghost" title="sidebar" @click="$notesiumState.showNoteSidebar=true"
|
||||
class="ml-2 cursor-pointer text-gray-400 hover:text-gray-600">
|
||||
<Icon name="outline-information-circle" size="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NoteSidebar v-if="$notesiumState.showNoteSidebar && !note.ghost" :note=note
|
||||
@note-save="handleSave()"
|
||||
@note-open="(...args) => $emit('note-open', ...args)"
|
||||
@note-delete="(...args) => $emit('note-delete', ...args)"
|
||||
@finder-open="(...args) => $emit('finder-open', ...args)" />
|
||||
|
||||
<Finder v-if="showFinder" uri="/api/raw/list?sort=mtime" small=true @finder-selection="handleFinderSelection" />
|
||||
</div>
|
||||
`
|
||||
|
||||
import * as Table from './cm-table.js';
|
||||
import NoteSidebar from './note-sidebar.js'
|
||||
import NoteStatusbar from './note-statusbar.js'
|
||||
import Finder from './finder.js'
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { NoteSidebar, NoteStatusbar, Finder, Icon },
|
||||
props: ['note', 'activeTabId'],
|
||||
emits: ['note-open', 'note-close', 'note-save', 'note-delete', 'finder-open'],
|
||||
data() {
|
||||
return {
|
||||
hasFocus: false,
|
||||
vimMode: null,
|
||||
fatCursor: false,
|
||||
showFinder: false,
|
||||
selectedLines: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
simulateDoubleLeftBracket() {
|
||||
if (this.$notesiumState.editorVimMode && this.vimMode.mode !== 'insert' ) return CodeMirror.Pass;
|
||||
const cursorPos = this.cm.getCursor();
|
||||
this.cm.replaceRange('[', cursorPos, cursorPos);
|
||||
this.showFinder = true;
|
||||
},
|
||||
handleLeftBracket(e) {
|
||||
if (this.$notesiumState.editorVimMode && this.vimMode.mode !== 'insert' ) return;
|
||||
e.preventDefault();
|
||||
|
||||
const cursorPos = this.cm.getCursor();
|
||||
const startPos = { line: cursorPos.line, ch: cursorPos.ch - 1 };
|
||||
const prevChar = this.cm.getRange(startPos, cursorPos);
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastPress = now - (this.lastBracketPressTime || 0);
|
||||
const threshold = 2000;
|
||||
|
||||
if (prevChar === '[' && timeSinceLastPress < threshold) {
|
||||
this.showFinder = true;
|
||||
} else {
|
||||
this.cm.replaceRange('[', cursorPos, cursorPos);
|
||||
this.lastBracketPressTime = now;
|
||||
|
||||
this.fatCursor = true;
|
||||
const restoreCursor = () => { this.fatCursor = false; this.cm.off('keydown', restoreCursor); };
|
||||
this.cm.on('keydown', restoreCursor);
|
||||
setTimeout(() => { restoreCursor(); }, threshold);
|
||||
}
|
||||
},
|
||||
handleFinderSelection(value) {
|
||||
this.showFinder = false;
|
||||
|
||||
const cursorPos = this.cm.getCursor();
|
||||
const startPos = { line: cursorPos.line, ch: cursorPos.ch - 1 };
|
||||
let formattedLink = '';
|
||||
if (value) formattedLink = `[${value.Content}](${value.Filename})`;
|
||||
this.cm.replaceRange(formattedLink, startPos, cursorPos);
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.cm.focus();
|
||||
this.cm.refresh();
|
||||
if (this.$notesiumState.editorVimMode) {
|
||||
CodeMirror.Vim.handleKey(this.cm, "a");
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSave() {
|
||||
if (this.note.isModified) {
|
||||
const timestamp = this.note.ghost ? this.note.Ctime : this.note.Mtime;
|
||||
this.$emit('note-save', this.note.Filename, this.cm.getValue(), timestamp, this.note.ghost);
|
||||
}
|
||||
if (this.$notesiumState.editorVimMode) {
|
||||
CodeMirror.Vim.exitInsertMode(this.cm)
|
||||
}
|
||||
},
|
||||
handleTab() {
|
||||
if (this.$notesiumState.editorVimMode && this.vimMode.mode !== 'insert' ) return;
|
||||
if (this.cm.somethingSelected()) return CodeMirror.Pass;
|
||||
if (Table.isCursorInTable(this.cm)) {
|
||||
Table.formatTableAndAdvance(this.cm, this.$notesiumState.editorConcealFormatting);
|
||||
} else {
|
||||
this.cm.execCommand('insertSoftTab');
|
||||
}
|
||||
},
|
||||
handleBackspace() {
|
||||
if (this.cm.somethingSelected()) return CodeMirror.Pass;
|
||||
const cursorPos = this.cm.getCursor();
|
||||
const indentUnit = this.cm.getOption('indentUnit');
|
||||
const spacesForIndentUnit = ' '.repeat(indentUnit);
|
||||
const checkFrom = {line: cursorPos.line, ch: Math.max(0, cursorPos.ch - indentUnit)};
|
||||
if (this.cm.getRange(checkFrom, cursorPos) === spacesForIndentUnit) {
|
||||
this.cm.replaceRange('', checkFrom, cursorPos);
|
||||
} else {
|
||||
return CodeMirror.Pass;
|
||||
}
|
||||
},
|
||||
handleEsc() {
|
||||
if (this.$notesiumState.editorVimMode) {
|
||||
CodeMirror.Vim.handleEx(this.cm, 'nohlsearch');
|
||||
return CodeMirror.Pass;
|
||||
}
|
||||
this.cm.display.input.blur();
|
||||
document.body.focus();
|
||||
},
|
||||
handleEditorVimMode() {
|
||||
if (this.$notesiumState.editorVimMode) {
|
||||
this.cm.setOption("keyMap", "vim");
|
||||
this.cm.on('vim-mode-change', (e) => { this.vimMode = e; });
|
||||
this.cm.on('vim-keypress', (key) => { if (':/?'.includes(key)) this.vimMode = { mode: 'command' }; });
|
||||
this.cm.focus();
|
||||
} else {
|
||||
this.cm.setOption("keyMap", "default");
|
||||
this.cm.off('vim-mode-change');
|
||||
this.cm.off('vim-keypress');
|
||||
this.vimMode = null;
|
||||
}
|
||||
},
|
||||
handleEditorFoldGutter() {
|
||||
if (this.$notesiumState.editorFoldGutter) {
|
||||
this.cm.setOption("gutters", ["CodeMirror-foldgutter"]);
|
||||
this.cm.setOption("foldGutter", true);
|
||||
} else {
|
||||
this.cm.setOption("gutters", []);
|
||||
this.cm.setOption("foldGutter", false);
|
||||
}
|
||||
},
|
||||
foldWidget(from, to) {
|
||||
var header = document.createElement("span");
|
||||
header.appendChild(document.createTextNode(this.cm.getLine(from.line).trim()));
|
||||
header.className = "cm-foldmarker-header";
|
||||
|
||||
var lines = document.createElement("span");
|
||||
lines.appendChild(document.createTextNode(`[${to.line - from.line} lines]`));
|
||||
lines.className = "cm-foldmarker-lines";
|
||||
|
||||
var widget = document.createElement("span");
|
||||
widget.appendChild(header);
|
||||
widget.appendChild(lines);
|
||||
widget.className = "cm-foldmarker";
|
||||
return widget;
|
||||
},
|
||||
lineNumberHL(linenum) {
|
||||
if (!Number.isInteger(linenum) || linenum === undefined) return;
|
||||
this.$nextTick(() => {
|
||||
this.cm.setOption("styleActiveLine", true);
|
||||
this.cm.setCursor({line: linenum - 1});
|
||||
this.note.Linenum = undefined;
|
||||
});
|
||||
},
|
||||
handleKeyPress(event) {
|
||||
if (event.target.tagName !== 'BODY' || this.note.Filename !== this.activeTabId) return;
|
||||
if (event.ctrlKey && event.code === 'KeyS') {
|
||||
this.handleSave();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (event.code === 'Tab') {
|
||||
this.$nextTick(() => { this.cm.focus(); });
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.cm = new CodeMirror(this.$refs.codemirror, {
|
||||
value: this.note.Content,
|
||||
placeholder: '# title',
|
||||
lineNumbers: false,
|
||||
lineWrapping: this.$notesiumState.editorLineWrapping,
|
||||
styleActiveLine: false,
|
||||
foldOptions: {
|
||||
rangeFinder: CodeMirror.fold.markdown,
|
||||
markdownIncludeHeader: true,
|
||||
widget: this.foldWidget,
|
||||
inclusiveRight: true,
|
||||
clearOnEnter: false,
|
||||
scanUp: true,
|
||||
},
|
||||
tabSize: 4,
|
||||
indentUnit: 4,
|
||||
theme: 'notesium-light',
|
||||
mode: {
|
||||
name: "gfm",
|
||||
highlightFormatting: true,
|
||||
},
|
||||
extraKeys: {
|
||||
"Esc": this.handleEsc,
|
||||
"Ctrl-S": this.handleSave,
|
||||
"Tab": this.handleTab,
|
||||
"Backspace": this.handleBackspace,
|
||||
"Ctrl-Enter": function(cm) { return cm.execCommand('toggleFold'); },
|
||||
"Shift-Tab": function(cm) { return Table.navigateTable(cm, 'left'); },
|
||||
"Alt-Up": function(cm) { return Table.navigateTable(cm, 'up'); },
|
||||
"Alt-Down": function(cm) { return Table.navigateTable(cm, 'down'); },
|
||||
"Alt-Left": function(cm) { return Table.navigateTable(cm, 'left'); },
|
||||
"Alt-Right": function(cm) { return Table.navigateTable(cm, 'right'); },
|
||||
"Alt-K": this.simulateDoubleLeftBracket,
|
||||
},
|
||||
});
|
||||
|
||||
if (Number.isInteger(this.note.Linenum) && this.note.Linenum > 1) {
|
||||
this.lineNumberHL(this.note.Linenum);
|
||||
}
|
||||
|
||||
this.cm.save = () => { this.handleSave(); }
|
||||
this.cm.quit = (confirmIfModified) => { this.$emit('note-close', this.note.Filename, confirmIfModified); }
|
||||
this.cm.writequit = () => {
|
||||
if (!this.note.isModified) {
|
||||
this.$emit('note-close', this.note.Filename);
|
||||
return;
|
||||
}
|
||||
const currentMtime = this.note.Mtime;
|
||||
const closeWhenSaved = (attempts, interval) => {
|
||||
if (attempts <= 0) return;
|
||||
setTimeout(() => {
|
||||
(currentMtime !== this.note.Mtime) ? this.$emit('note-close', this.note.Filename) : closeWhenSaved(attempts - 1, interval * 2);
|
||||
}, interval);
|
||||
};
|
||||
this.handleSave();
|
||||
closeWhenSaved(5, 100);
|
||||
}
|
||||
this.cm.openlink = (link) => {
|
||||
const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link);
|
||||
const hasProtocol = /^[a-zA-Z]+:\/\//.test(link);
|
||||
(isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank');
|
||||
}
|
||||
|
||||
this.cm.on('focus', (cm, e) => {
|
||||
if (this.$notesiumState.editorVimMode) CodeMirror.Vim.exitInsertMode(this.cm);
|
||||
this.cm.setOption("styleActiveLine", true);
|
||||
this.hasFocus = true;
|
||||
});
|
||||
this.cm.on('blur', (cm, e) => {
|
||||
if (this.vimMode?.mode == 'command') return;
|
||||
this.vimMode = null;
|
||||
this.cm.setOption("styleActiveLine", false);
|
||||
this.hasFocus = false;
|
||||
});
|
||||
this.cm.on('changes', (cm, changes) => {
|
||||
this.note.isModified = !cm.doc.isClean();
|
||||
});
|
||||
this.cm.on('cursorActivity', (cm, e) => {
|
||||
if (!this.$notesiumState.editorConcealFormatting) return;
|
||||
this.selectedLines.forEach(line => {
|
||||
cm.removeLineClass(line, 'wrap', 'CodeMirror-selectedline');
|
||||
});
|
||||
this.selectedLines = [];
|
||||
if (cm.somethingSelected()) {
|
||||
const from = cm.getCursor("from").line;
|
||||
const to = cm.getCursor("to").line;
|
||||
for (let line = from; line <= to; line++) {
|
||||
cm.addLineClass(line, 'wrap', 'CodeMirror-selectedline');
|
||||
this.selectedLines.push(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.cm.on('mousedown', (cm, e) => {
|
||||
let el = e.composedPath()[0];
|
||||
if (el.classList.contains('cm-link') || el.classList.contains('cm-url')) {
|
||||
const getNextNSibling = (element, n) => { for (; n > 0 && element; n--, element = element.nextElementSibling); return element; };
|
||||
|
||||
if (el.classList.contains('cm-formatting')) {
|
||||
switch (el.textContent) {
|
||||
case '[': el = getNextNSibling(el, 4); break;
|
||||
case ']': el = getNextNSibling(el, 2); break;
|
||||
case '(': el = getNextNSibling(el, 1); break;
|
||||
case ')': el = el.previousElementSibling; break;
|
||||
default: return;
|
||||
}
|
||||
if (!el?.classList.contains('cm-url')) return;
|
||||
}
|
||||
|
||||
if (el.classList.contains('cm-link')) {
|
||||
const potentialUrlElement = getNextNSibling(el, 3);
|
||||
el = potentialUrlElement?.classList.contains('cm-url') ? potentialUrlElement : el;
|
||||
}
|
||||
|
||||
const link = el.textContent;
|
||||
const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link);
|
||||
const hasProtocol = /^[a-zA-Z]+:\/\//.test(link);
|
||||
(isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank');
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
this.handleEditorVimMode();
|
||||
this.handleEditorFoldGutter();
|
||||
document.addEventListener('keydown', this.handleKeyPress);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyPress);
|
||||
},
|
||||
watch: {
|
||||
'activeTabId': function(newVal) { if (this.$notesiumState.editorVimMode && this.note.Filename == newVal) this.$nextTick(() => { this.cm.focus(); }); },
|
||||
'note.Linenum': function(newVal) { this.lineNumberHL(newVal); if (this.$notesiumState.editorVimMode) this.$nextTick(() => { this.cm.focus(); }); },
|
||||
'note.Mtime': function() { this.cm.doc.markClean(); },
|
||||
'$notesiumState.editorLineWrapping': function(newVal) { this.cm.setOption("lineWrapping", newVal); },
|
||||
'$notesiumState.editorVimMode': function() { this.handleEditorVimMode(); },
|
||||
'$notesiumState.editorFoldGutter': function() { this.handleEditorFoldGutter(); },
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
var t = `
|
||||
<div class="relative flex-none" :style="{ width: paneWidth + 'px' }">
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-show="resizing" v-text="paneWidth + 'px'" class="absolute bottom-0 right-0 p-4 text-gray-400 text-xs"></div>
|
||||
<div @dblclick="setDefaultWidth()" @mousedown="startResize"
|
||||
:class="direction == 'right' ? 'left-full pl-1.5 mr-1.5' : 'right-full pr-1.5 ml-1.5'"
|
||||
class="z-50 absolute group inset-y-0 cursor-ew-resize flex items-center">
|
||||
<div class="h-8 w-1 rounded-full group-hover:bg-gray-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: { type: String },
|
||||
defaultWidth: { type: Number, default: 200 },
|
||||
minWidth: { type: Number, default: 100 },
|
||||
direction: { type: String, default: "right" },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
paneWidth: null,
|
||||
maxWidth: null,
|
||||
startResizeClientX: null,
|
||||
startResizePaneWidth: null,
|
||||
resizing: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startResize(event) {
|
||||
this.startResizeClientX = event.clientX;
|
||||
this.startResizePaneWidth = this.paneWidth;
|
||||
this.maxWidth = this.$el.parentElement.offsetWidth - 50;
|
||||
this.resizing = true;
|
||||
event.preventDefault();
|
||||
document.addEventListener('mousemove', this.doResize);
|
||||
document.addEventListener('mouseup', this.stopResize);
|
||||
},
|
||||
doResize(event) {
|
||||
let draggedDistance = event.clientX - this.startResizeClientX;
|
||||
if (this.direction === 'left') draggedDistance = -draggedDistance;
|
||||
const newWidth = this.startResizePaneWidth + draggedDistance;
|
||||
if (newWidth <= this.maxWidth && newWidth >= this.minWidth ) this.paneWidth = newWidth;
|
||||
},
|
||||
stopResize() {
|
||||
this.resizing = false;
|
||||
this.savePreferredWidth();
|
||||
document.removeEventListener('mousemove', this.doResize);
|
||||
document.removeEventListener('mouseup', this.stopResize);
|
||||
},
|
||||
setDefaultWidth() {
|
||||
this.paneWidth = this.defaultWidth;
|
||||
this.savePreferredWidth()
|
||||
},
|
||||
savePreferredWidth() {
|
||||
const key = `${this.name}Width`;
|
||||
this.$notesiumState[key] = this.paneWidth;
|
||||
},
|
||||
loadPreferredWidth() {
|
||||
const key = `${this.name}Width`;
|
||||
this.paneWidth = parseInt(this.$notesiumState[key], 10) || this.defaultWidth;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadPreferredWidth();
|
||||
},
|
||||
template: t
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
var t = `
|
||||
<div v-show="showDatePicker" @keyup.esc="$emit('periodic-close')" @click="$emit('periodic-close')" class="fixed inset-0 z-40" aria-hidden="true"></div>
|
||||
<div v-if="showDatePicker" class="block absolute left-16 z-50 w-64 pt-3">
|
||||
<div class="rounded-md bg-white shadow-md border border-gray-200 p-3
|
||||
before:absolute before:bottom-0 before:top-0 before:-left-2 before:bg-white before:border-l before:border-b before:border-gray-300
|
||||
before:w-4 before:h-4 before:rotate-45 before:-z-1 before:my-auto">
|
||||
<DatePicker :dottedDates="periodicNoteDates" @date-selected="(date) => periodicNoteDate = date" />
|
||||
<div class="flex space-x-2 items-center justify-items-center">
|
||||
<div @click="$emit('note-weekly', periodicNoteDate); $emit('periodic-close')"
|
||||
class="py-1 w-full text-xs text-center rounded-md hover:cursor-pointer hover:text-white bg-emerald-100 hover:bg-emerald-600 text-emerald-600">
|
||||
Weekly note
|
||||
</div>
|
||||
<div @click="$emit('note-daily', periodicNoteDate); $emit('periodic-close')"
|
||||
class="py-1 w-full text-xs text-center rounded-md hover:cursor-pointer hover:text-white bg-indigo-100 hover:bg-indigo-600 text-indigo-600">
|
||||
Daily note
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import DatePicker from './datepicker.js'
|
||||
export default {
|
||||
components: { DatePicker },
|
||||
emits: ['note-daily', 'note-weekly', 'periodic-close'],
|
||||
data() {
|
||||
return {
|
||||
showDatePicker: false,
|
||||
periodicNoteDate: null,
|
||||
periodicNoteDates: {},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchPeriodicNoteDates() {
|
||||
fetch('/api/raw/list?prefix=ctime&date=2006-01-02T15:04:05')
|
||||
.then(response => response.text())
|
||||
.then(text => {
|
||||
const dates = text.split('\n').reduce((acc, line) => {
|
||||
const parts = line.split(' ');
|
||||
if (parts.length > 1) {
|
||||
const date = parts[1].split('T')[0];
|
||||
const time = parts[1].split('T')[1];
|
||||
if (time === '00:00:00') {
|
||||
if (!acc[date]) acc[date] = [];
|
||||
if (!acc[date].includes('daily')) acc[date].push('daily');
|
||||
} else if (time === '00:00:01') {
|
||||
if (!acc[date]) acc[date] = [];
|
||||
if (!acc[date].includes('weekly')) acc[date].push('weekly');
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
this.periodicNoteDates = dates;
|
||||
this.showDatePicker = true;
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchPeriodicNoteDates();
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
var t = `
|
||||
<div :class="{ 'cm-links-hover': clickableLinks }" class="h-full cm-conceal" ref="preview"></div>
|
||||
`
|
||||
|
||||
export default {
|
||||
props: ['filename', 'lineNumber', 'clickableLinks', 'appendIncomingLinks'],
|
||||
emits: ['note-open'],
|
||||
methods: {
|
||||
fetchNote() {
|
||||
fetch("/api/notes/" + this.filename)
|
||||
.then(response => response.json())
|
||||
.then(note => {
|
||||
if (this.appendIncomingLinks && note.IncomingLinks?.length) {
|
||||
const sortedIncomingLinks = note.IncomingLinks.sort((a, b) => a.Title.localeCompare(b.Title));
|
||||
const linksMd = sortedIncomingLinks.map(link => `- [${link.Title}](${link.Filename})`).join('\n');
|
||||
this.cm.setValue(`${note.Content.replace(/\n+$/, '')}\n\n---\n\n**Incoming links**\n\n${linksMd}`);
|
||||
} else {
|
||||
this.cm.setValue(note.Content);
|
||||
this.lineNumberHL();
|
||||
}
|
||||
});
|
||||
},
|
||||
lineNumberHL() {
|
||||
if (!Number.isInteger(this.lineNumber) || this.lineNumber === undefined) return;
|
||||
this.$nextTick(() => {
|
||||
this.cm.setOption("styleActiveLine", true);
|
||||
this.cm.setCursor({line: this.lineNumber - 1, ch: 0});
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.cm = new CodeMirror(this.$refs.preview, {
|
||||
value: '',
|
||||
readOnly: true,
|
||||
styleActiveLine: false,
|
||||
lineWrapping: this.$notesiumState.editorLineWrapping,
|
||||
theme: 'notesium-light',
|
||||
mode: {
|
||||
name: "gfm",
|
||||
highlightFormatting: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.clickableLinks) {
|
||||
this.cm.on('mousedown', (cm, e) => {
|
||||
let el = e.composedPath()[0];
|
||||
if (el.classList.contains('cm-link') || el.classList.contains('cm-url')) {
|
||||
const getNextNSibling = (element, n) => { for (; n > 0 && element; n--, element = element.nextElementSibling); return element; };
|
||||
|
||||
if (el.classList.contains('cm-formatting')) {
|
||||
switch (el.textContent) {
|
||||
case '[': el = getNextNSibling(el, 4); break;
|
||||
case ']': el = getNextNSibling(el, 2); break;
|
||||
case '(': el = getNextNSibling(el, 1); break;
|
||||
case ')': el = el.previousElementSibling; break;
|
||||
default: return;
|
||||
}
|
||||
if (!el?.classList.contains('cm-url')) return;
|
||||
}
|
||||
|
||||
if (el.classList.contains('cm-link')) {
|
||||
const potentialUrlElement = getNextNSibling(el, 3);
|
||||
el = potentialUrlElement?.classList.contains('cm-url') ? potentialUrlElement : el;
|
||||
}
|
||||
|
||||
const link = el.textContent;
|
||||
const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link);
|
||||
const hasProtocol = /^[a-zA-Z]+:\/\//.test(link);
|
||||
(isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank');
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.fetchNote();
|
||||
},
|
||||
watch: {
|
||||
filename: function() { this.fetchNote(); },
|
||||
lineNumber: function() { this.lineNumberHL(); },
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
var t = `
|
||||
<div class="flex-none h-full drop-shadow-md w-12 px-2 divide-y border-r divide-gray-600 border-gray-600 bg-gray-700 text-gray-400">
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-0.5">
|
||||
<span title="New note" @click="$emit('note-new', '')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-plus" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-2 space-y-2">
|
||||
<span title="Labels panel" @click="$notesiumState.showLabelsPanel=!$notesiumState.showLabelsPanel"
|
||||
:class="{'text-gray-100': $notesiumState.showLabelsPanel}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-tag" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Notes list panel" @click="$notesiumState.showNotesPanel=!$notesiumState.showNotesPanel"
|
||||
:class="{'text-gray-100': $notesiumState.showNotesPanel}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-queue-list" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Graph panel" @click="$notesiumState.showGraphPanel=!$notesiumState.showGraphPanel"
|
||||
:class="{'text-gray-100': $notesiumState.showGraphPanel}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="graph" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Periodic notes" @click="$emit('periodic-open')"
|
||||
:class="{'text-gray-100': showPeriodic}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-calendar" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-2 space-y-2">
|
||||
<span title="Search notes" @click="$emit('finder-open', '/api/raw/lines?color=true&prefix=title')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="mini-magnifying-glass" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="List notes" @click="$emit('finder-open', '/api/raw/list?color=true&prefix=label&sort=alpha')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="mini-bars-three-bottom-left" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="List notes (modified)" @click="$emit('finder-open', '/api/raw/list?color=true&prefix=mtime&sort=mtime')"
|
||||
class="p-2 pb-1 pr-1 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-bars-arrow-down" size="h-5 w-5" />
|
||||
</span>
|
||||
<span title="List links" @click="$emit('finder-open', '/api/raw/links?color=true')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="mini-arrows-right-left" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="List broken links" @click="$emit('finder-open', '/api/raw/links?color=true&dangling=true')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-link-slash" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Graph view" @click="$emit('graph-open')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="graph" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-2 space-y-2">
|
||||
<span :title="updateAvailable ? 'An update is available' : 'Settings'" @click="$emit('settings-open')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<div :class="{'rounded-full -m-1 p-1 bg-red-500 text-gray-100': updateAvailable}">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { Icon },
|
||||
props: ['versionCheck', 'showPeriodic'],
|
||||
emits: ['note-new', 'finder-open', 'periodic-open', 'settings-open', 'graph-open'],
|
||||
computed: {
|
||||
updateAvailable() {
|
||||
return this.versionCheck.comparison == '-1';
|
||||
},
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<div class="flex flex-wrap">
|
||||
<dl class="flex w-full px-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center justify-between">
|
||||
<dt class="text-xs leading-6 text-gray-900 font-semibold">Software update</dt>
|
||||
<dd v-if="versionCheck.inprogress" class="mt-1 text-blue-700"><Icon name="spinner-circle" size="h-4 w-4" /></dd>
|
||||
<dd v-else class="text-xs leading-6 text-blue-700 hover:text-blue-600 hover:cursor-pointer" @click="$emit('version-check')">Check for updates</dd>
|
||||
</dl>
|
||||
<dl class="px-6 py-2 flex w-full flex-none justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Runtime version</dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900" v-text="runtime.version || 'unknown'"></dd>
|
||||
</dl>
|
||||
<dl class="px-6 py-2 flex w-full flex-none justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Latest release</dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900" v-text="versionCheck.latestVersion || 'unknown'"></dd>
|
||||
</dl>
|
||||
<dl class="px-6 py-2 flex w-full flex-none justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Last check</dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900" v-text="formattedDate(versionCheck.date) || 'unknown'"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-if="versionCheck.error" class="font-mono text-xs text-red-600 px-6 py-2" v-text="versionCheck.error"></div>
|
||||
<div v-else-if="versionCheck.comparison == '-1'" class="w-full py-2 mx-auto border-t border-gray-200 text-center text-blue-700 bg-blue-50">
|
||||
<a class="text-xs font-bold leading-6 hover:underline" target="_blank" href="https://github.com/alonswartz/notesium/releases">
|
||||
A new release is available <span aria-hidden="true"> →</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="versionCheck.comparison == '0'" class="w-full py-2 mx-auto border-t border-gray-200 text-center text-green-700 bg-green-50">
|
||||
<span class="text-xs font-medium leading-6">You are using the latest version</span>
|
||||
</div>
|
||||
<div v-else-if="versionCheck.comparison == '1'" class="w-full py-2 mx-auto border-t border-gray-200 text-center text-yellow-700 bg-yellow-50">
|
||||
<span class="text-xs font-medium leading-6">You are using a newer version than the latest release</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<dl class="flex flex-wrap">
|
||||
<div class="flex-auto pl-6 py-2 bg-gray-100 border-b border-gray-200">
|
||||
<dt class="text-xs font-semibold leading-6 text-gray-900">Resources</dt>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<a target="_blank" href="https://www.notesium.com"
|
||||
class="flex w-full px-6 py-2 flex-none items-center justify-items-center justify-between hover:bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Website</dt>
|
||||
<dd><Icon name="outline-external-link" size="h-4 w-4" /></dd>
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/alonswartz/notesium"
|
||||
class="flex w-full px-6 py-2 flex-none items-center justify-items-center justify-between hover:bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Github</dt>
|
||||
<dd><Icon name="outline-external-link" size="h-4 w-4" /></dd>
|
||||
</a>
|
||||
<a target="_blank" :href="issueUrl"
|
||||
class="flex w-full px-6 py-2 flex-none items-center justify-items-center justify-between hover:bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Report an issue</dt>
|
||||
<dd><Icon name="outline-external-link" size="h-4 w-4" /></dd>
|
||||
</a>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<dl class="flex flex-wrap">
|
||||
<div class="flex-auto pl-6 py-2 bg-gray-100 border-b border-gray-200">
|
||||
<dt class="text-xs font-semibold leading-6 text-gray-900 hover:cursor-pointer" @click="getRuntime()" title="click to refresh">Runtime</dt>
|
||||
</div>
|
||||
<template v-for="(val, key) in runtime" :key="key">
|
||||
<template v-if="typeof val === 'object' && val !== null">
|
||||
<details class="text-xs flex w-full flex-col bg-gray-50">
|
||||
<summary class="flex w-full pl-6 pr-5 py-2 flex-none items-center justify-between hover:bg-gray-100 hover:cursor-pointer focus:outline-none">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900" v-text="key"></dt>
|
||||
<dd><Icon name="chevron-right" size="h-4 w-4" /></dd>
|
||||
</summary>
|
||||
<div class="ml-6 pr-6 border-l-2 border-dotted border-gray-200">
|
||||
<div v-for="(subVal, subKey) in val" :key="key + subKey" class="pl-3 py-2 flex w-full flex-none items-center justify-between">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900 whitespace-nowrap" v-text="subKey"></dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900 truncate ml-10" :title="subVal" v-text="subVal"></dd>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="px-6 py-2 flex w-full flex-none items-center justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900 whitespace-nowrap" v-text="key"></dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900 truncate ml-10" :title="val" v-text="val"></dd>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<details class="text-xs flex w-full flex-col bg-gray-50">
|
||||
<summary class="flex w-full pl-6 pr-5 py-2 flex-none items-center justify-between hover:bg-gray-100 hover:cursor-pointer focus:outline-none">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">state</dt>
|
||||
<dd><Icon name="chevron-right" size="h-4 w-4" /></dd>
|
||||
</summary>
|
||||
<div class="ml-6 pr-6 border-l-2 border-dotted border-gray-200">
|
||||
<div v-for="(stateVal, stateKey) in $notesiumState" :key="stateKey" class="pl-3 py-2 flex w-full flex-none items-center justify-between">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900 whitespace-nowrap" v-text="stateKey"></dt>
|
||||
<dd class="flex space-x-3 -mr-1">
|
||||
<span class="text-xs font-mono leading-6 text-gray-900 truncate ml-10" v-text="stateVal"></span>
|
||||
<span class="cursor-pointer text-gray-400 hover:text-gray-700 mt-0.5" @click="delete $notesiumState[stateKey]">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
import { formatDate } from './dateutils.js';
|
||||
export default {
|
||||
components: { Icon },
|
||||
emits: ['version-check'],
|
||||
props: ['versionCheck'],
|
||||
data() {
|
||||
return {
|
||||
runtime: {},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getRuntime() {
|
||||
fetch("/api/runtime")
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(response => { this.runtime = response; })
|
||||
.catch(e => { console.error(e); });
|
||||
},
|
||||
formattedDate(date) {
|
||||
if (date) return formatDate(date, '%Y-%m-%d %H:%M:%S');
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
issueUrl() {
|
||||
const url = "https://github.com/alonswartz/notesium/issues/new";
|
||||
if (Object.keys(this.runtime).length === 0) return url
|
||||
let body
|
||||
body = "```\n";
|
||||
body += `version:${this.runtime.version}\n`;
|
||||
body += `gitversion:${this.runtime.build.gitversion}\n`;
|
||||
body += `buildtime:${this.runtime.build.buildtime}\n`;
|
||||
body += `platform:${this.runtime.platform}\n`;
|
||||
body += "```\n\n";
|
||||
const params = new URLSearchParams({ body: body });
|
||||
return `${url}?${params.toString()}`;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.getRuntime();
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<div class="px-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center">
|
||||
<span class="text-xs font-semibold leading-6 text-gray-900" v-text="settings.title"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="entry in settings.entries" :key="entry.name" class="px-6 py-2 flex w-full flex-none items-center justify-center justify-between">
|
||||
<span class="text-xs text-gray-900 font-medium leading-6 mt-1" v-text="entry.title"></span>
|
||||
<button @click="$notesiumState[entry.name] = !$notesiumState[entry.name]" type="button" role="switch"
|
||||
:class="$notesiumState[entry.name] ? 'bg-indigo-600' : 'bg-gray-200'"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2">
|
||||
<span aria-hidden="true"
|
||||
:class="$notesiumState[entry.name] ? 'translate-x-5' : 'translate-x-0'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="keymap in keymaps" :key="keymap.name" class="rounded-md border border-gray-200">
|
||||
<div class="px-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center">
|
||||
<details v-if="keymap.info" class="w-full">
|
||||
<summary class="flex w-full items-center py-px justify-between hover:cursor-pointer focus:outline-none">
|
||||
<span class="text-xs font-semibold leading-6 text-gray-900" v-text="keymap.title"></span>
|
||||
<span class="text-gray-600 hover:text-gray-900"><Icon name="outline-information-circle" size="h-4 w-4" /></span>
|
||||
</summary>
|
||||
<div class="py-3 text-xs text-gray-700 space-y-2 leading-6">
|
||||
<template v-if="keymap.name == 'vim'">
|
||||
<p>Vim mode attempts to emulate the most useful features of Vim
|
||||
as faithfully as possible, but is not a complete implementation.
|
||||
It does however feature the following:</p>
|
||||
<ul class="list-disc pl-4 space-y-2">
|
||||
<li>All common motions and operators, including text objects</li>
|
||||
<li>Operator motion orthogonality</li>
|
||||
<li>Commands for write and quit (:w :wq :q :q!)</li>
|
||||
<li>Visual mode - characterwise, linewise, blockwise</li>
|
||||
<li>Full macro support (q @)</li>
|
||||
<li>Folding support (za zo zc zR zM)</li>
|
||||
<li>Incremental highlighted search (/ ? # * g# g*)</li>
|
||||
<li>Search/replace with confirm (:substitute :%s)</li>
|
||||
<li>Search history</li>
|
||||
<li>Jump lists (C-o C-i)</li>
|
||||
<li>Sort (:sort)</li>
|
||||
<li>Marks (` ')</li>
|
||||
<li>Cross-buffer yank/paste</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="keymap.name == 'table'">
|
||||
<p>The editor will recognize when the cursor is placed within a
|
||||
table structure (identified by lines starting with the |
|
||||
character), and provide formatting and navigation.</p>
|
||||
<ul class="list-disc pl-4 space-y-2">
|
||||
<li><b>Automatic table formatting:</b> Pressing Tab not only
|
||||
navigates through the table but also automatically formats it.
|
||||
This includes adjusting cell padding to align text according
|
||||
to the column specifications defined in the header row.</li>
|
||||
<li><b>Column alignment:</b> The alignment for each column is
|
||||
determined by the syntax used in the header separator row
|
||||
(--- left, :---: center, ---: right).</li>
|
||||
<li><b>Dynamic column adjustment:</b> If the cursor is at the
|
||||
end of a row and Tab is pressed, a new column will be added.
|
||||
When the cursor is on the header row, pressing Tab ensures the
|
||||
header separator row exists and matches the column count of
|
||||
the header, adjusting as necessary.</li>
|
||||
<li><b>Concealment support:</b> When concealment is enabled,
|
||||
the formatting logic takes this into account, calculating the
|
||||
maximum length of each column without the concealed text,
|
||||
ensuring a visually consistent table layout.</li>
|
||||
<li><b>Navigation:</b> Move across table cells and rows with
|
||||
the provided keybindings.</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
<span v-else class="text-xs font-semibold leading-6 text-gray-900" v-text="keymap.title"></span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="entry in keymap.entries" class="w-full flex px-6 py-2 text-xs items-center justify-items-center justify-between">
|
||||
<div class="flex">
|
||||
<span v-if="entry[2]" class="text-gray-600 font-medium w-20 mt-1" v-text="entry[2]"></span>
|
||||
<span class="text-gray-900 font-medium mt-1" v-text="entry[1]"></span>
|
||||
</div>
|
||||
<span class="text-gray-900 font-mono bg-gray-200 rounded-md p-2 pb-1" v-text="entry[0]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
export default {
|
||||
components: { Icon },
|
||||
data() {
|
||||
return {
|
||||
settings: {
|
||||
name: 'settings',
|
||||
title: 'Settings',
|
||||
entries: [
|
||||
{name: 'editorVimMode', title: 'Vim mode'},
|
||||
{name: 'editorLineWrapping', title: 'Line wrapping'},
|
||||
{name: 'editorConcealFormatting', title: 'Conceal formatting'},
|
||||
{name: 'editorFoldGutter', title: 'Fold gutter'},
|
||||
]
|
||||
},
|
||||
keymaps: [
|
||||
{
|
||||
name: 'default',
|
||||
title: 'Default mode',
|
||||
entries: [
|
||||
['Tab', 'Enter editing mode (focus active note)', 'none'],
|
||||
['C-s', 'Save note', 'all'],
|
||||
['[[', 'Insert selected note link via Finder (mtime sorted)', 'edit'],
|
||||
['Alt-k', 'Insert selected note link via Finder (mtime sorted)', 'edit'],
|
||||
['Shift-Tab', 'Auto-indent current line or selection', 'edit'],
|
||||
['C-]', 'Indent current line or selection', 'edit'],
|
||||
['C-[', 'Dedent current line or selection', 'edit'],
|
||||
['C-Enter', 'Toggle section fold', 'edit'],
|
||||
['Esc', 'Exit editing mode (unfocus)', 'edit'],
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'vim',
|
||||
title: 'Vim mode',
|
||||
info: true,
|
||||
entries: [
|
||||
['Tab', 'Enter normal mode (focus active note)', 'none'],
|
||||
['C-s', 'Save note and set normal mode', 'all'],
|
||||
['C-h C-l C-6', 'Note tab keybinds passthrough', 'all'],
|
||||
['space n <char>', 'Global keybinds passthrough', 'normal'],
|
||||
['ge | gx', 'Open link under cursor', 'normal'],
|
||||
['z<char> | C-Enter', 'Fold, unfold, toggle sections', 'normal'],
|
||||
['[[', 'Insert selected note link via Finder (mtime sorted)', 'insert'],
|
||||
['Alt-k', 'Insert selected note link via Finder (mtime sorted)', 'insert'],
|
||||
[':set [no]wrap', 'Set line wrapping', 'command'],
|
||||
[':set [no]conceal', 'Set conceal formatting', 'command'],
|
||||
[':set [no]fold', 'Set fold gutter', 'command'],
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'table',
|
||||
title: 'Table formatting and navigation',
|
||||
info: true,
|
||||
entries: [
|
||||
['Tab', 'Format table and advance column (right)', 'table'],
|
||||
['Shift-Tab', 'Navigate to previous column (left)', 'table'],
|
||||
['Alt-Arrow', 'Navigate rows and columns', 'table'],
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
<div v-for="section in sections" :key="section.name" class="rounded-md border border-gray-200">
|
||||
<div class="pl-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center">
|
||||
<span class="text-xs font-semibold leading-6 text-gray-900" v-text="section.title"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="entry in section.entries" class="w-full flex px-6 py-2 text-xs items-center justify-items-center justify-between">
|
||||
<div class="flex">
|
||||
<span v-if="entry[2]" class="text-gray-600 font-medium w-20 mt-1" v-text="entry[2]"></span>
|
||||
<span class="text-gray-900 font-medium mt-1" v-text="entry[1]"></span>
|
||||
</div>
|
||||
<span class="text-gray-900 font-mono bg-gray-200 rounded-md p-2 pb-1" v-text="entry[0]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
sections: [
|
||||
{
|
||||
name: 'global',
|
||||
title: 'Global',
|
||||
entries: [
|
||||
['space n n', 'Open new note for editing'],
|
||||
['space n d', 'Open new or existing daily note'],
|
||||
['space n w', 'Open new or existing weekly note'],
|
||||
['space n l', 'Finder: List with prefixed label, sorted alphabetically'],
|
||||
['space n c', 'Finder: List with prefixed date created, sorted by ctime'],
|
||||
['space n m', 'Finder: List with prefixed date modified, sorted by mtime'],
|
||||
['space n k', 'Finder: Links related notes to active note (or all if none open)'],
|
||||
['space n s', 'Finder: Full text search across all notes'],
|
||||
['space n g', 'Open fullscreen force graph view'],
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'finder',
|
||||
title: 'Finder',
|
||||
entries: [
|
||||
['C-p', 'Toggle preview'],
|
||||
['↓ | C-j', 'Select next entry (down)'],
|
||||
['↑ | C-k', 'Select previous entry (up)'],
|
||||
['Enter', 'Submit selected entry'],
|
||||
['Esc', 'Dismiss finder'],
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'tabs',
|
||||
title: 'Note tabs',
|
||||
entries: [
|
||||
['C-h', 'Switch to note tab on the left of the active note tab'],
|
||||
['C-l', 'Switch to note tab on the right of the active note tab'],
|
||||
['C-^ | C-6', 'Switch to previously active tab'],
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<dl class="flex flex-wrap">
|
||||
<div class="flex-auto pl-6 py-2 bg-gray-100 border-b border-gray-200">
|
||||
<dt class="text-xs font-semibold leading-6 text-gray-900">Counts</dt>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="stat in stats" :key="stat[0]" :title="stat[2]" @click="stat[3] ? $emit('finder-open', stat[3]) : undefined"
|
||||
:class="{'hover:bg-gray-50 cursor-pointer': stat[3]}"
|
||||
class="px-6 py-2 flex w-full flex-none items-center justify-center justify-between">
|
||||
<dt :class="stat[3] ? 'text-gray-900' : 'text-gray-600'" class="text-xs font-medium leading-6 flex" v-text="stat[1]"></dt>
|
||||
<dd :class="stat[3] ? 'text-gray-900' : 'text-gray-600'" class="text-xs font-mono leading-6" v-text="counts[stat[0]] || 'unknown'"></dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default {
|
||||
emits: ['finder-open'],
|
||||
data() {
|
||||
return {
|
||||
counts: {},
|
||||
stats: [
|
||||
['notes', 'Notes', 'All notes', '/api/raw/list?color=true&prefix=label&sort=alpha'],
|
||||
['labels', 'Label notes', 'Notes with one-word titles', '/api/raw/list?color=true&labels=true&sort=alpha'],
|
||||
['orphans', 'Orphan notes', 'Notes without incoming or outgoing links', '/api/raw/list?color=true&orphans=true&sort=alpha'],
|
||||
['links', 'Links', 'Count of links', '/api/raw/links?color=true'],
|
||||
['dangling', 'Dangling links', 'Count of broken links', '/api/raw/links?color=true&dangling=true'],
|
||||
['lines', 'Lines', 'Count of lines spanning all notes', '/api/raw/lines?color=true&prefix=title'],
|
||||
['words', 'Words', 'Count of words spanning all notes', null],
|
||||
['chars', 'Characters', 'Count of characters spanning all notes', null],
|
||||
],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCounts() {
|
||||
fetch('/api/raw/stats')
|
||||
.then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e)))
|
||||
.then(text => {
|
||||
this.counts = text.trim().split('\n').reduce((dict, line) => {
|
||||
const [key, val] = line.split(' '); dict[key] = val;
|
||||
return dict;
|
||||
}, {});
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e.Error);
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.getCounts();
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
var t = `
|
||||
<div @keyup.esc="$emit('settings-close');" class="relative inset-0 z-50" aria-labelledby="settings" role="dialog" aria-modal="true">
|
||||
<div @click="$emit('settings-close');" class="fixed inset-0" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16">
|
||||
<div class="pointer-events-auto">
|
||||
<div class="flex h-full bg-white shadow-xl">
|
||||
<div class="flex-col h-full w-48 bg-gray-100 border-x border-gray-200">
|
||||
<ul class="space-y-1 cursor-pointer mt-2 p-2 text-sm">
|
||||
<li v-for="section in sections"
|
||||
@click="active=section[0]" :class="{'bg-gray-200': active == section[0] }"
|
||||
class="flex justify-between p-2 rounded-md text-gray-800 hover:bg-gray-200">
|
||||
<span class="overflow-hidden truncate pr-2" v-text="section[1]" />
|
||||
<span class="text-gray-500 hover:text-gray-100"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="h-full w-[40rem] pr-1 mt-2">
|
||||
<KeyBinds v-if="active == 'keybinds'" />
|
||||
<Editor v-else-if="active == 'editor'" />
|
||||
<Stats v-else-if="active == 'stats'" @finder-open="(...args) => $emit('finder-open', ...args)" />
|
||||
<About v-else-if="active == 'about'" @version-check="(...args) => $emit('version-check', ...args)" :versionCheck=versionCheck />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
import KeyBinds from './settings-keybinds.js'
|
||||
import Editor from './settings-editor.js'
|
||||
import About from './settings-about.js'
|
||||
import Stats from './settings-stats.js'
|
||||
export default {
|
||||
components: { KeyBinds, Editor, About, Stats },
|
||||
props: ['versionCheck'],
|
||||
emits: ['settings-close', 'version-check', 'finder-open'],
|
||||
data() {
|
||||
return {
|
||||
active: 'keybinds',
|
||||
sections: [
|
||||
['keybinds', 'Key Bindings'],
|
||||
['editor', 'Editor'],
|
||||
['stats', 'Statistics'],
|
||||
['about', 'About'],
|
||||
],
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.versionCheck.comparison == '-1') this.active = 'about';
|
||||
},
|
||||
template: t
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
var t = `
|
||||
<Pane v-if="$notesiumState.showLabelsPanel" name="labelsPanel" :defaultWidth="195" :minWidth="100">
|
||||
<div class="h-full overflow-y-auto bg-gray-700 text-gray-400 text-sm font-medium dark-scroll border-r border-gray-600">
|
||||
<ul class="space-y-1 cursor-pointer px-2">
|
||||
<li v-for="label in sortedLabelNotes" :key="label.Filename"
|
||||
@click="$notesiumState.showNotesPanel ? query='label:'+label.Title+' ' : $emit('finder-open', '/api/raw/links?color=true&filename=' + label.Filename)"
|
||||
class="group flex justify-between p-2 rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<span class="overflow-hidden truncate pr-2" v-text="label.Title" />
|
||||
<span class="group-hover:hidden text-gray-500" v-text="label.LinkedNotesCount" />
|
||||
<span class="hidden group-hover:block text-gray-500 hover:text-gray-100" @click.stop="$emit('note-open', label.Filename)">↗</span>
|
||||
</li>
|
||||
|
||||
<li title="notes with 1-word titles are considered labels"
|
||||
class="flex items-center justify-items-center h-9 pl-2 pr-1 rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<input class="h-full w-full text-white hover:placeholder:text-gray-300 placeholder:text-gray-500 bg-transparent focus:outline-none text-sm"
|
||||
@keydown.space.prevent
|
||||
@keyup.esc="newLabel=''; $refs.newLabelInput.blur()"
|
||||
@keyup.enter="createNewLabelNote()"
|
||||
v-model="newLabel" ref="newLabelInput" placeholder="new label..." type="text" autocomplete="off" spellcheck="false" />
|
||||
<div class="flex items-center text-gray-500">
|
||||
<Icon v-if="!newLabel" name="outline-plus" size="h-4 w-4" @click="$refs.newLabelInput.focus()" class="cursor-pointer hover:text-gray-200" />
|
||||
<Icon v-if="newLabelStatus.isValid" name="mini-check" size="h-4 w-4" @click="createNewLabelNote()" class="text-green-400" />
|
||||
<span v-if="newLabelStatus.error" v-text="newLabelStatus.error" class="text-red-400 text-xs whitespace-nowrap mt-1"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<Pane v-if="$notesiumState.showNotesPanel" name="notesPanel" :defaultWidth="380" :minWidth="100"
|
||||
:class="{'dark border-none': $notesiumState.notesPanelDarkMode}" class="border-r border-gray-200">
|
||||
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300 delay-200"
|
||||
leave-active-class="transition duration-200">
|
||||
<div v-if="previewFilename" class="absolute right-0 top-0 z-50">
|
||||
<div class="relative">
|
||||
<div class="absolute origin-top-right top-14 left-6 w-[40rem] h-[40rem] pl-4 py-4 rounded-lg shadow-2xl bg-white border border-gray-300
|
||||
before:absolute before:bottom-0 before:top-0 before:-left-2 before:bg-white before:border-l before:border-b before:border-gray-300
|
||||
before:w-4 before:h-4 before:rotate-45 before:-z-1 before:my-auto">
|
||||
<Preview :filename="previewFilename" appendIncomingLinks=true />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex items-center justify-items-center h-9 border-b border-gray-200 bg-gray-100 dark:bg-gray-700 dark:border-gray-600">
|
||||
<input ref="queryInput" v-model="query" placeholder="filter..." autocomplete="off" spellcheck="false"
|
||||
@keyup.esc="query = ''; $refs.queryInput.blur();"
|
||||
class="h-full w-full px-4 ring-0 border-none focus:outline-none text-sm placeholder:text-gray-400
|
||||
text-gray-900 bg-gray-100 dark:text-gray-100 dark:bg-gray-700" />
|
||||
|
||||
<div class="inline-flex items-center justify-items-center mt-3 m-2 h-full space-x-4">
|
||||
<div v-show="query" @click="query = ''" title="clear"
|
||||
class="-mt-1 pr-2 cursor-pointer border-r border-gray-300 text-gray-400 hover:text-gray-700 dark:border-gray-500 dark:hover:text-gray-300">
|
||||
<Icon name="mini-x-mark" size="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div class="relative group inline-block text-left">
|
||||
<span title="labels" class="cursor-pointer text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200">
|
||||
<Icon name="outline-tag" size="h-5 w-5" class="pb-1" />
|
||||
</span>
|
||||
<div class="hidden group-hover:block absolute right-0 z-50 w-64 pt-3 -mt-1 origin-top-right">
|
||||
<div class="rounded-md bg-white shadow-md border border-gray-200">
|
||||
<ul class="text-sm divide-y divide-gray-100">
|
||||
<template v-if="$notesiumState.showLabelsPanel || ($notesiumState.notesPanelCompact && $notesiumState.notesPanelCompactLabels)">
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer" @click="$notesiumState.sidePanelSortLabels='title'">
|
||||
<span class="text-gray-600">Title</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'title'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortLabels='links'">
|
||||
<span class="text-gray-600">Linked notes count</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'links'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li class="flex items-center justify-between p-2 text-gray-300">
|
||||
<span>Title</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'title'"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 text-gray-300">
|
||||
<span>Link count</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'links'"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="pt-px bg-gray-200"></li>
|
||||
<li v-if="$notesiumState.notesPanelCompact" class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="$notesiumState.notesPanelCompactLabels=!$notesiumState.notesPanelCompactLabels">
|
||||
<span class="text-gray-600">Labels tree</span>
|
||||
<span v-show="$notesiumState.notesPanelCompactLabels" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li v-else class="flex items-center justify-between p-2 text-gray-300">
|
||||
<span>Labels tree</span>
|
||||
<span v-show="$notesiumState.notesPanelCompactLabels"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="$notesiumState.showLabelsPanel=!$notesiumState.showLabelsPanel">
|
||||
<span class="text-gray-600">Labels panel</span>
|
||||
<span v-show="$notesiumState.showLabelsPanel" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<template v-if="!$notesiumState.showLabelsPanel">
|
||||
<li class="pt-px bg-gray-200"></li>
|
||||
<li class="flex items-center justify-items-center p-2" title="notes with 1-word titles are considered labels">
|
||||
<input class="h-full w-full bg-transparent focus:outline-none text-sm placeholder:text-gray-400 text-gray-800"
|
||||
@keydown.space.prevent
|
||||
@keyup.esc="newLabel=''; $refs.newLabelInput.blur()"
|
||||
@keyup.enter="createNewLabelNote()"
|
||||
v-model="newLabel" ref="newLabelInput" placeholder="New label..." type="text" autocomplete="off" spellcheck="false" />
|
||||
<div class="flex items-center cursor-pointer">
|
||||
<Icon v-if="!newLabel" name="outline-plus" size="h-5 w-5" @click="$refs.newLabelInput.focus()" class="text-gray-400 hover:text-gray-600" />
|
||||
<Icon v-if="newLabelStatus.isValid" name="mini-check" size="h-5 w-5" @click="createNewLabelNote()" class="text-green-500" />
|
||||
<span v-if="newLabelStatus.error" v-text="newLabelStatus.error" class="cursor-default text-red-700 text-xs whitespace-nowrap mt-1"></span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group inline-block text-left">
|
||||
<span title="sort & density" class="cursor-pointer text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200">
|
||||
<Icon name="outline-bars-arrow-down" size="h-5 w-5" />
|
||||
</span>
|
||||
<div class="hidden group-hover:block absolute right-0 z-50 w-64 pt-3 -mt-1 origin-top-right">
|
||||
<div class="rounded-md bg-white shadow-md border border-gray-200">
|
||||
<ul class="divide-y divide-gray-100 text-sm">
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='title'">
|
||||
<span class="text-gray-600">Title</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'title'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='mtime'">
|
||||
<span class="text-gray-600">Modified</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'mtime'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='ctime'">
|
||||
<span class="text-gray-600">Created</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'ctime'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='links'">
|
||||
<span class="text-gray-600">Linked notes count</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'links'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="bg-gray-200 pt-px"></li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.notesPanelCompact=true">
|
||||
<span class="text-gray-600">Compact view</span>
|
||||
<span v-show="$notesiumState.notesPanelCompact" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.notesPanelCompact=false">
|
||||
<span class="text-gray-600">Detailed view</span>
|
||||
<span v-show="!$notesiumState.notesPanelCompact" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="bg-gray-200 pt-px"></li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="$notesiumState.notesPanelDarkMode=!$notesiumState.notesPanelDarkMode">
|
||||
<span class="text-gray-600">Dark mode</span>
|
||||
<span v-show="$notesiumState.notesPanelDarkMode" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-full flex flex-col justify-between overflow-y-scroll dark:bg-gray-700" :class="{'dark-scroll': $notesiumState.notesPanelDarkMode}">
|
||||
|
||||
<ul v-if="$notesiumState.notesPanelCompact" class="mt-2 text-sm">
|
||||
<li v-if="$notesiumState.notesPanelCompactLabels" v-for="note in filteredLabelNotes" :key="'label-' + note.Filename">
|
||||
<details class="cursor-pointer [&_.rotate-on-open]:open:rotate-90">
|
||||
<summary class="group flex items-center justify-between justify-items-center list-none py-1.5 pl-2
|
||||
rounded-r-2xl focus:outline-none text-gray-900 hover:bg-indigo-50 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<div class="flex items-center justify-center gap-x-2 truncate">
|
||||
<div class="text-gray-400 dark:text-gray-500 rotate-on-open">
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</div>
|
||||
<span class="flex space-x-2 overflow-hidden truncate pr-2">
|
||||
<span v-text="note.Title" />
|
||||
<span class="hidden group-hover:block text-gray-400 dark:text-gray-500" v-text="'('+note.LinkedNotesCount+')'" />
|
||||
<span>
|
||||
</div>
|
||||
<span class="hidden group-hover:flex space-x-2 pr-2 whitespace-nowrap">
|
||||
<span title="list links" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 mt-1"
|
||||
@click.stop="$emit('finder-open', '/api/raw/links?color=true&filename=' + note.Filename)">
|
||||
<Icon name="mini-arrows-right-left" size="h-3 w-3" />
|
||||
</span>
|
||||
<span class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
@mouseenter="previewFilename=note.Filename" @mouseleave="previewFilename=''"
|
||||
@click.stop="$emit('note-open', note.Filename, 1)">↗</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div v-if="note.LinkedNotesCount > 0" class="ml-[18px] border-dotted border-l border-gray-300 dark:border-gray-500">
|
||||
<div v-for="link in note.LinkedNotes" :key="'link-' + link.Filename" @click="$emit('note-open', link.Filename, 1)"
|
||||
class="group flex items-center justify-items-center justify-between pl-[18px] py-1 pr-2 truncate rounded-r-2xl
|
||||
text-gray-900 dark:text-gray-400 hover:bg-indigo-50 dark:hover:bg-gray-600">
|
||||
<div class="leading-6 overflow-hidden truncate" v-text="link.Title" :title="link.Title"></div>
|
||||
<div class="hidden group-hover:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 -mr-1 pr-1"
|
||||
@mouseenter="previewFilename=link.Filename" @mouseleave="previewFilename=''" v-text="'↗'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
<li v-for="note in filteredNotes" :key="note.Filename"
|
||||
@click="$emit('note-open', note.Filename)"
|
||||
class="group flex justify-between items-center py-1 pl-4 pr-2 cursor-pointer rounded-r-2xl text-gray-900 hover:bg-indigo-50
|
||||
dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<div class="text-sm leading-6 overflow-hidden truncate" v-text="note.Title" :title="note.Title"></div>
|
||||
<div class="hidden group-hover:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 -mr-1 pr-1"
|
||||
@mouseenter="previewFilename=note.Filename" @mouseleave="previewFilename=''" v-text="'↗'">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul v-else class="divide-y divide-gray-100 dark:divide-gray-600">
|
||||
<li v-for="note in filteredNotes" :key="note.Filename"
|
||||
@click="$emit('note-open', note.Filename)"
|
||||
class="group flex justify-between py-3 pl-4 pr-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<div class="truncate">
|
||||
<div class="text-sm leading-6 overflow-hidden truncate text-gray-900 dark:text-gray-300" v-text="note.Title" :title="note.Title"></div>
|
||||
<div class="flex space-x-1 overflow-hidden truncate text-xs text-gray-400 leading-6">
|
||||
<span v-if="$notesiumState.sidePanelSortNotes == 'ctime'" v-text="note.CtimeRelative" title="created" />
|
||||
<span v-else v-text="note.MtimeRelative" title="modified" />
|
||||
<div class="space-x-1 overflow-hidden truncate">
|
||||
<template v-for="label in note.LinkedLabels">
|
||||
<span>·</span>
|
||||
<span class="hover:text-gray-600 dark:hover:text-gray-200" v-text="label" @click.stop="query='label:'+label+' '"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden group-hover:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 -my-3 py-3 -mr-2 pr-2"
|
||||
@mouseenter="previewFilename=note.Filename" @mouseleave="previewFilename=''" v-text="'↗'">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="query" class="group m-4 cursor-pointer text-xs text-gray-500 dark:text-gray-400"
|
||||
@click="$emit('finder-open', '/api/raw/lines', query.replace(/label:/g, ''))">
|
||||
<div class="truncate">
|
||||
{{ filteredNotes.length }}/{{ notesLength }} matches for "<span class="italic text-gray-600 dark:text-gray-300" v-text="query"></span>"
|
||||
</div>
|
||||
<div class="group-hover:underline mt-1" >Full text search →</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Pane>
|
||||
`
|
||||
|
||||
import Icon from './icon.js'
|
||||
import Pane from './pane.js'
|
||||
import Preview from './preview.js'
|
||||
import { formatDate } from './dateutils.js';
|
||||
export default {
|
||||
props: ['lastSave'],
|
||||
emits: ['note-open', 'note-new', 'finder-open'],
|
||||
components: { Pane, Icon, Preview },
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
notes: [],
|
||||
notesLength: 0,
|
||||
newLabel: '',
|
||||
previewFilename: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchNotes() {
|
||||
fetch("/api/notes")
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
|
||||
.then(response => {
|
||||
const notes = Object.values(response);
|
||||
this.notesLength = notes.length;
|
||||
this.notes = notes.map(note => {
|
||||
const linkedNotes = [...new Map(
|
||||
[...(note.IncomingLinks || []), ...(note.OutgoingLinks || [])].map(link => [link.Filename, { Filename: link.Filename, Title: link.Title }])
|
||||
).values()].sort((a, b) => a.Title.localeCompare(b.Title));
|
||||
const linkedLabels = linkedNotes.filter(link => link.Title && !link.Title.includes(' ')).map(link => link.Title);
|
||||
const mtime = new Date(note.Mtime);
|
||||
const ctime = new Date(note.Ctime);
|
||||
return {
|
||||
Filename: note.Filename,
|
||||
Title: note.Title,
|
||||
Mtime: mtime,
|
||||
Ctime: ctime,
|
||||
MtimeRelative: this.formatRelativeDate(mtime),
|
||||
CtimeRelative: this.formatRelativeDate(ctime),
|
||||
IsLabel: note.IsLabel,
|
||||
LinkedLabels: linkedLabels,
|
||||
LinkedNotes: linkedNotes,
|
||||
LinkedNotesCount: linkedNotes.length,
|
||||
SearchStr: (note.Title + ' ' + linkedLabels.join(' ')).toLowerCase(),
|
||||
};
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
formatRelativeDate(date) {
|
||||
const now = new Date();
|
||||
const nowTime = now.getTime();
|
||||
const dateTime = date.getTime();
|
||||
const diff = nowTime - dateTime;
|
||||
|
||||
const minutes = Math.floor(diff / 60000); // 60 * 1000
|
||||
const hours = Math.floor(diff / 3600000); // 60 * 60 * 1000
|
||||
|
||||
if (minutes < 0) {
|
||||
// future date
|
||||
} else if (minutes < 1) {
|
||||
return 'Just now';
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
} else if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
} else if (hours < 48) {
|
||||
return `Yesterday`;
|
||||
}
|
||||
|
||||
const format = now.getFullYear() === date.getFullYear() ? '%b %d' : '%b %d, %Y';
|
||||
return formatDate(date, format);
|
||||
},
|
||||
createNewLabelNote() {
|
||||
if (this.newLabelStatus.isValid) {
|
||||
const content = `# ${this.newLabel}\n`;
|
||||
this.$emit('note-new', null, content);
|
||||
this.$refs.newLabelInput.blur();
|
||||
this.newLabel='';
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
newLabelStatus() {
|
||||
if (!this.newLabel) return { isValid: false, error: '' };
|
||||
if (this.newLabel.includes(' ')) return { isValid: false, error: 'not 1-word' };
|
||||
if (this.sortedLabelNotes.some(label => label.Title.toLowerCase() === this.newLabel.toLowerCase())) return { isValid: false, error: 'exists' };
|
||||
return { isValid: true, error: '' };
|
||||
},
|
||||
sortedNotes() {
|
||||
switch(this.$notesiumState.sidePanelSortNotes) {
|
||||
case 'title': return this.notes.sort((a, b) => a.Title.localeCompare(b.Title));
|
||||
case 'links': return this.notes.sort((a, b) => b.LinkedNotesCount - a.LinkedNotesCount);
|
||||
case 'mtime': return this.notes.sort((a, b) => b.Mtime - a.Mtime);
|
||||
case 'ctime': return this.notes.sort((a, b) => b.Ctime - a.Ctime);
|
||||
}
|
||||
},
|
||||
sortedLabelNotes() {
|
||||
switch(this.$notesiumState.sidePanelSortLabels) {
|
||||
case 'title': return this.notes.filter(note => note.IsLabel).sort((a, b) => a.Title.localeCompare(b.Title));
|
||||
case 'links': return this.notes.filter(note => note.IsLabel).sort((a, b) => b.LinkedNotesCount - a.LinkedNotesCount);
|
||||
}
|
||||
},
|
||||
filteredNotes() {
|
||||
const maxNotes = 300;
|
||||
const { query, sortedNotes } = this;
|
||||
if (!query) return sortedNotes.slice(0, maxNotes);
|
||||
const queryWords = query.toLowerCase().split(' ');
|
||||
const labelQuery = queryWords.find(word => word.startsWith('label:'));
|
||||
if (labelQuery) {
|
||||
const label = labelQuery.slice(6);
|
||||
if (!label) return sortedNotes.filter(note => note.IsLabel).slice(0, maxNotes);
|
||||
const notesSubset = sortedNotes.filter(note => note.LinkedLabels.some(l => l.toLowerCase() === label) || note.Title.toLowerCase() === label);
|
||||
const remainingQueryWords = queryWords.filter(word => word !== labelQuery);
|
||||
return notesSubset.filter(note => remainingQueryWords.every(queryWord => note.SearchStr.includes(queryWord))).slice(0, maxNotes);
|
||||
}
|
||||
return sortedNotes.filter(note => queryWords.every(queryWord => note.SearchStr.includes(queryWord))).slice(0, maxNotes);
|
||||
},
|
||||
filteredLabelNotes() {
|
||||
const { query, sortedLabelNotes } = this;
|
||||
if (!query) return sortedLabelNotes;
|
||||
const queryWords = query.toLowerCase().split(' ');
|
||||
return sortedLabelNotes.filter(note => queryWords.every(queryWord => note.SearchStr.includes(queryWord)));
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchNotes();
|
||||
},
|
||||
watch: {
|
||||
'lastSave': function() { this.fetchNotes(); },
|
||||
},
|
||||
template: t
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const { reactive, watch } = Vue;
|
||||
|
||||
const defaultState = {
|
||||
showGraphPanel: false,
|
||||
showLabelsPanel: false,
|
||||
showNotesPanel: false,
|
||||
showNoteSidebar: true,
|
||||
notesPanelDarkMode: false,
|
||||
notesPanelCompact: false,
|
||||
notesPanelCompactLabels: true,
|
||||
sidePanelSortNotes: 'title', // title, links, mtime, ctime
|
||||
sidePanelSortLabels: 'title', // title, links
|
||||
editorFoldGutter: false,
|
||||
editorLineWrapping: false,
|
||||
editorConcealFormatting: true,
|
||||
editorVimMode: false,
|
||||
startOfWeek: 1, // 0 for Sunday, 1 for Monday, ...
|
||||
};
|
||||
|
||||
const savedState = localStorage.getItem('notesiumState');
|
||||
const initialState = savedState ? JSON.parse(savedState) : defaultState;
|
||||
const notesiumState = reactive({ ...defaultState, ...initialState });
|
||||
|
||||
watch(notesiumState, (newState) => {
|
||||
Object.assign(newState, { ...defaultState, ...newState });
|
||||
localStorage.setItem('notesiumState', JSON.stringify(newState));
|
||||
}, { deep: true });
|
||||
|
||||
export { notesiumState };
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["*.js", "!vendor.js", "!tailwind.config.js"],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Reference in New Issue
Block a user