diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.vue | 75 | ||||
| -rw-r--r-- | src/components/InfoModal.vue | 6 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 207 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 9 | ||||
| -rw-r--r-- | src/components/icon/InfoIcon.vue | 8 | ||||
| -rw-r--r-- | src/index.css | 13 | ||||
| -rw-r--r-- | src/lib.ts | 14 | ||||
| -rw-r--r-- | src/store.ts | 27 |
8 files changed, 319 insertions, 40 deletions
diff --git a/src/App.vue b/src/App.vue index 80ce803..2f5051a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,14 +5,17 @@ <h1 class="my-auto ml-2 text-4xl" :style="{color: store.color.altDark}">Histplorer</h1> <div class="mx-auto"/> <!-- Spacer --> <!-- Icons --> - <icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline"> - <plus-icon/> + <icon-button :size="45" :style="buttonStyles"> + <help-icon/> </icon-button> <icon-button :size="45" :style="buttonStyles"> <settings-icon/> </icon-button> - <icon-button :size="45" :style="buttonStyles"> - <help-icon/> + <icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline"> + <plus-icon/> + </icon-button> + <icon-button :size="45" :style="buttonStyles" @click="searchOpen = true" title="Search"> + <search-icon/> </icon-button> </div> <!-- Content area --> @@ -23,13 +26,16 @@ :eventTree="eventTree" :unitCountMaps="unitCountMaps" class="grow basis-full min-h-0 outline outline-1" @close="onTimelineClose(idx)" @state-chg="onTimelineChg($event, idx)" @event-display="onEventDisplay" - @event-click="onEventClick"/> + @info-click="onInfoClick"/> <base-line :vert="vert" :timelines="timelines" class='m-1 sm:m-2'/> </div> <!-- Modals --> <transition name="fade"> - <info-modal v-if="infoModalEvent != null && infoModalData != null" - :event="infoModalEvent" :eventInfo="infoModalData" @close="infoModalEvent = null"/> + <search-modal v-if="searchOpen" :eventTree="eventTree" :titleToEvent="titleToEvent" + @close="searchOpen = false" @search="onSearch" @info-click="onInfoClick"/> + </transition> + <transition name="fade"> + <info-modal v-if="infoModalData != null" :eventInfo="infoModalData" @close="infoModalData = null"/> </transition> </div> </template> @@ -40,11 +46,13 @@ import {ref, computed, onMounted, onUnmounted, Ref, shallowRef, ShallowRef} from import TimeLine from './components/TimeLine.vue'; import BaseLine from './components/BaseLine.vue'; import InfoModal from './components/InfoModal.vue'; +import SearchModal from './components/SearchModal.vue'; import IconButton from './components/IconButton.vue'; // Icons -import PlusIcon from './components/icon/PlusIcon.vue'; -import SettingsIcon from './components/icon/SettingsIcon.vue'; import HelpIcon from './components/icon/HelpIcon.vue'; +import SettingsIcon from './components/icon/SettingsIcon.vue'; +import PlusIcon from './components/icon/PlusIcon.vue'; +import SearchIcon from './components/icon/SearchIcon.vue'; // Other import {HistDate, HistEvent, queryServer, EventResponseJson, jsonToHistEvent, SCALES, stepDate, TimelineState, cmpHistEvent, dateToUnit, DateRangeTree, @@ -110,6 +118,7 @@ function onTimelineClose(idx: number){ // For storing and looking up events const eventTree: ShallowRef<RBTree<HistEvent>> = shallowRef(new RBTree(cmpHistEvent)); let idToEvent: Map<number, HistEvent> = new Map(); +let titleToEvent: Map<string, HistEvent> = new Map(); const unitCountMaps: Ref<Map<number, number>[]> = ref(SCALES.map(() => new Map())); // For each scale, maps units to event counts // For keeping event data under a memory limit @@ -149,6 +158,10 @@ function reduceEvents(){ eventTree.value = newTree; unitCountMaps.value = newMaps; idToEvent = eventsToKeep; + titleToEvent = new Map(); + for (let event of eventsToKeep.values()){ + titleToEvent.set(event.title, event); + } } // For getting events from server const EVENT_REQ_LIMIT = 300; @@ -214,6 +227,7 @@ async function onEventDisplay( }); let responseObj: EventResponseJson | null = await queryServer(urlParams); if (responseObj == null){ + console.log('WARNING: Server gave null response to event query'); return; } queriedRanges[scaleIdx].add([firstDate, lastDate]); @@ -225,6 +239,7 @@ async function onEventDisplay( if (success){ eventAdded = true; idToEvent.set(event.id, event); + titleToEvent.set(event.title, event); } } // Collect unit counts @@ -260,18 +275,25 @@ async function onEventDisplay( } // For info modal -const infoModalEvent = ref(null as HistEvent | null); const infoModalData = ref(null as EventInfo | null); -async function onEventClick(eventId: number){ +async function onInfoClick(eventTitle: string){ // Query server for event info - let urlParams = new URLSearchParams({type: 'info', event: String(eventId)}); + let urlParams = new URLSearchParams({type: 'info', event: eventTitle}); let responseObj: EventInfoJson | null = await queryServer(urlParams); if (responseObj != null){ - infoModalEvent.value = idToEvent.get(eventId)!; infoModalData.value = jsonToEventInfo(responseObj); + } else { + console.log('WARNING: Server gave null response to info query'); } } +// For search modal +const searchOpen = ref(false); +function onSearch(event: HistEvent){ + searchOpen.value = false; + console.log(`Need to jump to event "${event.title}" at ${event.start}`); +} + // For resize handling let lastResizeHdlrTime = 0; // Used to throttle resize handling let afterResizeHdlr = 0; // Used to trigger handler after ending a run of resize events @@ -298,6 +320,33 @@ async function onResize(){ onMounted(() => window.addEventListener('resize', onResize)); onUnmounted(() => window.removeEventListener('resize', onResize)); +// For keyboard shortcuts +function onKeyDown(evt: KeyboardEvent){ + if (store.disableShortcuts){ + return; + } + if (evt.key == 'Escape'){ + if (infoModalData.value != null){ + infoModalData.value = null; + } else if (searchOpen.value){ + searchOpen.value = false; + } + } else if (evt.key == 'f' && evt.ctrlKey){ + evt.preventDefault(); + // Open/focus search bar + if (!searchOpen.value){ + searchOpen.value = true; + } + } +} +onMounted(() => { + window.addEventListener('keydown', onKeyDown); + // Note: Need 'keydown' instead of 'keyup' to override default CTRL-F +}); +onUnmounted(() => { + window.removeEventListener('keydown', onKeyDown); +}); + // Styles const buttonStyles = computed(() => ({ color: store.color.text, diff --git a/src/components/InfoModal.vue b/src/components/InfoModal.vue index 6ab2bde..3e03187 100644 --- a/src/components/InfoModal.vue +++ b/src/components/InfoModal.vue @@ -74,7 +74,7 @@ import SCollapsible from './SCollapsible.vue'; import CloseIcon from './icon/CloseIcon.vue'; import DownIcon from './icon/DownIcon.vue'; import ExternalLinkIcon from './icon/ExternalLinkIcon.vue'; -import {HistEvent, EventInfo} from '../lib'; +import {EventInfo} from '../lib'; import {useStore} from '../store'; // Refs @@ -86,14 +86,14 @@ const store = useStore(); // Props + events const props = defineProps({ - event: {type: Object as PropType<HistEvent>, required: true}, eventInfo: {type: Object as PropType<EventInfo>, required: true}, }); const emit = defineEmits(['close']); // For data display +const event = computed(() => props.eventInfo.event) const datesDisplayStr = computed(() => { - return props.event.start.toString() + (props.event.end == null ? '' : ' to ' + props.event.end.toString()) + return event.value.start.toString() + (event.value.end == null ? '' : ' to ' + event.value.end.toString()) }); function licenseToUrl(license: string){ license = license.toLowerCase().replaceAll('-', ' '); diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue new file mode 100644 index 0000000..65d2496 --- /dev/null +++ b/src/components/SearchModal.vue @@ -0,0 +1,207 @@ +<template> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef"> + <div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex" + :style="styles"> + <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef" + @keyup.enter="onSearch" @keyup.esc="onClose" + @input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/> + <div class="p-1 hover:cursor-pointer"> + <search-icon @click.stop="onSearch" class="w-8 h-8"/> + </div> + <div class="absolute top-[100%] w-full overflow-hidden" :style="suggContainerStyles"> + <div v-for="(sugg, idx) of searchSuggs" :key="sugg" + :style="{backgroundColor: idx == focusedSuggIdx ? store.color.bgAltDark : store.color.bgAlt}" + class="border-b p-1 px-2 hover:underline hover:cursor-pointer flex" + @click="resolveSearch(sugg)"> + <div class="grow overflow-hidden whitespace-nowrap text-ellipsis"> + <span>{{suggDisplayStrings[idx][0]}}</span> + <span class="font-bold text-yellow-600">{{suggDisplayStrings[idx][1]}}</span> + <span>{{suggDisplayStrings[idx][2]}}</span> + </div> + <info-icon class="hover:cursor-pointer my-auto w-5 h-5" + @click.stop="onInfoIconClick(sugg)"/> + </div> + <div v-if="hasMoreSuggs" class="text-center">• • •</div> + </div> + </div> +</div> +</template> + +<script setup lang="ts"> +import {ref, computed, onMounted, onUnmounted, PropType} from 'vue'; +import SearchIcon from './icon/SearchIcon.vue'; +import InfoIcon from './icon/InfoIcon.vue'; +import {HistEvent, queryServer, EventInfoJson, jsonToEventInfo, SuggResponseJson} from '../lib'; +import {useStore} from '../store'; +import {RBTree} from '../rbtree'; + +// Refs +const rootRef = ref(null as HTMLDivElement | null); +const inputRef = ref(null as HTMLInputElement | null); + +// Global store +const store = useStore(); + +// Props + events +const props = defineProps({ + eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true}, + titleToEvent: {type: Object as PropType<Map<string, HistEvent>>, required: true}, +}); +const emit = defineEmits(['search', 'close', 'info-click']); + +// Search-suggestion data +const searchSuggs = ref([] as string[]); +const hasMoreSuggs = ref(false); +const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text) +const suggDisplayStrings = computed((): [string, string, string][] => { + let result: [string, string, string][] = []; + let input = suggsInput.value; + // Split each suggestion's text into parts before/within/after an input match + for (let title of searchSuggs.value){ + let idx = title.toLowerCase().indexOf(input.toLowerCase()); + if (idx != -1){ + result.push([ + title.substring(0, idx), + title.substring(idx, idx + input.length), + title.substring(idx + input.length) + ]); + } else { + result.push([input, '', '']); + } + } + return result; +}); +const focusedSuggIdx = ref(null as null | number); // Index of a suggestion selected using the arrow keys + +// For server requests +const lastReqTime = ref(0); +const pendingReqParams = ref(null as null | URLSearchParams); // Holds data for latest request to make +const pendingReqInput = ref(''); // Holds the user input associated with pendingReqData +const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for making a request despite a previous one still waiting +async function onInput(){ + let input = inputRef.value!; + // Check for empty input + if (input.value.length == 0){ + searchSuggs.value = []; + hasMoreSuggs.value = false; + focusedSuggIdx.value = null; + return; + } + // Create URL params + let urlParams = new URLSearchParams({ + type: 'sugg', + input: input.value, + limit: String(store.searchSuggLimit), + }); + // Code for querying server + pendingReqParams.value = urlParams; + pendingReqInput.value = input.value; + let doReq = async () => { + let reqInput = pendingReqInput.value; + let responseObj: SuggResponseJson | null = await queryServer(pendingReqParams.value!); + if (responseObj == null){ + return; + } + searchSuggs.value = responseObj.suggs; + hasMoreSuggs.value = responseObj.hasMore; + suggsInput.value = reqInput; + // Auto-select first result if present + if (searchSuggs.value.length > 0){ + focusedSuggIdx.value = 0; + } else { + focusedSuggIdx.value = null; + } + }; + // Query server, delaying/skipping if a request was recently sent + let currentTime = new Date().getTime(); + if (lastReqTime.value == 0){ + lastReqTime.value = currentTime; + await doReq(); + if (lastReqTime.value == currentTime){ + lastReqTime.value = 0; + } + } else if (pendingDelayedSuggReq.value == 0){ + lastReqTime.value = currentTime; + pendingDelayedSuggReq.value = window.setTimeout(async () => { + pendingDelayedSuggReq.value = 0; + await doReq(); + if (lastReqTime.value == currentTime){ + lastReqTime.value = 0; + } + }, 300); + } +} + +// For search events +function onSearch(){ + if (focusedSuggIdx.value == null){ + let input = inputRef.value!.value.toLowerCase(); + resolveSearch(input) + } else { + resolveSearch(searchSuggs.value[focusedSuggIdx.value]); + } +} +async function resolveSearch(eventTitle: string){ + if (eventTitle == ''){ + return; + } + // Check if the event data is already here + if (props.titleToEvent.has(eventTitle)){ + emit('search', props.titleToEvent.get(eventTitle)); + return; + } + // Query server for event + let urlParams = new URLSearchParams({type: 'info', event: eventTitle}); + let responseObj: EventInfoJson | null = await queryServer(urlParams); + if (responseObj != null){ + let eventInfo = jsonToEventInfo(responseObj); + emit('search', eventInfo.event); + } else { + // Trigger failure animation + let input = inputRef.value!; + input.classList.remove('animate-red-then-fade'); + input.offsetWidth; // Triggers reflow + input.classList.add('animate-red-then-fade'); + } +} + +// More event handling +function onClose(evt: Event){ + if (evt.target == rootRef.value){ + emit('close'); + } +} +function onDownKey(){ + if (focusedSuggIdx.value != null){ + focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length; + } +} +function onUpKey(){ + if (focusedSuggIdx.value != null){ + focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length; + // The addition after '-1' is to avoid becoming negative + } +} +function onInfoIconClick(eventTitle: string){ + emit('info-click', eventTitle); +} + +// Focus input on mount +onMounted(() => inputRef.value!.focus()) + +// Styles +const styles = computed((): Record<string,string> => { + let br = store.borderRadius; + return { + backgroundColor: store.color.bgAlt, + borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`, + }; +}); +const suggContainerStyles = computed((): Record<string,string> => { + let br = store.borderRadius; + return { + backgroundColor: store.color.bgAlt, + borderRadius: `0 0 ${br}px ${br}px`, + }; +}); +</script> diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue index 277a263..e5fbf34 100644 --- a/src/components/TimeLine.vue +++ b/src/components/TimeLine.vue @@ -48,7 +48,7 @@ <div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein z-20" :style="eventStyles(id)"> <!-- Image --> <div class="rounded-full cursor-pointer hover:brightness-125" :style="eventImgStyles(id)" - @click="onEventClick(id)"></div> + @click="emit('info-click', idToEvent.get(id)!.title)"></div> <!-- Label --> <div class="text-center text-stone-100 text-sm whitespace-nowrap text-ellipsis overflow-hidden"> {{idToEvent.get(id)!.title}} @@ -96,7 +96,7 @@ const props = defineProps({ eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true}, unitCountMaps: {type: Object as PropType<Map<number, number>[]>, required: true}, }); -const emit = defineEmits(['close', 'state-chg', 'event-display', 'event-click']); +const emit = defineEmits(['close', 'state-chg', 'event-display', 'info-click']); // For size tracking const width = ref(0); @@ -1109,11 +1109,6 @@ watch(firstDate, onStateChg); const skipTransition = ref(true); onMounted(() => setTimeout(() => {skipTransition.value = false}, 100)); -// Click handling -function onEventClick(eventId: number){ - emit('event-click', eventId); -} - // Styles const mainlineStyles = computed(() => { return { diff --git a/src/components/icon/InfoIcon.vue b/src/components/icon/InfoIcon.vue new file mode 100644 index 0000000..47a14cd --- /dev/null +++ b/src/components/icon/InfoIcon.vue @@ -0,0 +1,8 @@ +<template> +<svg viewBox="0 0 24 24" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="12" cy="12" r="10"/> + <line x1="12" y1="16" x2="12" y2="12"/> + <line x1="12" y1="8" x2="12.01" y2="8"/> +</svg> +</template> diff --git a/src/index.css b/src/index.css index e050c82..e16c538 100644 --- a/src/index.css +++ b/src/index.css @@ -25,6 +25,19 @@ opacity: 1; } } +.animate-red-then-fade { + animation-name: red-then-fade; + animation-duration: 500ms; + animation-timing-function: ease-in; +} +@keyframes red-then-fade { + from { + background-color: rgba(255,0,0,0.2); + } + to { + background-color: transparent; + } +} /* Other */ @font-face { @@ -280,10 +280,12 @@ export class ImgInfo { } } export class EventInfo { + event: HistEvent; desc: string; wikiId: number; imgInfo: ImgInfo; - constructor(desc: string, wikiId: number, imgInfo: ImgInfo){ + constructor(event: HistEvent, desc: string, wikiId: number, imgInfo: ImgInfo){ + this.event = event; this.desc = desc; this.wikiId = wikiId; this.imgInfo = imgInfo; @@ -310,9 +312,6 @@ export async function queryServer(params: URLSearchParams, serverDataUrl=SERVER_ console.log(`Error with querying ${url.toString()}: ${error}`); return null; } - if (responseObj == null){ - console.log('WARNING: Server gave null response'); - } return responseObj; } export function getImagePath(imgId: number): string { @@ -341,6 +340,7 @@ export type EventResponseJson = { unitCounts: {[x: number]: number} | null, } export type EventInfoJson = { + event: HistEventJson, desc: string, wikiId: number, imgInfo: ImgInfoJson, @@ -351,6 +351,10 @@ export type ImgInfoJson = { artist: string, credit: string, } +export type SuggResponseJson = { + suggs: string[], + hasMore: boolean, +} export function jsonToHistDate(json: HistDateJson): HistDate { return new HistDate(json.gcal, json.year, json.month, json.day); } @@ -368,7 +372,7 @@ export function jsonToHistEvent(json: HistEventJson): HistEvent { ); } export function jsonToEventInfo(json: EventInfoJson): EventInfo { - return new EventInfo(json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); + return new EventInfo(jsonToHistEvent(json.event), json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); } export function jsonToImgInfo(json: ImgInfoJson): ImgInfo { return new ImgInfo(json.url, json.license, json.artist, json.credit); diff --git a/src/store.ts b/src/store.ts index 1d51cc7..393e4b8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,19 +8,20 @@ import {CalDate} from './lib'; export const useStore = defineStore('store', { state: () => { const color = { // Note: For scrollbar colors on chrome, edit ./index.css - text: '#fafaf9', // stone-50 - textDark: '#a8a29e', // stone-400 - bg: '#292524', // stone-800 - bgLight: '#44403c', // stone-700 - bgDark: '#1c1917', // stone-900 - bgLight2: '#57534e', // stone-600 - bgDark2: '#0e0c0b', // darker version of stone-900 - alt: '#fde047', // yellow-300 - altDark: '#eab308', // yellow-500 - altDark2: '#ca8a04', // yellow-600 + text: '#fafaf9', // stone-50 + textDark: '#a8a29e', // stone-400 + bg: '#292524', // stone-800 + bgLight: '#44403c', // stone-700 + bgDark: '#1c1917', // stone-900 + bgLight2: '#57534e', // stone-600 + bgDark2: '#0e0c0b', // darker version of stone-900 + alt: '#fde047', // yellow-300 + altDark: '#eab308', // yellow-500 + altDark2: '#ca8a04', // yellow-600 altBg: '#6a5e2e', - alt2: '#2563eb', // sky-600 - bgAlt: '#f5f5f4', // stone-100 + alt2: '#2563eb', // sky-600 + bgAlt: '#f5f5f4', // stone-100 + bgAltDark: '#d6d3d1', // stone-300 }; return { tickLen: 16, @@ -46,6 +47,8 @@ export const useStore = defineStore('store', { showEventCounts: true, transitionDuration: 300, borderRadius: 5, // px + searchSuggLimit: 10, + disableShortcuts: false, }; }, }); |
