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:
2026-04-28 21:52:25 +02:00
co-authored by Claude Opus 4.7
parent b816914b9c
commit df9407dd8d
63 changed files with 7391 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.vendor/
vendor.js
vendor.css
tailwind.css
+40
View File
@@ -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
}
+480
View File
@@ -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
}
+184
View File
@@ -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;
}
+81
View File
@@ -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;
});
}
+54
View File
@@ -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
}
+145
View File
@@ -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
}
+35
View File
@@ -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);
}
+60
View File
@@ -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
}
+1
View File
@@ -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

+107
View File
@@ -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
}
+204
View File
@@ -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
}
+158
View File
@@ -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
}
+164
View File
@@ -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
}
+116
View File
@@ -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
}
+124
View File
@@ -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>
+58
View File
@@ -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
}
+80
View File
@@ -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 "$@"
+122
View File
@@ -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
}
+137
View File
@@ -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}}&swarr;
</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}}&nearr;
</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
}
+333
View File
@@ -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
}
+71
View File
@@ -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
}
+62
View File
@@ -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
}
+82
View File
@@ -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
}
+84
View File
@@ -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
}
+157
View File
@@ -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"> &rarr;</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 (&#96; ')</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
}
+54
View File
@@ -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
}
+387
View File
@@ -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 &amp; 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 &rarr;</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
}
+29
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
tailwind.css
d3.min.js
+100
View File
@@ -0,0 +1,100 @@
As of version [0.5.3](https://github.com/alonswartz/notesium/blob/master/CHANGELOG.md#053), the `web/graph` has been rewritten and implemented
in `web/app`, with all of same features (except cluster settings and
darkmode), has tighter integration, and additional improvements.
This graph implemention is backwards compatible, and is especially
useful for Vim users who don't need the additional features, but instead
just want a way to view the graph via a single `keybind` and have
optionally configured the `notesium://` [URI protocol](#custom-uri-protocol) handler for the
edit links available in the note preview side pane.
## Table of contents
- [Features](#features)
- [Screenshots](#screenshots)
- [Download](#download)
- [CLI](#cli)
- [Vim](#vim)
## Features
- Visual overview of notes structure with a force graph view.
- Cluster nodes based on links, inferred from titles or creation date.
- Adjust node size dynamically based on bi-directional link count.
- Emphasize nodes and their links using search filter or node click.
- Preview notes in a side panel. Open for editing via `notesium://` link.
- Tweak forces such as repel force, collide radius, and strength.
- Drag, pan, or zoom the graph for a better view or focus.
- Customize label visibility or automatically scale per zoom level.
## Screenshots
*Graph: display all notes and their links in a force graph view*
![image: force graph cluster links](https://www.notesium.com/images/screenshot-1688650369.png)
<br/>
*Graph: cluster notes based on their titles instead of links*
![image: force graph cluster titles](https://www.notesium.com/images/screenshot-1687865971.png)
<br/>
*Graph: filter notes with emphasized matches. preview note content (dark mode)*
![image: force graph note preview](https://www.notesium.com/images/screenshot-1690971723.png)
<br/>
*Graph: zoomed out large note collection (dark mode)*
![image: force graph zoom](https://www.notesium.com/images/screenshot-1682941869.png)
<br/>
## Download
As of version 0.5.3, the `web/graph` is no longer embedded in the
release binary, so it needs to be downloaded separately and vendor/css
files *handled* (depending on your preference).
**Offline usage**
Download vendor files and compile CSS (assumes Linux and [tailwindcss standalone-cli](https://tailwindcss.com/blog/standalone-cli)).
```bash
git clone https://github.com/alonswartz/notesium.git
cd notesium
./web/graph/make.sh all
```
**CDN usage**
```bash
git clone https://github.com/alonswartz/notesium.git
cd notesium
$EDITOR web/graph/index.html
```
```diff
<title>Notesium Graph</title>
- <!--
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
- -->
- <link href="tailwind.css" rel="stylesheet">
- <script src="d3.min.js"></script>
<script src="forcegraph.js"></script>
```
## CLI
```bash
notesium --webroot=/path/to/notesium/web/graph --stop-on-idle --open-browser
```
## Vim
```vim
command! -bang NotesiumGraph
\ let webroot = "/path/to/notesium/web/graph" |
\ let options = "--webroot=".webroot." --stop-on-idle --open-browser" |
\ execute ":silent !nohup notesium web ".options." > /dev/null 2>&1 &"
nnoremap <silent> <Leader>ng :NotesiumGraph<CR>
```
+221
View File
@@ -0,0 +1,221 @@
function initialize_forcegraph(data, graphdiv) {
const links = data.links.map(d => Object.create(d));
const nodes = data.nodes.map(d => Object.create(d));
const svg = d3.select(graphdiv).append("svg")
.style("height", "inherit")
.style("width", "inherit")
.attr("viewBox", [-160, -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)
.attr("nid", d => d.id)
.classed("node", true)
.classed("node-oneword", d => (d.title.split(" ").length == 1))
.classed("node-dangling", d => (d.title === 'dangling link'))
.classed("node-ghost", d => (d.type === 'ghost'))
.call(drag(simulation));
const title = node.append("title")
.text(d => (d.type === "ghost" ? "ghost: " + d.title : d.id + ": " + d.title));
const label = svg.append("g")
.selectAll("circle")
.data(nodes)
.enter()
.append('a')
.append("text")
.classed("label", true)
.attr("alignment-baseline", "middle")
.attr("href", node => (node.type === 'ghost' ? null : node.id))
.text(node => (node.title == 'dangling link') ? '' : node.title);
const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', function(event) {
svg.selectAll('g').attr('transform', event.transform);
if (d3.select("#forcegraph-scale-labels").property("checked")) {
scaleLabels(event);
}
});
svg.call(zoom);
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);
label
.attr('x', d => d.x + 4).attr('y', d => d.y);
});
// emphasize or de-emphasize nodes and their links and labels
var emphasizeNodesArr = [];
function emphasizeNodes() {
if (emphasizeNodesArr.length > 0) {
const _linkedIds = data.links.filter(l => emphasizeNodesArr.includes(l.source) || emphasizeNodesArr.includes(l.target));
const linkedIds = Array.from(new Set(_linkedIds.flatMap(l => [l.source, l.target])));
node.attr("fill-opacity", 0.1);
node.filter(n => linkedIds.includes(n.id)).attr("fill-opacity", 0.3)
node.filter(n => emphasizeNodesArr.includes(n.id)).attr("fill-opacity", 1)
label.attr("fill-opacity", 0.3).attr("font-weight", "normal");
label.filter(l => linkedIds.includes(l.id)).attr("fill-opacity", 1)
label.filter(l => emphasizeNodesArr.includes(l.id)).attr("fill-opacity", 1).attr("font-weight", "bold")
link.attr("stroke", "currentColor").attr("stroke-opacity", 0.3);
link.filter(l => emphasizeNodesArr.includes(l.source.id) || emphasizeNodesArr.includes(l.target.id)).attr("stroke-opacity", 1);
} else {
node.attr("fill-opacity", 1)
label.attr("fill-opacity", 1).attr("font-weight", "normal");
link.attr("stroke", "currentColor").attr("stroke-opacity", 1);
}
};
// emphasize or de-emphasize when nodes are clicked
node.on("click", function(event) {
const nid = event.target.attributes.nid.value;
emphasizeNodesArr.includes(nid) ?
emphasizeNodesArr.splice(emphasizeNodesArr.indexOf(nid), 1) :
emphasizeNodesArr.push(nid);
emphasizeNodes();
});
// filter: filtered list
function filteredList(results) {
const resultsDom = d3.select("#forcegraph-filter-results");
const resultsSorted = results.sort((a, b) => a.title.localeCompare(b.title));
const searchWords = d3.select("#forcegraph-filter").node().value.toLowerCase().split(" ").filter(n => n.replace(/\W/g, ''));
const searchExp = new RegExp(`(${searchWords.join('|')})`, 'ig');
resultsDom.html("");
resultsSorted.forEach(n => {
var href = n.type === 'ghost' ? null : n.id;
var title = n.title.replace(searchExp, '<b><a href="' + href + '">$1</a></b>');
resultsDom.append("li").append("a").attr("href", href).html(title)
});
}
// filter: search node titles word-wise
function searchNodes(searchStr) {
const searchWords = searchStr.toLowerCase().split(" ");
return data.nodes.filter(n => {
return searchWords.every(searchWord =>
n.title.toLowerCase().includes(searchWord)
);
});
}
// filter: list and graph emphasis when node titles match
d3.select('#forcegraph-filter').on('input', function() {
if (this.value) {
const results = searchNodes(this.value);
filteredList(results);
emphasizeNodesArr = [...results.map(n => n.id)];
emphasizeNodes();
} else {
filteredList([]);
emphasizeNodesArr.splice(0);
emphasizeNodes();
}
});
// dynamic circle radius based on links count
d3.select("#forcegraph-dynamic-radius").on("change", function() {
if (this.checked) {
node.attr("r", (d) => data.links.reduce((i, l) => (l.source === d.id || l.target === d.id) ? i + 0.1 : i, 1));
} else {
node.attr("r", 2);
}
});
// toggle labels
d3.select("#forcegraph-labels").on("change", function() {
svg.selectAll('.label').classed("hidden", !this.checked);
});
// scale labels
d3.select("#forcegraph-scale-labels").on("change", scaleLabels);
function scaleLabels(event) {
let k = 1;
let labelSize = 5;
switch (event.type) {
case "zoom":
k = event.transform.k;
break;
case "change":
if (event.target.checked) k = d3.zoomTransform(svg.node()).k;
break;
}
labelSize = k > 0.9 ? labelSize - k : 0;
svg.selectAll('.label').transition().style("font-size", labelSize + "px");
}
// repel force strength
d3.select("#forcegraph-force-strength").on("change", function() {
d3.select("#forcegraph-force-strength-value").text(this.value);
simulation.force("charge", d3.forceManyBody().strength(this.value));
simulation.alpha(1).restart();
});
// collide radius
d3.select("#forcegraph-collide-radius").on("change", function() {
d3.select("#forcegraph-collide-radius-value").text(this.value);
simulation.force("collide").radius(this.value);
simulation.alpha(1).restart();
});
// collide strength
d3.select("#forcegraph-collide-strength").on("change", function() {
d3.select("#forcegraph-collide-strength-value").text(this.value);
simulation.force("collide").strength(this.value);
simulation.alpha(1).restart();
});
// trigger settings consistency upon initialization
d3.select('#forcegraph-filter').dispatch("change");
d3.select("#forcegraph-dynamic-radius").dispatch("change");
d3.select("#forcegraph-labels").dispatch("change");
d3.select("#forcegraph-scale-labels").dispatch("change");
d3.select("#forcegraph-force-strength").dispatch("change");
d3.select("#forcegraph-collide-radius").dispatch("change");
d3.select("#forcegraph-collide-strength").dispatch("change");
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);
}
}
+393
View File
@@ -0,0 +1,393 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notesium Graph</title>
<!--
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
-->
<link href="tailwind.css" rel="stylesheet">
<script src="d3.min.js"></script>
<script src="forcegraph.js"></script>
<style>
#forcegraph { margin: auto; width: 97vw; height: 97vh; }
#forcegraph .node { fill: #ff7f0e; cursor: pointer; }
#forcegraph .node-oneword { fill: #1f77b4; }
#forcegraph .node-ghost { fill: #a855f7; }
#forcegraph .node-dangling { fill-opacity: 0.3; }
#forcegraph .link { stroke-width: 0.5; }
#forcegraph .label { fill: #475569; font-size: 4px; cursor: pointer; }
#forcegraph .label:hover { text-decoration: underline; }
.dark #forcegraph .label { fill: #94a3b8; }
#forcegraph-filter-results li { margin-bottom: 3px; }
#forcegraph-filter-results a:hover { text-decoration: underline; }
#preview-panel-content a { color: #2563eb; }
#preview-panel-content a:hover { text-decoration: underline; }
#preview-panel-content .heading { color: #ff7f0e; font-weight: bold;}
#preview-panel-content-body pre { background-color: #f8fafc; padding: 5px;}
.dark #preview-panel-content a { color: #60a5fa; }
.dark #preview-panel-content hr { border-top: 1px solid #475569; }
.dark #preview-panel-content-body pre { background-color: #0f172a; padding: 5px;}
</style>
</head>
<body>
<div class="h-screen bg-slate-50 dark:bg-slate-800 dark:text-white">
<div id="preview-panel" hidden class="relative z-10" role="dialog" aria-modal="true">
<div class="absolute inset-0 overflow-hidden">
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
<div class="w-screen max-w-xl">
<div class="flex h-full flex-col overflow-y-scroll bg-white py-3 shadow-xl dark:text-slate-300 dark:shadow-slate-700 dark:bg-slate-800">
<div class="absolute top-0 right-2 px-4 py-2 flex">
<a id="preview-panel-editlink" href="#" class="p-1 cursor-pointer text-gray-200 hover:text-gray-400" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<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>
</a>
<div onclick="toggleDiv('#preview-panel')" class="p-1 cursor-pointer text-gray-200 hover:text-gray-400">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div id="preview-panel-content" class="relative flex-1 mt-4 px-4 sm:px-6 text-xs leading-normal">
<pre id="preview-panel-content-body"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<ul id="forcegraph-filter-results" class="absolute top-0 left-44 max-h-screen overflow-y-hidden pt-6 p-4 w-max text-gray-600 text-xs backdrop-blur-sm bg-slate-50/10 dark:text-slate-400 dark:bg-slate-800/10"></ul>
<div class="absolute w-44 space-y-2 text-xs text-gray-600 border border-gray-200 rounded-lg px-2 py-1.5 m-2 backdrop-blur-sm bg-white/30
dark:text-slate-400 dark:border-slate-700 dark:bg-slate-900/50">
<div class="flex items-center justify-between pt-1 pb-3">
<input id="forcegraph-filter" placeholder="filter..." type="text" autocomplete="off" spellcheck="false"
class="w-full px-2 py-1 border focus:outline-none rounded-md dark:bg-slate-900 dark:border-slate-700">
<div class="flex h-5 items-center">
<input id="forcegraph-filter-results-toggle" type="checkbox" checked class="h-4 w-4 ml-2 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</div>
<div onclick="toggleDiv('#settings-display')" class="flex items-center justify-between py-1 cursor-pointer">
<p class="text-sm text-gray-900 dark:text-slate-200">display</p>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<ul id="settings-display" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
<li class="flex items-center justify-between py-3">
<span>dark mode</span>
<div class="flex h-5 items-center">
<input id="darkmode" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>show labels</span>
<div class="flex h-5 items-center">
<input id="forcegraph-labels" type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>auto-scale labels</span>
<div class="flex h-5 items-center">
<input id="forcegraph-scale-labels" type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>size nodes per links</span>
<div class="flex h-5 items-center">
<input id="forcegraph-dynamic-radius" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
</ul>
<div onclick="toggleDiv('#settings-cluster')" class="flex items-center justify-between py-1 cursor-pointer">
<p class="text-sm text-gray-900 dark:text-slate-200">cluster</p>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<ul id="settings-cluster" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
<li class="flex items-center justify-between py-3">
<span>default</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDefault()" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>infer from titles</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterTitles()" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>creation day</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY-MM-DD')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>creation week</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY/WeekXX')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>creation month</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY-MM')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
</ul>
<div onclick="toggleDiv('#settings-forces')" class="flex items-center justify-between py-1 cursor-pointer">
<p class="text-sm text-gray-900 dark:text-slate-200">forces</p>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<ul id="settings-forces" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
<li class="py-3 space-y-1">
<div class="flex items-center justify-between">
<span>repel force</span>
<span id="forcegraph-force-strength-value">-30</span>
</div>
<input class="w-full" id="forcegraph-force-strength" type="range" value="-30" min="-100" max="0" step="1">
</li>
<li class="py-3 space-y-1">
<div class="flex items-center justify-between">
<span>collide radius</span>
<span id="forcegraph-collide-radius-value">1</span>
</div>
<input class="w-full" id="forcegraph-collide-radius" type="range" value="1" min="1" max="50" step="1">
</li>
<li class="py-3 space-y-1">
<div class="flex items-center justify-between">
<span>collide strength</span>
<span id="forcegraph-collide-strength-value">0.5</span>
</div>
<input class="w-full" id="forcegraph-collide-strength" type="range" value="0.5" min="0" max="1" step="0.05">
</li>
</ul>
</div>
<div id="forcegraph" class="text-gray-300/70 dark:text-slate-700"></div>
</div>
<script type="text/javascript">
function sendHeartbeat() {
fetch("/api/heartbeat")
.then(response => {
if (!response.ok) {
throw new Error("HTTP status " + response.status);
}
})
.catch(error => {
console.log("Failed to send heartbeat: " + error.message);
clearInterval(heartbeatInterval);
});
}
var heartbeatInterval = setInterval(sendHeartbeat, 5000);
var originalData = null;
function clusterDefault() {
document.getElementById("forcegraph").innerHTML = "";
initialize_forcegraph(originalData, '#forcegraph');
}
function clusterTitles() {
const data = {
"href": originalData.href,
"nodes": [ ...originalData.nodes ],
"links": []
};
const clusters = new Set();
data.nodes.forEach(node => {
const match = node.title.match(/^(\w+):/);
if (match) {
clusters.add(match[1]);
data.links.push({'source': node.id, 'target': match[1]});
}
});
clusters.forEach(c => data.nodes.push({'id': c, 'title': c, 'type': 'ghost'}));
document.getElementById("forcegraph").innerHTML = "";
initialize_forcegraph(data, '#forcegraph');
}
function clusterDates(format="YYYY-MM-DD") {
const data = {
"href": originalData.href,
"nodes": [ ...originalData.nodes ],
"links": []
};
const clusters = new Set();
data.nodes.forEach(node => {
let epoch = parseInt(node.id.slice(0, -3), 16);
let dateTime = new Date(epoch * 1000);
let year = dateTime.getFullYear();
switch (format) {
case 'YYYY-MM-DD':
month = String(dateTime.getMonth() + 1).padStart(2, '0');
day = String(dateTime.getDate()).padStart(2, '0');
formattedDate = `${year}-${month}-${day}`;
break;
case 'YYYY-MM':
month = String(dateTime.getMonth() + 1).padStart(2, '0');
formattedDate = `${year}-${month}`;
break;
case 'YYYY/WeekXX':
onejan = new Date(year, 0, 1);
week = Math.ceil(((dateTime - onejan) / 86400000 + onejan.getDay() + 1) / 7);
formattedDate = `${year}/Week${week}`;
}
clusters.add(formattedDate);
data.links.push({'source': node.id, 'target': formattedDate});
})
clusters.forEach(c => data.nodes.push({'id': c, 'title': c, 'type': 'ghost'}));
document.getElementById("forcegraph").innerHTML = "";
initialize_forcegraph(data, '#forcegraph');
}
// toggle settings section
function toggleDiv(settingsDiv) {
d3.select(settingsDiv).attr("hidden", function() {
return this.hasAttribute("hidden") ? null : true;
});
}
// toggle darkmode
d3.select("#darkmode").on("change", function() {
d3.select("body").classed("dark", this.checked);
});
// toggle filtered results list
d3.select("#forcegraph-filter-results-toggle").on("change", function() {
d3.select("#forcegraph-filter-results").classed("hidden", !this.checked);
});
// capture keypress events
d3.select("body").on('keyup', function() {
if (event.code === 'Slash' && document.activeElement.id != 'forcegraph-filter') {
d3.select("#forcegraph-filter").node().focus();
event.preventDefault();
return;
}
if (event.code === 'Escape' && document.activeElement.id === 'forcegraph-filter') {
d3.select("#forcegraph-filter").node().value = '';
d3.select("#forcegraph-filter").node().blur();
d3.select("#forcegraph-filter").dispatch("input");
event.preventDefault();
return;
}
});
// capture click events
d3.select("body").on('click', function() {
if (! event.target.attributes.href ) return;
if (! event.target.attributes.href.value ) return;
if (! event.target.attributes.href.value.endsWith('.md') ) return;
if (! graphData.nodes.map(n => n.id).includes(event.target.attributes.href.value) ) return;
event.preventDefault();
getNote(event.target.attributes.href.value);
});
// markdownify
function markdownify(s) {
const blocks = [];
s = s.replace(/(```)([^`]*?)(```)/gs, function(_, start, content, end) {
var div = document.createElement("div");
div.textContent = content;
blocks.push('<pre>' + div.innerHTML + '</pre>');
return '%%BLOCK' + (blocks.length - 1) + '%%';
});
s = s
.replace(/(^|\s)\[(.*?)\]\((http[s]?:\/\/.*?)\)/g, '$1<a href="$3" target="_blank">$2➜</a>')
.replace(/(^|\s)\[(.*?)\]\((?!http[s]?:\/\/)(?!.*\.md$)(.*?)\)/g, '$1<a href="$3">$2</a>')
.replace(/(^|\n)(#+\s.*?)(?=\n|$)/g, '$1<span class="heading">$2</span>')
.replace(/\*\*(.*?)\*\*/gs, '<b>$1</b>')
.replace(/`([^`]*)`/g, '<b><i>`$1`</i></b>')
.trimEnd();
blocks.forEach((block, index) => {
s = s.replace('%%BLOCK' + index + '%%', block);
});
return s;
}
// preview panel
function getNote(id) {
fetch("/api/notes/" + id)
.then(response => response.json())
.then(data => {
document.getElementById("preview-panel").hidden = false;
document.getElementById("preview-panel-editlink").href = 'notesium://' + data.Path;
document.getElementById("preview-panel-editlink").hidden = hideEditLink;
document.getElementById("preview-panel-content-body").innerHTML = markdownify(data.Content);
var backlinks = ""
if (data.IncomingLinks) {
backlinks += "\n\n<hr/>\n\n## backlinks\n\n";
var linksWithTitles = data.IncomingLinks.map(link => {
var node = graphData.nodes.find(node => node.id === link.Filename);
var title = node ? node.title : link.Filename;
return { link: link, title: title };
});
linksWithTitles.sort((a, b) => a.title.localeCompare(b.title));
linksWithTitles.forEach(item => {
backlinks += `[${item.title}](${item.link.Filename})\n`;
});
document.getElementById("preview-panel-content-body").innerHTML += markdownify(backlinks);
}
});
}
const hideEditLink = new URLSearchParams(window.location.search).has('noxdg');
const graphData = {
"nodes": [],
"links": [],
}
fetch("/api/notes")
.then(response => response.json())
.then(data => {
for (const key in data) {
const note = data[key];
graphData.nodes.push({'id': note.Filename, 'title': note.Title});
if (note.OutgoingLinks) {
note.OutgoingLinks.forEach(link => {
graphData.links.push({'source': note.Filename, 'target': link.Filename});
});
}
}
// dangling links
const nodes = graphData.nodes.map(n => n.id);
const targets = graphData.links.map(l => l.target);
const dangling = targets.filter(id => !nodes.includes(id));
dangling.forEach(id => graphData.nodes.push({'id': id, 'title': 'dangling link', 'type': 'ghost'}));
originalData = graphData;
clusterDefault();
});
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash -e
fatal() { echo "Fatal: $*" 1>&2; exit 1; }
usage() {
cat<<EOF
Usage: $0 COMMAND [OPTIONS]
Commands:
all handle vendor, tailwind
vendor download and verify vendor files
tailwind [--watch] build tailwind.css
EOF
exit 1
}
D3JS_URL="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"
D3JS_HASH="d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c"
__vendor() {
URL="$1"
OUT="$(basename "$URL")"
HASH="$2"
if [ -e "$OUT" ]; then
echo -n "$HASH $OUT" | sha256sum --strict --check -
return 0
fi
curl -qs $URL -o $OUT.tmp
echo -n "$HASH $OUT.tmp" | sha256sum --strict --check -
mv $OUT.tmp $OUT
}
_vendor() {
command -v curl >/dev/null || fatal "curl not found"
command -v sha256sum >/dev/null || fatal "sha256sum not found"
__vendor "$D3JS_URL" "$D3JS_HASH"
}
_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,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["*.html"],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0;url=/app/">
</head>
<body>
<p>If you are not redirected, <a href="/app/">click here</a>.</p>
</body>
</html>