Tons of fixes to prepare for staged releases.

This commit is contained in:
Max committed 2026-10-28 10:11:40 -07:00
1 parent 9f37d3faf1
commit 5bebe072b2
24 files changed
+1110 -474

No files matched your search

+2
View File
@@ -11,12 +11,14 @@
"dependencies": {
"@tailwindcss/vite": "^4.1.13",
"axios": "^1.1.3",
"chart.js": "^4.6.1",
"diff-match-patch": "^1.0.5",
"fomantic-ui-css": "^2.9.0",
"mitt": "^3.0.1",
"socket.io-client": "^2.5.0",
"tailwindcss": "^4.1.13",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.4",
"vue-router": "^4.0.0",
"vuedraggable": "^4.1.0",
"vuex": "^4.0.0"
+36 -4
View File
@@ -13,7 +13,10 @@ importers:
version: 4.1.18([email protected]([email protected])([email protected]))
axios:
specifier: ^1.1.3
version: 1.18.1
version: 1.18.1([email protected])
chart.js:
specifier: ^4.6.1
version: 4.6.1
diff-match-patch:
specifier: ^1.0.5
version: 1.0.5
@@ -32,6 +35,9 @@ importers:
vue:
specifier: ^3.5.18
version: 3.5.26
vue-chartjs:
specifier: ^5.3.4
version: 5.3.4([email protected])([email protected])
vue-router:
specifier: ^4.0.0
version: 4.6.4([email protected])
@@ -240,6 +246,9 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/[email protected]':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@rolldown/[email protected]':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@@ -530,6 +539,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==}
engines: {pnpm: '>=8'}
[email protected]:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -895,6 +908,12 @@ packages:
yaml:
optional: true
[email protected]:
resolution: {integrity: sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==}
peerDependencies:
chart.js: ^4.1.1
vue: ^3.0.0-0 || ^2.7.0
[email protected]:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
@@ -1049,6 +1068,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/[email protected]': {}
'@rolldown/[email protected]': {}
'@rollup/[email protected]':
@@ -1261,9 +1282,9 @@ snapshots:
[email protected]: {}
[email protected]:
[email protected]([email protected]):
dependencies:
follow-redirects: 1.16.0
follow-redirects: 1.16.0([email protected])
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
@@ -1282,6 +1303,10 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
[email protected]:
dependencies:
'@kurkle/color': 0.3.4
[email protected]:
dependencies:
delayed-stream: 1.0.0
@@ -1397,7 +1422,9 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
debug: 4.4.3
[email protected]:
dependencies:
@@ -1636,6 +1663,11 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.30.2
[email protected]([email protected])([email protected]):
dependencies:
chart.js: 4.6.1
vue: 3.5.26
[email protected]([email protected]):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -1001,3 +1001,8 @@ padding-right: 10px;
background-color: rgba(0,0,0,0.7);
z-index: 1000;
}
i.outline {
outline-style: none;
}
@@ -0,0 +1,212 @@
<template>
<div class="line-graph-holder">
<Line v-if="hasData" :data="chartData" :options="chartOptions" />
<div v-else class="no-data">No data for the selected range.</div>
</div>
</template>
<script>
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
// Categorical slots, validated for both of this app's surfaces (#fff / #000)
// with scripts/validate_palette.js. Assigned by a field's stable position,
// never by its position in a particular graph, so toggling fields on and off
// never repaints the ones that stay.
const SERIES = {
light: ['#2a78d6','#eb6834','#1baf7a','#eda100','#e87ba4','#008300','#4a3aa7','#e34948'],
dark: ['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'],
}
// A field can carry an explicit Fomantic colour name chosen by the user; that
// is their intent and it matches the field's icon elsewhere on the page, so it
// wins over the generated slot.
const FOMANTIC = {
red:'#DB2828', orange:'#F2711C', yellow:'#FBBD08', olive:'#B5CC18',
green:'#21BA45', teal:'#00B5AD', blue:'#2185D0', violet:'#6435C9',
purple:'#A333C8', pink:'#E03997', brown:'#A5673F', grey:'#767676',
black:'#1B1C1D',
}
export default {
name: 'MetricLineGraph',
inject: ['getFieldColor'],
components: { Line },
props: {
graph: { type: Object, required: true }, // { fieldIds: [...] }
userFields: { type: Object, default: () => ({}) },
cycleData: { type: Object, default: () => ({}) },
tempChartDays: { type: [Number, String], default: 30 },
},
computed: {
isDark(){
return Number(this.$store.getters.getIsNightMode) === 1
},
surface(){
return this.isDark ? '#000000' : '#ffffff'
},
textColor(){
// Text wears text tokens, never the series colour.
return this.isDark ? '#d9d9d9' : '#3d3d3d'
},
gridColor(){
// One step off the surface, hairline and recessive.
return this.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'
},
fieldIds(){
return this.graph?.fieldIds || []
},
// Every date present in the data, oldest first, trimmed to the
// requested window. Sorting by real date rather than object key
// order means out-of-order writes cannot scramble the line.
dateCodes(){
const codes = Object.keys(this.cycleData)
.map(code => ({ code, time: this.codeToDate(code) }))
.filter(entry => !isNaN(entry.time))
.sort((a, b) => a.time - b.time)
.map(entry => entry.code)
const span = parseInt(this.tempChartDays)
if(!span || span < 1 || span >= codes.length){ return codes }
return codes.slice(-span)
},
hasData(){
return this.fieldIds.length > 0 && this.dateCodes.length > 0
},
chartData(){
return {
labels: this.dateCodes.map(code => this.formatLabel(code)),
datasets: this.fieldIds.map(fieldId => {
const colour = this.colourFor(fieldId)
return {
label: this.userFields[fieldId]?.label || fieldId,
data: this.dateCodes.map(code => this.valueAt(code, fieldId)),
borderColor: colour,
backgroundColor: colour,
borderWidth: 2,
tension: 0.25,
// A marker that is drawn is >= 8px with a 2px ring in
// the surface colour; dense series only show them on
// hover so the line stays readable.
pointRadius: this.denseData ? 0 : 4,
pointHoverRadius: 5,
pointBorderColor: this.surface,
pointBorderWidth: 2,
pointHoverBorderColor: this.surface,
pointHoverBorderWidth: 2,
pointHitRadius: 12,
spanGaps: false, // a missing day is a gap, not a zero
}
}),
}
},
denseData(){
return this.dateCodes.length > 40
},
chartOptions(){
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 200 },
interaction: { mode: 'index', intersect: false },
scales: {
x: {
grid: { display: false },
ticks: { color: this.textColor, maxRotation: 0, autoSkipPadding: 16 },
border: { color: this.gridColor },
},
y: {
grid: { color: this.gridColor, lineWidth: 1, drawTicks: false },
ticks: { color: this.textColor, padding: 8 },
border: { display: false },
},
},
plugins: {
// Always present: identity is never carried by colour alone,
// and it is the relief for the low-contrast light slots.
legend: {
display: true,
position: 'bottom',
labels: {
color: this.textColor,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 8,
boxHeight: 8,
padding: 14,
},
},
tooltip: {
backgroundColor: this.isDark ? '#1b1b1b' : '#ffffff',
titleColor: this.textColor,
bodyColor: this.textColor,
borderColor: this.gridColor,
borderWidth: 1,
padding: 10,
usePointStyle: true,
callbacks: {
label: ctx => {
const v = ctx.parsed.y
return `${ctx.dataset.label}: ${v === null ? '—' : v}`
},
},
},
},
}
},
},
methods: {
// Date codes are stored as D.M.YYYY
codeToDate(code){
const [d, m, y] = String(code).split('.').map(Number)
return new Date(y, (m || 1) - 1, d || 1).getTime()
},
formatLabel(code){
const date = new Date(this.codeToDate(code))
if(isNaN(date.getTime())){ return code }
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
},
valueAt(code, fieldId){
const raw = this.cycleData[code]?.[fieldId]
if(raw === undefined || raw === null || raw === ''){ return null }
const num = Number(raw)
// Non-numeric field types cannot be plotted on a value axis; a gap
// is honest, where the old code substituted -1 and bent the scale.
return isNaN(num) ? null : num
},
colourFor(fieldId){
const named = this.getFieldColor ? this.getFieldColor(fieldId) : null
if(named && FOMANTIC[named]){ return FOMANTIC[named] }
// Stable slot: keyed off the field's position in userFields, so the
// colour follows the field rather than its rank in this graph.
const all = Object.keys(this.userFields)
const index = all.indexOf(fieldId)
const slots = this.isDark ? SERIES.dark : SERIES.light
return slots[(index < 0 ? 0 : index) % slots.length]
},
},
}
</script>
<style scoped>
.line-graph-holder {
width: 100%;
height: 320px;
}
.no-data {
padding: 2em 0;
text-align: center;
color: var(--menu-text, #767676);
}
</style>
@@ -43,12 +43,12 @@
</div>
</div>
<div class="sixteen wide column">
<dix class="ui basic segment" v-if="!editGraphs">
<div class="ui basic segment" v-if="!editGraphs">
<div class="ui button" v-on:click="toggleEditGraphs">
<i class="edit icon"></i>
<span>Add/Edit Graphs</span>
</div>
</dix>
</div>
<div v-if="editGraphs">
@@ -64,7 +64,12 @@
</div>
</div>
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment animate-height`">
<div class="ui basic center aligned segment" v-if="!graphs || graphs.length == 0">
<h4 class="ui header">No graphs yet</h4>
<p>Choose <b>Add/Edit Graphs</b>, then <b>New Graph</b>, and pick which metrics to plot.</p>
</div>
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment`">
<!-- Edit options -->
<div class="ui small header" v-if="editGraphs">
@@ -126,15 +131,20 @@
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
Select Graph type and Metrics to display
</div>
<div v-if="graph?.type == undefined && graph.fieldIds && graph.fieldIds.length > 0">
<div :id="`graphdiv${index}`" style="width: 100%; min-height: 320px;"></div>
<div v-if="isLineGraph(graph) && graph.fieldIds && graph.fieldIds.length > 0">
<LineGraph
:graph="graph"
:userFields="userFields"
:cycleData="cycleData"
:tempChartDays="tempChartDays"
/>
</div>
<div class="ui segment" v-if="editGraphs">
<!-- change graph type -->
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':(String(graphId) == String(graph?.type))}">
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':isGraphTypeActive(graphId, graph)}">
{{ graphType }}
</div>
</div>
@@ -160,12 +170,12 @@
</div>
</div>
<div class="sixteen wide column">
<dix class="ui basic segment" v-if="!editGraphs">
<div class="ui basic segment" v-if="!editGraphs">
<div class="ui button" v-on:click="toggleEditGraphs">
<i class="edit icon"></i>
<span>Add/Edit Graphs</span>
</div>
</dix>
</div>
<div v-if="editGraphs">
@@ -208,6 +218,7 @@ import { defineAsyncComponent } from 'vue'
],
components: {
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')),
},
data: function(){
return {
@@ -223,33 +234,9 @@ import { defineAsyncComponent } from 'vue'
// Constants
this.PILL_CALENDAR = PILL_CALENDAR
this.LAST_DONE = LAST_DONE
// Include JS libraries
let graphsScript = document.createElement('script')
graphsScript.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.js')
document.head.appendChild(graphsScript)
},
mounted(){
this.localGraphData = this.graphs
this.graphCurrentData()
},
updated(){
// update graphs here? Or watch graphs prop
},
watch: {
// whenever question changes, this function will run
userFields(newFields, oldFields) {
// console.log([newFields, oldFields])
if( JSON.stringify(oldFields) == "{}" ){
this.graphCurrentData()
}
},
tempChartDays(newDays, oldDays){
if( newDays != oldDays ){
this.graphCurrentData()
}
},
},
methods: {
saveGraphs(){
@@ -269,10 +256,35 @@ import { defineAsyncComponent } from 'vue'
this.$emit('toggleEditGraphs')
},
changeGraphType(index, newType){
console.log(index + ' change to ' + newType)
this.localGraphData[index]['type'] = newType
// graphTypesDef is an object, so its keys are strings and the
// default line graph arrives here as the STRING 'undefined'.
// Saved graphs represent the line graph as an absent type, so
// remove the key rather than storing a string that no render
// test can match.
if(this.isLineGraphType(newType)){
delete this.localGraphData[index].type
}
else {
this.localGraphData[index].type = newType
}
this.saveGraphs()
},
// A line graph is the default: an absent type. Accept the string
// form too so graphs saved by the older code still render.
isLineGraphType(type){
return type === undefined || type === null || type === '' || type === 'undefined'
},
isLineGraph(graph){
return this.isLineGraphType(graph?.type)
},
isGraphTypeActive(graphId, graph){
if(this.isLineGraphType(graphId)){
return this.isLineGraph(graph)
}
return String(graphId) === String(graph?.type)
},
addGraph(){
this.localGraphData.push({})
this.saveGraphs()
@@ -331,103 +343,6 @@ import { defineAsyncComponent } from 'vue'
return title
},
graphCurrentData(){
// try again if dygraphs isn't loaded
if( typeof(window.Dygraph) != 'function' ){
setTimeout(() => {
this.graphCurrentData()
}, 100)
return
}
const graphOptions = {
interactionModel: {},
// pointClickCallback: function(e, pt){
// console.log(e)
// console.log(pt)
// console.log(this.getValue(pt.idx, 0))
// }
}
// Excel date format YYYYMMDD
const convertToExcelDate = (dateCode) => {
return dateCode
.split('.')
.reverse()
.map(item => String(item).padStart(2,0))
.join('')
}
// Generate set of keys for graph length
let dataKeys = Object.keys(this.cycleData)
dataKeys = dataKeys.splice(0, this.tempChartDays)
console.log(dataKeys)
// build CSV data for each graph
this.graphs.forEach((graph,index) => {
// only chart line graphs with dygraphs
if( graph.type != undefined ){
return
}
if( !graph.fieldIds ){
return
}
// CSV or path to a CSV file.
let dataString = ""
// Lookup graph field titles
let graphLabels = ['Date']
graph.fieldIds.forEach(fieldId => {
const graphLabel = this.userFields[fieldId]?.label
const escapedLabel = graphLabel.replaceAll(',','')
graphLabels.push(escapedLabel)
})
dataString += graphLabels.join(',') + '\n'
// build each row, for each day
for (var i = 0; i < dataKeys.length; i++) {
let nextFragment = []
// push date code to first column
nextFragment.push(convertToExcelDate(dataKeys[i]))
graph.fieldIds.forEach(fieldId => {
const currentEntry = this.cycleData[dataKeys[i]]
let currentValue = currentEntry[fieldId]
// setup correct float graphing
if(fieldId == 'BT'){
// parse temp to fixed length float 00.00
currentValue = parseFloat(currentValue).toFixed(2)
}
if( currentValue == undefined ){
currentValue = -1
}
nextFragment.push(currentValue)
})
dataString += nextFragment.join(',') + "\n"
}
let graphDiv = document.getElementById("graphdiv"+index)
const g = new Dygraph(graphDiv, dataString ,graphOptions)
})
return
},
}
}
</script>
@@ -81,6 +81,13 @@
}
.days-since {
padding: 0.75em 0.5em 0.25em 0.5em;
font-size: 0.95em;
}
.days-since-row {
padding: 0.15em 0;
}
.pill-container {
width: 100%;
}
@@ -262,6 +269,14 @@
</div>
</div>
<!-- time since each metric was last recorded -->
<div class="days-since" v-if="daysSince.length > 0">
<div class="days-since-row" v-for="entry in daysSince" :key="entry.fieldId">
<i :class="`${entry.color} ${entry.icon} icon`"></i>
<span v-if="entry.days === null">Days Since {{ entry.label }} - no entries yet</span>
<span v-else>Days Since {{ entry.label }} - {{ entry.days }} {{ entry.days === 1 ? 'day' : 'days' }}.</span>
</div>
</div>
</div>
</template>
@@ -272,7 +287,7 @@
// let chartData = {}
export default {
inject: ['getFieldColor', 'getFieldIcon'],
inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'],
props: [
'graph', // options associated with this graph
'userFields', // all field attributes
@@ -313,6 +328,53 @@
this.setupCalendar(new Date())
},
computed: {
// "Days Since <metric> - <n> days" for every field on this graph.
// Counts a day as recorded only if it passes the same
// showZeroValuesCheck the pills use, so the "Show/Hide Lowest Value"
// toggle moves these numbers too.
daysSince(){
// showZeroValuesCheck reads lowestGraphValue, which getChartData
// assigns as a side effect -- touch it so the threshold is current
// and so this recomputes whenever the chart data does.
void this.getChartData
const dayMs = 86400000
const midnight = (time) => {
const date = new Date(time)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
const today = midnight(Date.now())
return (this.graph?.fieldIds || []).map(fieldId => {
let latest = null
Object.keys(this.cycleData || {}).forEach(dateCode => {
const value = this.cycleData[dateCode]?.[fieldId]
if(value === undefined || value === null || value === ''){ return }
// honour the Lowest Value toggle
if(!this.showZeroValuesCheck(value, fieldId)){ return }
const time = this.dateCodeToTime(dateCode)
// ignore unparseable codes and anything dated ahead of today
if(time === null || time > today){ return }
if(latest === null || time > latest){ latest = time }
})
return {
fieldId,
label: this.getFieldLabel(fieldId),
icon: this.getFieldIcon(fieldId),
color: this.getFieldColor(fieldId),
days: latest === null ? null : Math.round((today - latest) / dayMs),
}
})
},
getChartData(){
let chartData = {}
@@ -393,11 +455,26 @@
},
},
methods: {
// Date codes are stored unpadded as D.M.YYYY
dateCodeToTime(dateCode){
const parts = String(dateCode).split('.').map(Number)
if(parts.length !== 3 || parts.some(isNaN)){ return null }
const [day, month, year] = parts
const date = new Date(year, month - 1, day)
date.setHours(0, 0, 0, 0)
return isNaN(date.getTime()) ? null : date.getTime()
},
showZeroValuesCheck(dayValue, fieldId){
// Optional chaining: a graph can reference a default field (BT,
// CM, ...) that was never customised, so it has no userFields
// entry and the bare lookup threw.
// if graph type is boolean or there are two options
let isBooleanField = this.userFields[fieldId].type == 'boolean'
if(this.userFields[fieldId].customOptions){
let isBooleanField = this.userFields[fieldId]?.type == 'boolean'
if(this.userFields[fieldId]?.customOptions){
let options = this.userFields[fieldId].customOptions
isBooleanField = options.split(',').length == 2
@@ -415,6 +492,13 @@
}
// lowestGraphValue comes from Math.min of the charted values, so
// it is Infinity when the window holds no data -- without the
// finite guard nothing would ever clear the threshold.
if(!isFinite(this.lowestGraphValue)){
return true
}
return this.showZeroValues || dayValue > this.lowestGraphValue
},
getDayValue(fieldId, value){
@@ -62,7 +62,7 @@
<div class="menu-bottom-half" :class="{ 'hide-text':(openNotes > 1) }">
<!--
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/table`)" data-tooltip="Insert Table" data-position="bottom center">
<div class="edit-button" v-on:click="openMenuRoute('table')" data-tooltip="Insert Table" data-position="bottom center">
<i class="border all icon"></i>
</div> -->
@@ -87,13 +87,13 @@
<div class="edit-divide"></div>
<!--
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/colors`)" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
<div class="edit-button" v-on:click="openMenuRoute('colors')" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
<i class="paint brush icon"></i>
</div> -->
<!-- <div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)" data-tooltip="Tags" data-position="top center">
<!-- <div class="edit-button" v-on:click="openMenuRoute('tags')" data-tooltip="Tags" data-position="top center">
<i class="tags icon"></i>
</div> -->
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/images`)" data-tooltip="Images" data-position="top center">
<div class="edit-button" v-on:click="openMenuRoute('images')" data-tooltip="Images" data-position="top center">
<i class="image icon"></i>
<span>Images</span>
</div>
@@ -105,7 +105,7 @@
<div class="edit-divide"></div>
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/options`)" data-tooltip="More Options" data-position="top center">
<div class="edit-button" v-on:click="openMenuRoute('options')" data-tooltip="More Options" data-position="top center">
&nbsp;&nbsp;
<i class="ellipsis horizontal icon"></i>
&nbsp;&nbsp;
@@ -157,7 +157,7 @@
<!-- tags on the side, only show on desktop -->
<div class="note-mini-tag-area"
v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)"
v-on:click="openMenuRoute('tags')"
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }">
<span class="add-mini-tag" v-if="allTags.length == 0">
@@ -201,7 +201,7 @@
<!-- Squire Box -->
<div
id="squire-id"
:id="`squire-id-${noteid}`"
class="squire-box"
ref="squirebox"
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }"
@@ -369,7 +369,6 @@ import { defineAsyncComponent } from 'vue'
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
let rawNoteText = '' // Used for comparing and generating diffs
export default {
name: 'NoteInputPanel',
@@ -419,6 +418,9 @@ import { defineAsyncComponent } from 'vue'
//All the squire settings
editor: null,
// Baseline for diff generation. Per-instance: as a module-scoped
// variable this was shared by every open note's panel.
rawNoteText: '',
usersOnNote: 0,
sideMenuOpen: false,
@@ -475,25 +477,17 @@ import { defineAsyncComponent } from 'vue'
},
beforeMount(){
this.$bus.$on('new_file_upload', ({noteId, imageCode}) => {
if(this.noteid == noteId && this.editor){
this.editor.moveCursorToEnd()
this.editor.insertHTML(imageCode)
this.save()
}
})
this.$bus.$on('close_note_by_id', (noteId) => {
if(noteId == this.noteid){
this.closeButtonAction()
}
})
// Named handlers so beforeUnmount can remove only THIS panel's.
// mitt's off(type) with no handler removes every panel's.
this.$bus.$on('new_file_upload', this.onFileUpload)
this.$bus.$on('close_note_by_id', this.onCloseNoteById)
},
beforeUnmount(){
this.$io.emit('leave_room', this.rawTextId)
this.$io.emit('leave_room', { noteId: this.noteid })
this.$bus.$off('new_file_upload')
this.$bus.$off('new_file_upload', this.onFileUpload)
this.$bus.$off('close_note_by_id', this.onCloseNoteById)
this.destroyAttachmentStyles()
@@ -533,13 +527,13 @@ import { defineAsyncComponent } from 'vue'
},
methods: {
testModify(){
const text = document.getElementById('squire-id').children[0].innerHTML
const text = this.$refs.squirebox.children[0].innerHTML
const prepended = 'Hello-> ' + text
document.getElementById('squire-id').children[0].innerHTML = prepended
this.$refs.squirebox.children[0].innerHTML = prepended
const text1 = document.getElementById('squire-id').children[2].innerHTML
const text1 = this.$refs.squirebox.children[2].innerHTML
const prepended1 = 'Hey-> ' + text1
document.getElementById('squire-id').children[2].innerHTML = prepended1
this.$refs.squirebox.children[2].innerHTML = prepended1
},
simulateTyping(index = 0){
@@ -593,7 +587,7 @@ import { defineAsyncComponent } from 'vue'
this.setText(this.noteText)
// Use squire box HTML for diff/patch changes
rawNoteText = document.getElementById('squire-id').innerHTML
this.rawNoteText = this.$refs.squirebox.innerHTML
//focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
if(!this.$store.getters.getIsUserOnMobile){
@@ -867,6 +861,8 @@ import { defineAsyncComponent } from 'vue'
},
//Called on squire event for keyup
diffText(event){
console.log('Skipping diff text function')
return
// console.log(event.type)
const diffEvents = ['keyup','pathChange', 'click']
@@ -880,17 +876,17 @@ import { defineAsyncComponent } from 'vue'
this.diffTextTimeout = setTimeout(() => {
// Current Editor Text
const liveEditorElm = document.getElementById('squire-id')
const liveEditorElm = this.$refs.squirebox
// virtual element for selecting div
let virtualEditorElm = document.createElement('div')
virtualEditorElm.innerHTML = rawNoteText
virtualEditorElm.innerHTML = this.rawNoteText
// element at cursor
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
// Remove beginngin selector from path, make it more generic
const path = this.generateSelector(elmAtCaret).replace('div#squire-id > ','')
const path = this.generateSelector(elmAtCaret).replace(`div#squire-id-${this.noteid} > `,'')
let workingPath = ''
// default to entire note text, select down if path
@@ -938,10 +934,10 @@ import { defineAsyncComponent } from 'vue'
const patch_text = dmp.patch_toText(patch_list)
// save raw text for future diffs
rawNoteText = liveEditorElm.innerHTML
this.rawNoteText = liveEditorElm.innerHTML
let newPatch = {
id: this.rawTextId,
noteId: this.noteid,
diff: patch_text,
path: path,
// testing metrics
@@ -961,7 +957,10 @@ import { defineAsyncComponent } from 'vue'
// console.log('incoming patches ', incomingPatchs)
return new Promise((resolve, reject) => {
const editorElement = document.getElementById('squire-id')
const editorElement = this.$refs.squirebox
// Socket-driven: this panel may not be rendered yet, or may be gone
if(!editorElement){ return resolve(false) }
// iterate over incoming patches because they apply to specific divs
incomingPatchs.forEach(patch => {
@@ -988,10 +987,10 @@ import { defineAsyncComponent } from 'vue'
})
// save editor HTML after change for future comparisons
rawNoteText = editorElement.innerHTML
this.rawNoteText = editorElement.innerHTML
// update hash on patch
this.lastNoteHash = this.hashString( rawNoteText )
this.lastNoteHash = this.hashString( this.rawNoteText )
this.$nextTick(() => {
return resolve(true)
@@ -1041,7 +1040,7 @@ import { defineAsyncComponent } from 'vue'
//tell websockets to truncate history at this save
this.lastNoteHash = currentHash //Update last saved note hash
this.$io.emit('truncate_diffs_at_save', {'rawTextId':this.rawTextId, 'hash':currentHash })
this.$io.emit('truncate_diffs_at_save', {'noteId':this.noteid, 'hash':currentHash })
const postData = {
'noteId': this.currentNoteId,
@@ -1093,7 +1092,8 @@ import { defineAsyncComponent } from 'vue'
this.setupLoadedNoteData(response)
//Manually set squire text
const editorElement = document.getElementById('squire-id')
const editorElement = this.$refs.squirebox
if(!editorElement){ return }
editorElement.innerHTML = response.data.text
this.$nextTick(() => {
@@ -1146,6 +1146,15 @@ import { defineAsyncComponent } from 'vue'
return hash;
},
// Menu routes are pushed (not replaced) because the history entry is
// load-bearing: every menu closes via $router.go(-1). Forwarding the
// current query keeps ?open= alive across the menu and the pop back.
openMenuRoute(menu){
this.$router.push({
path: `/notes/open/${this.noteid}/menu/${menu}`,
query: this.$route.query,
})
},
closeButtonAction(playAnimation = false){
this.sizeDown = playAnimation
const animationTimeout = (playAnimation ? 300 : 0)
@@ -1181,66 +1190,106 @@ import { defineAsyncComponent } from 'vue'
})
},
destroyWebSockets(){
this.$io.removeListener('past_diffs')
this.$io.removeListener('update_user_count')
this.$io.removeListener('incoming_diff')
this.$io.removeListener('update_note_attachments')
// By reference: removeListener(event) alone would remove the other
// open panels' handlers too. new_note_text_saved was never removed
// at all and leaked a handler on every open/close.
this.$io.removeListener('update_user_count', this.onUpdateUserCount)
this.$io.removeListener('past_diffs', this.onPastDiffs)
this.$io.removeListener('incoming_diff', this.onIncomingDiff)
this.$io.removeListener('new_note_text_saved', this.onNewNoteTextSaved)
this.$io.removeListener('update_note_attachments', this.onUpdateNoteAttachments)
clearTimeout(this.idleNoteTimeout)
},
// Socket handlers are named methods rather than inline closures so that
// destroyWebSockets() can remove THIS panel's handler by reference.
// removeListener(event) with no handler drops every open panel's.
onFileUpload({noteId, imageCode}){
if(this.noteid == noteId && this.editor){
this.editor.moveCursorToEnd()
this.editor.insertHTML(imageCode)
this.save()
}
},
onCloseNoteById(noteId){
if(noteId == this.noteid){
this.closeButtonAction()
}
},
onUpdateUserCount(userCount){
this.usersOnNote = userCount
},
//Apply all diffs since last save
onPastDiffs(diffSinceLastUpdate){
// Every open panel shares one socket, so filter to this note's room
if(!this.isDiffForThisNote(diffSinceLastUpdate)){ return }
if(diffSinceLastUpdate != null){
this.diffsApplied = diffSinceLastUpdate.length
}
this.patchText(diffSinceLastUpdate)
.then(() => {
this.restoreCaretPosition()
.catch(e => console.log(e) )
})
},
onIncomingDiff(incomingDiff){
if(!this.isDiffForThisNote([incomingDiff])){ return }
this.patchText([incomingDiff])
.then(() => {
this.restoreCaretPosition()
.catch(e => console.log(e) )
})
},
// A diff payload carries the raw-text id it belongs to. Anything without
// one is dropped rather than guessed at -- applying a patch to the wrong
// note is how note bodies got clobbered.
isDiffForThisNote(diffs){
if(diffs == null){ return false }
const list = [].concat(diffs).filter(diff => diff != null)
if(list.length == 0){ return false }
return list.every(diff => String(diff.rawTextId) === String(this.rawTextId))
},
onNewNoteTextSaved({noteId, hash}){
if(this.statusText != 'saved'){
return
}
const sameIdCheck = (this.idleNote && this.noteid == noteId)
const differentHashCheck = (hash != this.lastNoteHash)
// if hashes do not match, reload text from server
if(sameIdCheck && differentHashCheck){
this.loadNoteNextFromServer()
}
},
onUpdateNoteAttachments(){
this.getAttachments()
},
initWebsocketEvents(){
//Tell server to push this note into a room
this.$io.emit('join_room', this.rawTextId )
this.$io.on('update_user_count', userCount => {
this.usersOnNote = userCount
})
//Apply all diffs since last save
this.$io.on('past_diffs', diffSinceLastUpdate => {
if(diffSinceLastUpdate != null){
this.diffsApplied = diffSinceLastUpdate.length
// console.log('Got Diffs Total -> ', diffSinceLastUpdate)
}
console.log(diffSinceLastUpdate)
this.patchText(diffSinceLastUpdate)
.then(() => {
this.restoreCaretPosition()
.catch(e => console.log(e) )
})
})
this.$io.on('incoming_diff', incomingDiff => {
this.patchText([incomingDiff])
.then(() => {
this.restoreCaretPosition()
.catch(e => console.log(e) )
})
})
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
if(this.statusText != 'saved'){
return
}
const sameIdCheck = (this.idleNote && this.noteid == noteId)
const differentHashCheck = (hash != this.lastNoteHash)
// if hashes do not match, reload text from server
if(sameIdCheck && differentHashCheck){
this.loadNoteNextFromServer()
}
})
this.$io.on('update_note_attachments', () => {
this.getAttachments()
// Ask for a note, not a room. The server authorises against the
// token and derives the room itself.
this.$io.emit('join_room', {
noteId: this.noteid,
token: localStorage.getItem('loginToken'),
})
this.$io.on('update_user_count', this.onUpdateUserCount)
this.$io.on('past_diffs', this.onPastDiffs)
this.$io.on('incoming_diff', this.onIncomingDiff)
this.$io.on('new_note_text_saved', this.onNewNoteTextSaved)
this.$io.on('update_note_attachments', this.onUpdateNoteAttachments)
},
logNoteInteraction(){
@@ -1253,6 +1302,8 @@ import { defineAsyncComponent } from 'vue'
},
saveCaretPosition(event){
return
//Find index of child element based on past range
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
@@ -1271,7 +1322,9 @@ import { defineAsyncComponent } from 'vue'
if(!this.lastRange){ return resolve(true) }
const editorElement = document.getElementById('squire-id')
const editorElement = this.$refs.squirebox
if(!editorElement){ return resolve(true) }
const lastElement = editorElement.querySelector(this.lastRange)
if( !lastElement ){ return resolve(true) }
+3
View File
@@ -17,6 +17,9 @@ import 'fomantic-ui-css/components/label.min.css'
import './assets/semantic-helper.css'
// Last, so Tailwind utilities win specificity ties against Fomantic
import './tailwind.css'
import io from 'socket.io-client'
import EventBus from './EventBus'
import Helpers from './Helpers'
@@ -171,7 +171,7 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
let container = this.$refs.squirebox
this.$router.go(-1)
setTimeout(()=>{
@@ -189,7 +189,7 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
let container = this.$refs.squirebox
//Close menu if user is on mobile, then sort list
this.$router.go(-1)
@@ -249,7 +249,7 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
let container = this.$refs.squirebox
//Close menu if user is on mobile
this.$router.go(-1)
@@ -317,7 +317,7 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
let container = this.$refs.squirebox
//Close menu if user is on mobile, then sort list
this.$router.go(-1)
@@ -377,7 +377,7 @@ const SquireButtonFunctions = {
// this.diffNoteText = this.editor._getHTML()
//Make sure all list items have draggable property
// let container = document.getElementById('squire-id')
// let container = this.$refs.squirebox
// let listItems = container.getElementsByTagName('li')
// for(let itemIndex in listItems){
// // console.log(listItems[itemIndex])
@@ -355,9 +355,9 @@
</div>
<div class="ui form" :class="{'loading-day':loadingDay}">
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
<div v-for="field in fields" :key="field"
:class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="field => field">
<template #item="{ element: field }">
<div :class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
<div class="ui very compact grid input-grid">
<!-- field label display -->
@@ -443,6 +443,7 @@
</div>
</div>
</template>
</draggable>
</div>
</div>
@@ -599,7 +600,6 @@
<div :class="{'green':(tempChartDays == 7)}" v-on:click="tempChartDays = 7; " class="ui button">7</div>
</div>
<!-- <div id="graphdiv" style="width: 100%; min-height: 320px;"></div> -->
<div class="ui divider"></div>
<MetricGraphs
@@ -686,8 +686,9 @@
<div class="ui small dividing header">
Active Fields
</div>
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
<div v-for="fieldId in fields" :key="fieldId" class="row">
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="fieldId => fieldId">
<template #item="{ element: fieldId }">
<div class="row">
<div class="eight wide column">
<i class="draggable grip lines icon"></i>
{{ userFields[fieldId]?.label }}
@@ -708,14 +709,14 @@
<!-- {{ openDay[fieldId] }} -->
</div>
</div>
</template>
</draggable>
<div class="ui small dividing header">
Other Fields
</div>
<draggable class="ui very compact internally celled grid">
<template v-for="(field,fieldId) in userFields" :key="fieldId">
<div class="ui row" v-if="!fields.includes(fieldId)">
<div class="ui very compact internally celled grid">
<div class="ui row" v-for="(field,fieldId) in unusedUserFields" :key="fieldId">
<div class="eight wide column">
<i class="draggable grip lines icon"></i>
{{ userFields[fieldId]?.label }}
@@ -727,10 +728,7 @@
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
</div>
</div>
</template>
<template v-for="(field,fieldId) in defaultFields" :key="fieldId">
<div class="ui row"
v-if="!fields.includes(fieldId) && !Object.keys(userFields).includes(fieldId)">
<div class="ui row" v-for="(field,fieldId) in unusedDefaultFields" :key="'default-'+fieldId">
<div class="eight wide column">
<i class="draggable grip lines icon"></i>
@@ -743,8 +741,7 @@
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
</div>
</div>
</template>
</draggable>
</div>
</div>
@@ -886,8 +883,6 @@
</div>
</modal>
<!-- may or may not need digraph CSS, maybe disect -->
<!-- <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.css" /> -->
<div class="ui basic segment"></div>
@@ -920,6 +915,7 @@ import { defineAsyncComponent } from 'vue'
return {
getFieldColor: field => this.getFieldColor(field),
getFieldIcon: field => this.getFieldIcon(field),
getFieldLabel: field => this.getFieldLabel(field),
}
},
components: {
@@ -1036,6 +1032,21 @@ import { defineAsyncComponent } from 'vue'
});
},
computed: {
// vuedraggable's computeNodes walks the default slot's vnodes directly
// and cannot handle the fragments that <template v-for> produces, so
// the filtering happens here instead of with a v-if in the slot.
unusedUserFields(){
return Object.fromEntries(
Object.entries(this.userFields).filter(([fieldId]) => !this.fields.includes(fieldId))
)
},
unusedDefaultFields(){
const userFieldIds = Object.keys(this.userFields)
return Object.fromEntries(
Object.entries(this.defaultFields).filter(([fieldId]) =>
!this.fields.includes(fieldId) && !userFieldIds.includes(fieldId))
)
},
getToday(){
return this.generateDateCode(new Date())
},
@@ -1115,6 +1126,27 @@ import { defineAsyncComponent } from 'vue'
return color
// userFields[field]?.icon
},
getFieldLabel(field){
let label = null
// user defined
if(this.userFields[field]?.label){
label = this.userFields[field].label
}
// default field value
if(!label && this.defaultFields[field]?.label){
label = this.defaultFields[field]?.label
}
// fall back to the raw id
if(!label){
label = field
}
return label
},
getFieldIcon(field){
let icon = null
@@ -1389,6 +1421,10 @@ import { defineAsyncComponent } from 'vue'
// update field to be reactive
this.openDay[fieldId] = value
// Push straight into cycleData so the graphs redraw immediately;
// the server write below stays debounced.
this.applyOpenDayToCycleData()
// remove debounce and set to modified
this.$nextTick(() => {
//0 blank, 1 modified, 2 saving, 3 saved
@@ -1422,7 +1458,9 @@ import { defineAsyncComponent } from 'vue'
},
saveDayData(){
// Local-only: fold the open day's values into cycleData. Cheap and
// synchronous, so callers can run it on every keystroke.
applyOpenDayToCycleData(){
// remove empty keys
let cleanDayData = {}
@@ -1432,12 +1470,15 @@ import { defineAsyncComponent } from 'vue'
}
})
// Only save entry if there is data
// Only keep entry if there is data
delete this.cycleData[this.calendar.dateCode]
if(Object.keys(cleanDayData).length > 0){
this.cycleData[this.calendar.dateCode] = cleanDayData
}
},
saveDayData(){
this.applyOpenDayToCycleData()
this.saveCycleData()
},
+122 -50
View File
@@ -206,6 +206,9 @@ import { defineAsyncComponent } from 'vue'
searchTags: [],
notes: [],
openNotes: [],
// fullPath of our own last URL write, so the $route watcher can
// recognise it and not treat it as an inbound navigation
lastSyncedFullPath: null,
collapseFloatingList: false,
highlights: [],
searchDebounce: null,
@@ -383,11 +386,26 @@ import { defineAsyncComponent } from 'vue'
},
mounted() {
//Open note on PAGE LOAD if ID is set
if(this.$route.params.id > 1){
this.openNote(this.$route.params.id)
// Restore every note named in ?open=
const ids = this.parseOpenQuery(this.$route.query.open)
// Fold the legacy /notes/open/:id deep link in, first position
const paramId = parseInt(this.$route.params.id, 10)
if(Number.isInteger(paramId) && paramId > 1 && !ids.includes(paramId)){
ids.unshift(paramId)
}
// Push directly rather than via openNote(), which would write the URL
// once per note; one normalising write follows instead.
ids.forEach(id => {
if(!this.openNotes.includes(id)){
this.openNotes.push(id)
}
})
// Unconditional, so a junk ?open=abc gets scrubbed too
this.syncOpenNotesToUrl()
//Loads initial batch and tags
this.reset()
@@ -398,17 +416,31 @@ import { defineAsyncComponent } from 'vue'
},
'$route' (to, from) {
// Our own URL write, not an inbound navigation
if(to.fullPath === this.lastSyncedFullPath){ return }
// Reload the notes if returning to this page
if(to.fullPath == '/notes' && !from.fullPath.includes('/notes/open/')){
// The discriminator is the absence of ?open=, NOT the path. A menu's
// $router.go(-1) lands on /notes?open=5 with `from` containing
// '/notes/open/', so a path comparison here would close every note
// every time a side menu is dismissed.
const landingOnBareNotes = (to.path === '/notes' && !to.query.open)
// Reload the notes if arriving at an empty /notes from elsewhere
if(landingOnBareNotes && !from.path.startsWith('/notes/open/') && from.path !== '/notes'){
this.reset()
}
// Close all notes if returning to /notes page
if(to.fullPath == '/notes' && from.fullPath.includes('/notes/open/')){
// Explicit navigation to a note-less /notes closes everything
if(landingOnBareNotes && this.openNotes.length > 0){
this.closeAllNotes()
}
// Stale history entry (e.g. go(-1) after another note was closed):
// rewrite the URL from the array. Never read it.
if(to.path === '/notes' && to.query.open && to.query.open !== this.openNotes.join(',')){
this.syncOpenNotesToUrl()
}
//Lookup tags set in URL
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
@@ -435,81 +467,121 @@ import { defineAsyncComponent } from 'vue'
}
},
methods: {
openNote(id, event = null){
// Where the open-note URL should point. A menu or a tag search owns the
// path, so only a plain notes view gets normalised back to /notes.
openNotesTargetPath(){
//
// A side menu is open; its close button is a $router.go(-1), so the
// path has to stay put or there is nowhere sane to land.
if(this.$route.params.openMenu){ return this.$route.path }
const intId = parseInt(id)
if(this.openNotes.includes(intId)){
// Preserve a tag search
if(this.$route.params.tag){ return this.$route.path }
console.log('Open already open note?')
return '/notes'
},
// Mirror openNotes into ?open=. This is the ONLY place the open-note
// URL is written.
syncOpenNotesToUrl(){
// const openIndex = this.openNotes.indexOf(intId)
// if(openIndex != -1){
// console.log('Open note and remove it ', intId + ' on index ' + openIndex)
// this.openNotes.splice(openIndex, 1)
// }
// this.$bus.$emit('close_note_by_id', intId)
const query = Object.assign({}, this.$route.query)
if(this.openNotes.length > 0){
query.open = this.openNotes.join(',')
}
else {
delete query.open
}
const target = { path: this.openNotesTargetPath(), query }
const targetFullPath = this.$router.resolve(target).fullPath
// Already correct -- don't navigate at all
if(targetFullPath === this.$route.fullPath){
this.lastSyncedFullPath = targetFullPath
return
}
// Set before navigating so the watcher sees it
this.lastSyncedFullPath = targetFullPath
this.$router.replace(target).catch(() => {})
},
// Tolerant parse of ?open=. Handles 'abc', '1,,2,2', '-4' and the
// repeated form ?open=1&open=2 (which arrives as an array).
parseOpenQuery(raw){
if(raw === undefined || raw === null){ return [] }
// Cap: a hand-crafted URL shouldn't be able to fire 50 note fetches
const MAX_RESTORE = 8
return [].concat(raw)
.join(',')
.split(',')
.map(value => parseInt(value, 10))
.filter(value => Number.isInteger(value) && value > 0)
.filter((value, index, all) => all.indexOf(value) === index)
.slice(0, MAX_RESTORE)
},
openNote(id, event = null){
const intId = parseInt(id, 10)
// Also absorbs the undefined the '$route.params.id' watcher now gets
// when we normalise /notes/open/5 into /notes?open=5
if(!Number.isInteger(intId) || intId < 1){ return }
//Don't open note if a link is clicked in display card
if(event && event.target && event.target.nodeName){
const nodeClick = event.target.nodeName
if(nodeClick == 'A'){ return }
}
// Push note to stack if not open
if(Number.isInteger(intId) && !this.openNotes.includes(intId)){
this.openNotes.push(intId)
if(this.openNotes.includes(intId)){
// Already open, but an external push('/notes/open/5') still needs
// the URL normalised back to the full set
this.syncOpenNotesToUrl()
return
}
this.$nextTick(() => {
// change route if open ID is not the same as current ID
if(this.$route.params.id != id){
console.log('Open note, change route -> route id ' + this.$route.params.id + ' note id ->' + id + ', ' +(this.$route.params.id == id))
this.$router.push('/notes/open/'+id)
}
})
return
this.openNotes.push(intId)
this.syncOpenNotesToUrl()
},
closeNote(noteId, modified){
closeNote(noteId, modified, syncUrl = true){
console.log('close note', this.$route.fullPath)
// parseInt: indexOf is strict, and ids arriving from the URL are
// strings. Without this the splice silently misses.
const intId = parseInt(noteId, 10)
const openIndex = this.openNotes.indexOf(intId)
const didClose = (openIndex !== -1)
const openIndex = this.openNotes.indexOf(noteId)
if(openIndex != -1){
console.log('Removing note id ', noteId + ' on index ' + openIndex)
if(didClose){
this.openNotes.splice(openIndex, 1)
}
// //A note has been closed
// if(this.$route.fullPath != '/notes'){
// this.$router.push('/notes')
// }
if(this.openNotes.length == 0 && this.$route.fullPath != '/notes'){
this.$router.push('/notes')
// Only write the URL when the array actually changed. NoteInputPanel
// emits close_active_note twice per note (closeButtonAction -> close(),
// then beforeUnmount -> close()); the second arrives after the splice.
if(didClose && syncUrl){
this.syncOpenNotesToUrl()
}
if(modified){
console.log('Just closed Note -> ' + noteId + ', modified -> ', modified)
this.$store.dispatch('fetchAndUpdateUserTotals')
//Focus and animate if modified
this.updateSingleNote(noteId, modified)
}
console.log('closeNote(): Open notes length ', this.openNotes.length)
},
closeAllNotes(){
console.log('Close all notes ------------')
// Backwards, so the in-loop splice can't skip an entry. URL writes are
// deferred so this is one navigation rather than N.
for (let i = this.openNotes.length - 1; i >= 0; i--) {
console.log('Close all notes -> ' + this.openNotes[i])
this.closeNote(this.openNotes[i])
this.closeNote(this.openNotes[i], false, false)
}
console.log('----------------')
this.syncOpenNotesToUrl()
},
toggleTagFilter(tagId){
+25
View File
@@ -0,0 +1,25 @@
/*
* Tailwind 4, wired to coexist with Fomantic UI.
*
* Deliberately NOT `@import "tailwindcss"` (the whole thing). That pulls in
* Preflight as `@layer base`, which zeroes margin/padding/border on every
* element, strips heading sizes and list markers, forces img/svg to
* display:block and makes buttons and inputs transparent. Dropped on top of
* Fomantic it visibly breaks the existing UI.
*
* Utilities are imported UNLAYERED on purpose. Tailwind's own index.css wraps
* them in `@layer utilities`, and an unlayered stylesheet beats any layered
* rule regardless of specificity -- since all of Fomantic is unlayered, the
* utilities would quietly lose every conflict. Unlayered they compete on normal
* specificity instead, and win ties by source order because this file is
* imported last in main.js.
*
* Fomantic mostly uses two-class selectors (.ui.button = specificity 0,2,0), so
* it still out-specifies a single utility (0,1,0). To beat Fomantic on a
* property it already sets, use the important modifier -- `p-4!`, `bg-red-500!`.
*
* To try Preflight anyway, add: @import "tailwindcss/preflight.css" layer(base);
*/
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css";