diff --git a/packages/models/src/Domain/Syncable/Tag/Tag.ts b/packages/models/src/Domain/Syncable/Tag/Tag.ts index 6213e2391..4c6c693a3 100644 --- a/packages/models/src/Domain/Syncable/Tag/Tag.ts +++ b/packages/models/src/Domain/Syncable/Tag/Tag.ts @@ -28,6 +28,10 @@ export class SNTag extends DecryptedItem implements TagContentSpecia this.preferences = this.payload.content.preferences } + get isDailyEntry(): boolean { + return this.preferences?.entryMode === 'daily' + } + get noteReferences(): ContentReference[] { const references = this.payload.references return references.filter((ref) => ref.content_type === ContentType.Note) diff --git a/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts b/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts index 25eb5053f..6f59ef030 100644 --- a/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts +++ b/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts @@ -16,4 +16,5 @@ export interface TagPreferences { newNoteTitleFormat?: NewNoteTitleFormat customNoteTitleFormat?: string editorIdentifier?: FeatureIdentifier | string + entryMode?: 'normal' | 'daily' } diff --git a/packages/services/src/Domain/Item/ItemManagerInterface.ts b/packages/services/src/Domain/Item/ItemManagerInterface.ts index a67637563..21869a979 100644 --- a/packages/services/src/Domain/Item/ItemManagerInterface.ts +++ b/packages/services/src/Domain/Item/ItemManagerInterface.ts @@ -11,6 +11,7 @@ import { DeletedItemInterface, ItemContent, PredicateInterface, + DecryptedPayload, } from '@standardnotes/models' import { AbstractService } from '../Service/AbstractService' @@ -93,6 +94,7 @@ export interface ItemManagerInterface extends AbstractService { >( contentType: ContentType, content?: C, + override?: Partial>, ): I /** diff --git a/packages/services/src/Domain/Mutator/MutatorClientInterface.ts b/packages/services/src/Domain/Mutator/MutatorClientInterface.ts index 0a9ebb5be..b8626ad17 100644 --- a/packages/services/src/Domain/Mutator/MutatorClientInterface.ts +++ b/packages/services/src/Domain/Mutator/MutatorClientInterface.ts @@ -3,6 +3,7 @@ import { BackupFile, DecryptedItemInterface, DecryptedItemMutator, + DecryptedPayload, EncryptedItemInterface, FileItem, ItemContent, @@ -117,6 +118,7 @@ export interface MutatorClientInterface { >( contentType: ContentType, content?: C, + override?: Partial>, ): I /** diff --git a/packages/snjs/lib/Client/NoteViewController.spec.ts b/packages/snjs/lib/Client/NoteViewController.spec.ts index 07aaef454..dabe301d3 100644 --- a/packages/snjs/lib/Client/NoteViewController.spec.ts +++ b/packages/snjs/lib/Client/NoteViewController.spec.ts @@ -28,6 +28,7 @@ describe('note view controller', () => { expect(application.mutator.createTemplateItem).toHaveBeenCalledWith( ContentType.Note, expect.objectContaining({ noteType: NoteType.Plain }), + expect.anything(), ) }) }) diff --git a/packages/snjs/lib/Client/NoteViewController.ts b/packages/snjs/lib/Client/NoteViewController.ts index ed28f5b2b..dc59039d9 100644 --- a/packages/snjs/lib/Client/NoteViewController.ts +++ b/packages/snjs/lib/Client/NoteViewController.ts @@ -34,7 +34,6 @@ export class NoteViewController implements ItemViewControllerInterface { private removeStreamObserver?: () => void public isTemplateNote = false private saveTimeout?: ReturnType - private defaultTitle: string | undefined private defaultTagUuid: UuidString | undefined private defaultTag?: SNTag public runtimeId = `${Math.random()}` @@ -42,14 +41,13 @@ export class NoteViewController implements ItemViewControllerInterface { constructor( private application: SNApplication, item?: SNNote, - templateNoteOptions?: TemplateNoteViewControllerOptions, + public templateNoteOptions?: TemplateNoteViewControllerOptions, ) { if (item) { this.item = item } if (templateNoteOptions) { - this.defaultTitle = templateNoteOptions.title this.defaultTagUuid = templateNoteOptions.tag } @@ -80,13 +78,19 @@ export class NoteViewController implements ItemViewControllerInterface { ? this.application.componentManager.componentWithIdentifier(editorIdentifier) : undefined - const note = this.application.mutator.createTemplateItem(ContentType.Note, { - text: '', - title: this.defaultTitle || '', - noteType: defaultEditor?.noteType || NoteType.Plain, - editorIdentifier: editorIdentifier, - references: [], - }) + const note = this.application.mutator.createTemplateItem( + ContentType.Note, + { + text: '', + title: this.templateNoteOptions?.title || '', + noteType: defaultEditor?.noteType || NoteType.Plain, + editorIdentifier: editorIdentifier, + references: [], + }, + { + created_at: this.templateNoteOptions?.createdAt || new Date(), + }, + ) this.isTemplateNote = true this.item = note @@ -109,6 +113,10 @@ export class NoteViewController implements ItemViewControllerInterface { } private streamItems() { + if (this.dealloced) { + return + } + this.removeStreamObserver = this.application.streamItems( ContentType.Note, ({ changed, inserted, source }) => { diff --git a/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts b/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts index 1fe67688e..1cdebefd3 100644 --- a/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts +++ b/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts @@ -3,4 +3,8 @@ import { UuidString } from '@Lib/Types/UuidString' export type TemplateNoteViewControllerOptions = { title?: string tag?: UuidString + createdAt?: Date + autofocusBehavior?: TemplateNoteViewAutofocusBehavior } + +export type TemplateNoteViewAutofocusBehavior = 'title' | 'editor' diff --git a/packages/snjs/lib/Client/Types.ts b/packages/snjs/lib/Client/Types.ts index 1f4ddcbbd..d21177df3 100644 --- a/packages/snjs/lib/Client/Types.ts +++ b/packages/snjs/lib/Client/Types.ts @@ -3,6 +3,6 @@ export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN = export const STRING_INVALID_NOTE = "The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note." export const STRING_ELLIPSES = '...' -export const NOTE_PREVIEW_CHAR_LIMIT = 80 +export const NOTE_PREVIEW_CHAR_LIMIT = 160 export const SAVE_TIMEOUT_DEBOUNCE = 350 export const SAVE_TIMEOUT_NO_DEBOUNCE = 100 diff --git a/packages/snjs/lib/Client/index.ts b/packages/snjs/lib/Client/index.ts index 0a9a03043..b1db52d7b 100644 --- a/packages/snjs/lib/Client/index.ts +++ b/packages/snjs/lib/Client/index.ts @@ -3,3 +3,4 @@ export * from './NoteViewController' export * from './FileViewController' export * from './ItemGroupController' export * from './ReactNativeToWebEvent' +export * from './TemplateNoteViewControllerOptions' diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index a5db04adc..dcedf2185 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -805,12 +805,13 @@ export class ItemManager public createTemplateItem< C extends Models.ItemContent = Models.ItemContent, I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, - >(contentType: ContentType, content?: C): I { + >(contentType: ContentType, content?: C, override?: Partial>): I { const payload = new Models.DecryptedPayload({ uuid: UuidGenerator.GenerateUuid(), content_type: contentType, content: Models.FillItemContent(content || {}), ...Models.PayloadTimestampDefaults(), + ...override, }) const item = Models.CreateDecryptedItemFromPayload(payload) return item diff --git a/packages/snjs/lib/Services/Mutator/MutatorService.ts b/packages/snjs/lib/Services/Mutator/MutatorService.ts index 9d6878226..ad1023b5c 100644 --- a/packages/snjs/lib/Services/Mutator/MutatorService.ts +++ b/packages/snjs/lib/Services/Mutator/MutatorService.ts @@ -29,6 +29,7 @@ import { CreateEncryptedBackupFileContextPayload, DecryptedItemInterface, DecryptedItemMutator, + DecryptedPayload, DecryptedPayloadInterface, EncryptedItemInterface, FileItem, @@ -221,8 +222,8 @@ export class MutatorService extends AbstractService implements MutatorClientInte public createTemplateItem< C extends ItemContent = ItemContent, I extends DecryptedItemInterface = DecryptedItemInterface, - >(contentType: ContentType, content?: C): I { - return this.itemManager.createTemplateItem(contentType, content) + >(contentType: ContentType, content?: C, override?: Partial>): I { + return this.itemManager.createTemplateItem(contentType, content, override) } public async setItemNeedsSync( diff --git a/packages/styles/src/Styles/_colors.scss b/packages/styles/src/Styles/_colors.scss index 1ccdbf990..9799bb590 100644 --- a/packages/styles/src/Styles/_colors.scss +++ b/packages/styles/src/Styles/_colors.scss @@ -15,6 +15,7 @@ --sn-stylekit-danger-color: #cc2128; --sn-stylekit-danger-contrast-color: #ffffff; + --sn-stylekit-danger-light-color: #f9e4e5; --sn-stylekit-shadow-color: #c8c8c8; --sn-stylekit-background-color: #ffffff; diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/Calendar.tsx b/packages/web/src/javascripts/Components/ContentListView/Calendar/Calendar.tsx new file mode 100644 index 000000000..12d3a68f1 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/Calendar.tsx @@ -0,0 +1,68 @@ +import { useState, useEffect, FunctionComponent, useMemo } from 'react' +import { CalendarActivity } from './CalendarActivity' +import CalendarDay from './CalendarDay' +import { CalendarDays, CalendarDaysLeap, CalendarDaysOfTheWeek } from './Constants' +import { createActivityRecord, dateToDateOnlyString, isLeapYear, getStartDayOfMonth } from './CalendarUtilts' +import { isDateInSameDay } from '@/Utils/DateUtils' + +type Props = { + activities: CalendarActivity[] + onDateSelect: (date: Date) => void + startDate: Date + className?: string + selectedDay?: Date +} + +const Calendar: FunctionComponent = ({ activities, startDate, onDateSelect, selectedDay, className }) => { + const activityMap = useMemo(() => createActivityRecord(activities), [activities]) + + const [date, setDate] = useState(startDate || new Date()) + const [month, setMonth] = useState(date.getMonth()) + const [year, setYear] = useState(date.getFullYear()) + const [startDay, setStartDay] = useState(getStartDayOfMonth(date)) + + useEffect(() => { + setDate(startDate) + setMonth(startDate.getMonth()) + setYear(startDate.getFullYear()) + setStartDay(getStartDayOfMonth(startDate)) + }, [startDate]) + + const today = new Date() + const days = isLeapYear(year) ? CalendarDaysLeap : CalendarDays + + return ( +
+
+
+ {CalendarDaysOfTheWeek.map((d) => ( +
+ {d} +
+ ))} +
+
+ {Array(days[month] + (startDay - 1)) + .fill(null) + .map((_, index) => { + const d = index - (startDay - 2) + const date = new Date(year, month, d) + const activities = activityMap[dateToDateOnlyString(date)] || [] + return ( + onDateSelect(date)} + hasPendingEntry={selectedDay && isDateInSameDay(selectedDay, date)} + /> + ) + })} +
+
+
+ ) +} + +export default Calendar diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarActivity.ts b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarActivity.ts new file mode 100644 index 000000000..4d5824a3e --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarActivity.ts @@ -0,0 +1,7 @@ +import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem' +export type CalendarActivityType = 'created' | 'edited' + +export type CalendarActivity = { + date: Date + item: ListableContentItem +} diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarDay.tsx b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarDay.tsx new file mode 100644 index 000000000..7ccbeb68e --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarDay.tsx @@ -0,0 +1,36 @@ +import { FunctionComponent } from 'react' +import { CalendarActivity } from './CalendarActivity' + +type Props = { + day: number + activities: CalendarActivity[] + isToday: boolean + onClick: () => void + hasPendingEntry?: boolean +} + +const CalendarDay: FunctionComponent = ({ day, activities = [], hasPendingEntry, isToday, onClick }) => { + const hasActivity = day > 0 && activities.length > 0 + const todayClassNames = 'bg-danger text-danger-contrast font-bold' + const hasActivityClassNames = 'bg-danger-light text-danger font-bold' + const defaultClassNames = 'bg-transparent hover:bg-contrast' + const hasPendingEntryNames = 'bg-contrast' + + return ( +
+
+ {day > 0 ? day : ''} +
+
+ ) +} + +export default CalendarDay diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarMonth.tsx b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarMonth.tsx new file mode 100644 index 000000000..7160b985c --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarMonth.tsx @@ -0,0 +1,4 @@ +export type CalendarMonth = { + /** Any date in the month */ + date: Date +} diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarUtils.spec.ts b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarUtils.spec.ts new file mode 100644 index 000000000..7ebe0c74a --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarUtils.spec.ts @@ -0,0 +1,27 @@ +import { addMonths, areDatesInSameMonth } from '@/Utils/DateUtils' +import { CalendarMonth } from './CalendarMonth' +import { insertMonthsWithTarget } from './CalendarUtilts' + +describe('calendar utils', () => { + it('insertMonthsWithTarget in past', () => { + const today = new Date() + const months: CalendarMonth[] = [{ date: addMonths(today, -1) }, { date: today }, { date: addMonths(today, 1) }] + const targetMonth = addMonths(today, -12) + + const result = insertMonthsWithTarget(months, targetMonth) + + expect(result).toHaveLength(14) + expect(areDatesInSameMonth(result[0].date, targetMonth)) + }) + + it('insertMonthsWithTarget in future', () => { + const today = new Date() + const months: CalendarMonth[] = [{ date: addMonths(today, -1) }, { date: today }, { date: addMonths(today, 1) }] + const targetMonth = addMonths(today, 12) + + const result = insertMonthsWithTarget(months, targetMonth) + + expect(result).toHaveLength(14) + expect(areDatesInSameMonth(result[result.length - 1].date, targetMonth)) + }) +}) diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarUtilts.ts b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarUtilts.ts new file mode 100644 index 000000000..a8dc3420e --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/CalendarUtilts.ts @@ -0,0 +1,89 @@ +import { addMonths, numberOfMonthsBetweenDates } from '@/Utils/DateUtils' +import { CalendarActivity } from './CalendarActivity' +import { CalendarMonth } from './CalendarMonth' + +type DateOnlyString = string + +export function dateToDateOnlyString(date: Date): DateOnlyString { + return date.toLocaleDateString() +} + +type ActivityRecord = Record + +export function createActivityRecord(activities: CalendarActivity[]): ActivityRecord { + const map: Record = {} + for (const activity of activities) { + const string = dateToDateOnlyString(activity.date) + if (!map[string]) { + map[string] = [] + } + map[string].push(activity) + } + return map +} + +export function isLeapYear(year: number) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 +} + +export function getStartDayOfMonth(date: Date) { + const startDate = new Date(date.getFullYear(), date.getMonth(), 1).getDay() + return startDate === 0 ? 7 : startDate +} + +/** + * Modifies months array in-place. + */ +export function insertMonths(months: CalendarMonth[], location: 'front' | 'end', number: number): CalendarMonth[] { + const earlierMonth = months[0].date + const laterMonth = months[months.length - 1].date + + for (let i = 1; i <= number; i++) { + if (location === 'front') { + const minusNFromFirstMonth = addMonths(earlierMonth, -i) + months.unshift({ + date: minusNFromFirstMonth, + }) + } else { + const plusNFromLastMonth = addMonths(laterMonth, i) + months.push({ + date: plusNFromLastMonth, + }) + } + } + + return months +} + +/** + * Modifies months array in-place. + */ +export function insertMonthsWithTarget(months: CalendarMonth[], targetMonth: Date): CalendarMonth[] { + const firstMonth = months[0].date + const lastMonth = months[months.length - 1].date + + const isBeforeFirstMonth = targetMonth.getTime() < firstMonth.getTime() + + const numMonthsToAdd = Math.abs( + isBeforeFirstMonth + ? numberOfMonthsBetweenDates(firstMonth, targetMonth) + : numberOfMonthsBetweenDates(lastMonth, targetMonth), + ) + + if (isBeforeFirstMonth) { + return insertMonths(months, 'front', numMonthsToAdd) + } else { + return insertMonths(months, 'end', numMonthsToAdd) + } +} + +/** + * Modifies months array in-place. + */ +export function removeMonths(months: CalendarMonth[], location: 'front' | 'end', number: number): void { + if (location === 'front') { + months.splice(0, number) + } else { + months.splice(months.length - number - 1, number) + } +} diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/Constants.ts b/packages/web/src/javascripts/Components/ContentListView/Calendar/Constants.ts new file mode 100644 index 000000000..2541d87ab --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/Constants.ts @@ -0,0 +1,17 @@ +export const CalendarDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +export const CalendarDaysLeap = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +export const CalendarDaysOfTheWeek = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] +export const CalendarMonths = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] diff --git a/packages/web/src/javascripts/Components/ContentListView/Calendar/InfiniteCalendar.tsx b/packages/web/src/javascripts/Components/ContentListView/Calendar/InfiniteCalendar.tsx new file mode 100644 index 000000000..750f79af0 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Calendar/InfiniteCalendar.tsx @@ -0,0 +1,305 @@ +import { areDatesInSameMonth } from '@/Utils/DateUtils' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import Calendar from './Calendar' +import { CalendarActivity, CalendarActivityType } from './CalendarActivity' +import { CalendarMonth } from './CalendarMonth' +import { CalendarMonths } from './Constants' +import { insertMonths, insertMonthsWithTarget } from './CalendarUtilts' + +type Props = { + activityType: CalendarActivityType + activities: CalendarActivity[] + onDateSelect: (date: Date) => void + selectedTemplateDay?: Date + selectedItemDay?: Date +} + +export type InfiniteCalendarInterface = { + changeMonth: (month: Date) => void +} + +const PageSize = 10 + +const InfiniteCalendar = forwardRef( + ({ activities, onDateSelect, selectedTemplateDay, selectedItemDay }: Props, ref) => { + const [date, setDate] = useState(new Date()) + const [month, setMonth] = useState(date.getMonth()) + const [year, setYear] = useState(date.getFullYear()) + + const [expanded, setExpanded] = useState(true) + const [scrollWidth, setScrollWidth] = useState(0) + + const today = new Date() + const [months, setMonths] = useState(() => { + const base = [{ date: today }] + insertMonths(base, 'front', 2) + insertMonths(base, 'end', 2) + return base + }) + + useImperativeHandle(ref, () => ({ + changeMonth(date: Date) { + setDate(date) + }, + })) + + const [firstElement, setFirstElement] = useState(null) + const [lastElement, setLastElement] = useState(null) + const [didPaginateLeft, setDidPaginateLeft] = useState(false) + const [restoreScrollAfterExpand, setRestoreScrollAfterExpand] = useState(false) + const scrollArea = useRef(null) + + const hasMonthInList = useCallback( + (date: Date): boolean => { + for (const month of months) { + if (areDatesInSameMonth(month.date, date)) { + return true + } + } + return false + }, + [months], + ) + + const insertMonthInList = useCallback( + (date: Date): void => { + setMonths(insertMonthsWithTarget(months, date)) + }, + [months, setMonths], + ) + + const resetNumberOfCalendarsToBase = useCallback( + (centerOnDate: Date) => { + const base = [{ date: centerOnDate }] + insertMonths(base, 'front', 1) + insertMonths(base, 'end', 1) + setMonths(base) + }, + [setMonths], + ) + + useEffect(() => { + if (selectedTemplateDay) { + setDate(selectedTemplateDay) + } + }, [selectedTemplateDay]) + + useEffect(() => { + if (selectedItemDay) { + setDate(selectedItemDay) + } + }, [selectedItemDay]) + + const scrollToMonth = useCallback((date: Date) => { + const elementId = elementIdForMonth(date) + const element = document.getElementById(elementId) + if (!element) { + return + } + + scrollArea.current!.scrollLeft = element.offsetLeft + -60 + }, []) + + useLayoutEffect(() => { + setMonth(date.getMonth()) + setYear(date.getFullYear()) + + if (!hasMonthInList(date)) { + insertMonthInList(date) + } + + scrollToMonth(date) + }, [date, hasMonthInList, insertMonthInList, scrollToMonth]) + + useEffect(() => { + if (!restoreScrollAfterExpand) { + return + } + + if (scrollArea.current && expanded) { + scrollToMonth(date) + setRestoreScrollAfterExpand(false) + } + }, [expanded, scrollToMonth, date, restoreScrollAfterExpand, setRestoreScrollAfterExpand]) + + useLayoutEffect(() => { + if (!scrollArea.current) { + return + } + + if (didPaginateLeft) { + scrollArea.current.scrollLeft += scrollArea.current.scrollWidth - scrollWidth + setDidPaginateLeft(false) + } + }, [months, didPaginateLeft, scrollWidth]) + + const paginateLeft = useCallback(() => { + if (scrollArea.current) { + setScrollWidth(scrollArea.current.scrollWidth) + } + + const copy = months.slice() + insertMonths(copy, 'front', PageSize) + setDidPaginateLeft(true) + setMonths(copy) + }, [months, setMonths]) + + const paginateRight = useCallback(() => { + const copy = months.slice() + insertMonths(copy, 'end', PageSize) + setDidPaginateLeft(false) + setMonths(copy) + }, [months, setMonths]) + + const updateCurrentMonth = useCallback( + (index: number) => { + const newMonth = months[index] + setMonth(newMonth.date.getMonth()) + setYear(newMonth.date.getFullYear()) + }, + [months, setMonth, setYear], + ) + + const visibilityObserver = useMemo( + () => + new IntersectionObserver( + (entries) => { + const visibleEntry = entries.find((entry) => entry.isIntersecting) + if (visibleEntry) { + const id = visibleEntry.target.id + const index = months.findIndex((candidate) => elementIdForMonth(candidate.date) === id) + updateCurrentMonth(index) + } + }, + { threshold: 0.9 }, + ), + [updateCurrentMonth, months], + ) + + const rightObserver = useMemo( + () => + new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + paginateRight() + } + }, + { threshold: 0.5 }, + ), + [paginateRight], + ) + + const leftObserver = useMemo( + () => + new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + paginateLeft() + } + }, + { threshold: 1.0 }, + ), + [paginateLeft], + ) + + useEffect(() => { + if (lastElement) { + rightObserver.observe(lastElement) + } + + return () => { + if (lastElement) { + rightObserver.unobserve(lastElement) + } + } + }, [lastElement, rightObserver]) + + useEffect(() => { + if (firstElement) { + leftObserver.observe(firstElement) + } + + return () => { + if (firstElement) { + leftObserver.unobserve(firstElement) + } + } + }, [firstElement, leftObserver]) + + const toggleVisibility = useCallback(() => { + setRestoreScrollAfterExpand(true) + + setExpanded(!expanded) + }, [expanded, setExpanded, setRestoreScrollAfterExpand]) + + const elementIdForMonth = (date: Date): string => { + return `month-${date.getFullYear()}-${date.getMonth()}` + } + + const handleDaySelection = useCallback( + (date: Date) => { + resetNumberOfCalendarsToBase(date) + onDateSelect(date) + }, + [onDateSelect, resetNumberOfCalendarsToBase], + ) + + return ( +
+
+ {CalendarMonths[month]} {year} +
+ {expanded && ( +
+ {months.map((month, index) => { + const isFirst = index === 0 + const isLast = index === months.length - 1 + const id = elementIdForMonth(month.date) + return ( +
{ + isFirst ? setFirstElement(ref) : isLast ? setLastElement(ref) : null + + if (ref) { + visibilityObserver.observe(ref) + } + }} + > + +
+ ) + })} +
+ )} +
+ ) + }, +) + +export default InfiniteCalendar diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx index dcadcd0f6..4220679b8 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx @@ -12,6 +12,7 @@ import { NavigationController } from '@/Controllers/Navigation/NavigationControl import { NotesController } from '@/Controllers/NotesController' import { ElementIds } from '@/Constants/ElementIDs' import { classNames } from '@/Utils/ConcatenateClassNames' +import { SNTag } from '@standardnotes/snjs' type Props = { application: WebApplication @@ -64,6 +65,33 @@ const ContentList: FunctionComponent = ({ [selectNextItem, selectPreviousItem], ) + const selectItem = useCallback( + (item: ListableContentItem, userTriggered?: boolean) => { + return selectionController.selectItem(item.uuid, userTriggered) + }, + [selectionController], + ) + + const getTagsForItem = (item: ListableContentItem) => { + if (hideTags) { + return [] + } + + const selectedTag = navigationController.selected + if (!selectedTag) { + return [] + } + + const tags = application.getItemTags(item) + + const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1 + if (isNavigatingOnlyTag) { + return [] + } + + return tags + } + return (
= ({ hideIcon={hideEditorIcon} sortBy={sortBy} filesController={filesController} - selectionController={selectionController} - navigationController={navigationController} + onSelect={selectItem} + tags={getTagsForItem(item)} notesController={notesController} /> ))} diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListItem.tsx index ce3b64556..b141ea746 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListItem.tsx @@ -1,35 +1,15 @@ -import { ContentType, SNTag } from '@standardnotes/snjs' +import { ContentType } from '@standardnotes/snjs' import { FunctionComponent } from 'react' import FileListItem from './FileListItem' import NoteListItem from './NoteListItem' import { AbstractListItemProps } from './Types/AbstractListItemProps' const ContentListItem: FunctionComponent = (props) => { - const getTags = () => { - if (props.hideTags) { - return [] - } - - const selectedTag = props.navigationController.selected - if (!selectedTag) { - return [] - } - - const tags = props.application.getItemTags(props.item) - - const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1 - if (isNavigatingOnlyTag) { - return [] - } - - return tags - } - switch (props.item.content_type) { case ContentType.Note: - return + return case ContentType.File: - return + return default: return null } diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index 36fef15cc..2242b661d 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -26,6 +26,8 @@ import { classNames } from '@/Utils/ConcatenateClassNames' import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider' import { LinkingController } from '@/Controllers/LinkingController' +import DailyContentList from './Daily/DailyContentList' +import { ListableContentItem } from './Types/ListableContentItem' type Props = { accountMenuController: AccountMenuController @@ -106,12 +108,13 @@ const ContentListView: FunctionComponent = ({ panelTitle, panelWidth, renderedItems, + items, searchBarElement, } = itemListController const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController - const { selected: selectedTag } = navigationController + const { selected: selectedTag, selectedAsTag } = navigationController const icon = selectedTag?.iconString @@ -227,6 +230,19 @@ const ContentListView: FunctionComponent = ({ const matchesXLBreakpoint = useMediaQuery(MediaQueryBreakpoints.xl) const isTabletScreenSize = matchesMediumBreakpoint && !matchesXLBreakpoint + const dailyMode = selectedAsTag?.isDailyEntry + + const handleDailyListSelection = useCallback( + async (item: ListableContentItem, userTriggered: boolean) => { + await selectionController.selectItemWithScrollHandling(item, { + userTriggered: true, + scrollIntoView: userTriggered === false, + animated: false, + }) + }, + [selectionController], + ) + return (
= ({ />
- {completedFullSync && !renderedItems.length ?

No items.

: null} - {!completedFullSync && !renderedItems.length ?

Loading...

: null} - {renderedItems.length ? ( - + )} + {!dailyMode && renderedItems.length ? ( + <> + {completedFullSync && !renderedItems.length ? ( +

No items.

+ ) : null} + {!completedFullSync && !renderedItems.length ? ( +

Loading...

+ ) : null} + + ) : null}
diff --git a/packages/web/src/javascripts/Components/ContentListView/Daily/CreateDailySections.ts b/packages/web/src/javascripts/Components/ContentListView/Daily/CreateDailySections.ts new file mode 100644 index 000000000..a48f7cb04 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Daily/CreateDailySections.ts @@ -0,0 +1,66 @@ +import { ListableContentItem } from '../Types/ListableContentItem' +import { addDays, getWeekdayName } from '@/Utils/DateUtils' +import { DailyItemsDay } from './DailyItemsDaySection' +import { dateToDailyDayIdentifier } from './Utils' + +export const createItemsByDateMapping = (items: ListableContentItem[]) => { + const mapping: Record = {} + + for (const item of items) { + const key = dateToDailyDayIdentifier(item.created_at) + if (!mapping[key]) { + mapping[key] = [] + } + mapping[key].push(item) + } + + return mapping +} + +export const templateEntryForDate = (date: Date): DailyItemsDay => { + const entryDateString = dateToDailyDayIdentifier(date) + + return { + dateKey: entryDateString, + date: date, + day: date.getDate(), + isToday: entryDateString === dateToDailyDayIdentifier(new Date()), + id: entryDateString, + weekday: getWeekdayName(date, 'short'), + } +} + +export function createDailyItemsWithToday(count: number): DailyItemsDay[] { + const today = templateEntryForDate(new Date()) + return insertBlanks([today], 'end', count) +} + +/** + * Modifies entries array in-place. + */ +export function insertBlanks(entries: DailyItemsDay[], location: 'front' | 'end', number: number): DailyItemsDay[] { + let laterDay, earlierDay + + if (entries.length > 0) { + laterDay = entries[0].date + earlierDay = entries[entries.length - 1].date + } else { + const today = new Date() + laterDay = today + earlierDay = today + } + + for (let i = 1; i <= number; i++) { + if (location === 'front') { + const plusNFromFirstDay = addDays(laterDay, i) + const futureEntry = templateEntryForDate(plusNFromFirstDay) + entries.unshift(futureEntry) + } else { + const minusNFromLastDay = addDays(earlierDay, -i) + const pastEntry = templateEntryForDate(minusNFromLastDay) + entries.push(pastEntry) + } + } + + return entries +} diff --git a/packages/web/src/javascripts/Components/ContentListView/Daily/DailyContentList.tsx b/packages/web/src/javascripts/Components/ContentListView/Daily/DailyContentList.tsx new file mode 100644 index 000000000..d3fc9be8f --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Daily/DailyContentList.tsx @@ -0,0 +1,309 @@ +import { FunctionComponent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' +import { ListableContentItem } from '../Types/ListableContentItem' +import { ItemListController } from '@/Controllers/ItemList/ItemListController' +import { SelectedItemsController } from '@/Controllers/SelectedItemsController' +import { ElementIds } from '@/Constants/ElementIDs' +import { classNames } from '@/Utils/ConcatenateClassNames' +import { useResponsiveAppPane } from '../../ResponsivePane/ResponsivePaneProvider' +import { AppPaneId } from '../../ResponsivePane/AppPaneMetadata' +import { createDailyItemsWithToday, createItemsByDateMapping, insertBlanks } from './CreateDailySections' +import { DailyItemsDay } from './DailyItemsDaySection' +import { DailyItemCell } from './DailyItemCell' +import { SNTag } from '@standardnotes/snjs' +import { CalendarActivity } from '../Calendar/CalendarActivity' +import { dateToDailyDayIdentifier } from './Utils' +import InfiniteCalendar, { InfiniteCalendarInterface } from '../Calendar/InfiniteCalendar' + +type Props = { + itemListController: ItemListController + items: ListableContentItem[] + onSelect: (item: ListableContentItem, userTriggered: boolean) => Promise + selectedTag: SNTag + selectedUuids: SelectedItemsController['selectedUuids'] +} + +const PageSize = 10 + +const DailyContentList: FunctionComponent = ({ + items, + itemListController, + onSelect, + selectedUuids, + selectedTag, +}) => { + const { toggleAppPane } = useResponsiveAppPane() + const [needsSelectionReload, setNeedsSelectionReload] = useState(false) + const [todayItem, setTodayItem] = useState() + const [selectedDay, setSelectedDay] = useState() + const [lastElement, setLastElement] = useState(null) + const [firstElement, setFirstElement] = useState(null) + const [lastScrollHeight, setLastScrollHeight] = useState(0) + const [didPaginateTop, setDidPaginateTop] = useState(false) + const scrollArea = useRef(null) + const calendarRef = useRef(null) + + const [dailyItems, setDailyItems] = useState(() => { + return createDailyItemsWithToday(PageSize) + }) + + const { hideTags, hideDate, hideNotePreview } = itemListController.webDisplayOptions + + const itemsByDateMapping = useMemo(() => { + return createItemsByDateMapping(items) + }, [items]) + + useEffect(() => { + setTodayItem(dailyItems.find((item) => item.isToday) as DailyItemsDay) + }, [dailyItems]) + + const calendarActivities: CalendarActivity[] = useMemo(() => { + return items.map((item) => { + return { + date: item.created_at, + item: item, + } + }) + }, [items]) + + const paginateBottom = useCallback(() => { + const copy = dailyItems.slice() + insertBlanks(copy, 'end', PageSize) + setDailyItems(copy) + }, [dailyItems, setDailyItems]) + + const paginateTop = useCallback(() => { + if (scrollArea.current) { + setLastScrollHeight(scrollArea.current.scrollHeight) + } + const copy = dailyItems.slice() + insertBlanks(copy, 'front', PageSize) + setDidPaginateTop(true) + setDailyItems(copy) + }, [dailyItems, setDailyItems, setDidPaginateTop]) + + useLayoutEffect(() => { + if (!scrollArea.current) { + return + } + + if (didPaginateTop) { + scrollArea.current.scrollTop += scrollArea.current.scrollHeight - lastScrollHeight + setDidPaginateTop(false) + } + }, [didPaginateTop, lastScrollHeight]) + + const onListItemDidBecomeVisible = useCallback( + (elementId: string) => { + const dailyItem = dailyItems.find((candidate) => candidate.id === elementId) + if (dailyItem) { + calendarRef?.current?.changeMonth(dailyItem.date) + } + }, + [dailyItems], + ) + + const visibilityObserver = useMemo( + () => + new IntersectionObserver( + (entries) => { + const visibleEntry = entries.find((entry) => entry.isIntersecting) + if (visibleEntry) { + onListItemDidBecomeVisible(visibleEntry.target.id) + } + }, + { threshold: 0.9 }, + ), + [onListItemDidBecomeVisible], + ) + + const bottomObserver = useMemo( + () => + new IntersectionObserver( + (entries) => { + const first = entries[0] + if (first.isIntersecting) { + paginateBottom() + } + }, + { threshold: 0.5 }, + ), + [paginateBottom], + ) + + const topObserver = useMemo( + () => + new IntersectionObserver( + (entries) => { + const first = entries[0] + if (first.isIntersecting) { + paginateTop() + } + }, + { threshold: 0.5 }, + ), + [paginateTop], + ) + + useEffect(() => { + if (lastElement) { + bottomObserver.observe(lastElement) + } + + return () => { + if (lastElement) { + bottomObserver.unobserve(lastElement) + } + } + }, [lastElement, bottomObserver]) + + useEffect(() => { + if (firstElement) { + topObserver.observe(firstElement) + } + + return () => { + if (firstElement) { + topObserver.unobserve(firstElement) + } + } + }, [firstElement, topObserver]) + + const onClickItem = useCallback( + async (day: DailyItemsDay, item: ListableContentItem, userTriggered: boolean) => { + await onSelect(item, userTriggered) + toggleAppPane(AppPaneId.Editor) + setSelectedDay(day.date) + }, + [onSelect, toggleAppPane], + ) + + const onClickTemplate = useCallback( + (date: Date) => { + setSelectedDay(date) + itemListController.createNewNote(undefined, date, 'editor') + toggleAppPane(AppPaneId.Editor) + }, + [setSelectedDay, itemListController, toggleAppPane], + ) + + const dailyItemForDate = useCallback( + (date: Date): DailyItemsDay | undefined => { + return dailyItems.find((candidate) => dateToDailyDayIdentifier(date) === candidate.dateKey) + }, + [dailyItems], + ) + + useEffect(() => { + if (needsSelectionReload) { + setNeedsSelectionReload(false) + + if (!todayItem) { + return + } + + const items = itemsByDateMapping[todayItem.id] + if (items?.length > 0) { + const item = items[0] + const dailyItem = dailyItemForDate(item.created_at) + if (dailyItem) { + void onClickItem(dailyItem, items[0], false) + } + } else { + onClickTemplate(todayItem.date) + const itemElement = document.getElementById(todayItem.id) + itemElement?.scrollIntoView({ behavior: 'auto' }) + } + } + }, [needsSelectionReload, onClickItem, onClickTemplate, todayItem, dailyItemForDate, itemsByDateMapping]) + + useEffect(() => { + setNeedsSelectionReload(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTag.uuid]) + + const onCalendarSelect = useCallback( + (date: Date) => { + const dailyItem = dailyItemForDate(date) + if (dailyItem) { + const items = itemsByDateMapping[dailyItem.id] + if (items?.length > 0) { + void onClickItem(dailyItem, items[0], false) + } else if (dailyItem) { + void onClickTemplate(dailyItem.date) + } + } else { + void onClickTemplate(date) + } + }, + [onClickItem, onClickTemplate, dailyItemForDate, itemsByDateMapping], + ) + + return ( + <> + +
+ {dailyItems.map((dailyItem, index) => { + const isFirst = index === 0 + const isLast = index === dailyItems.length - 1 + const items = itemsByDateMapping[dailyItem.id] + if (items) { + return items.map((item) => ( + onClickItem(dailyItem, item, true)} + ref={(ref) => { + isLast ? setLastElement(ref) : isFirst ? setFirstElement(ref) : null + if (ref) { + visibilityObserver.observe(ref) + } + }} + /> + )) + } else { + return ( + onClickTemplate(dailyItem.date)} + ref={(ref) => { + isLast ? setLastElement(ref) : isFirst ? setFirstElement(ref) : null + if (ref) { + visibilityObserver.observe(ref) + } + }} + /> + ) + } + })} +
+ + ) +} + +export default DailyContentList diff --git a/packages/web/src/javascripts/Components/ContentListView/Daily/DailyItemCell.tsx b/packages/web/src/javascripts/Components/ContentListView/Daily/DailyItemCell.tsx new file mode 100644 index 000000000..9730f43b1 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Daily/DailyItemCell.tsx @@ -0,0 +1,85 @@ +import { formatDateAndTimeForNote } from '@/Utils/DateUtils' +import { SNTag } from '@standardnotes/snjs' +import { ComponentPropsWithoutRef, forwardRef, FunctionComponent, Ref } from 'react' +import ListItemFlagIcons from '../ListItemFlagIcons' +import ListItemMetadata from '../ListItemMetadata' +import ListItemTags from '../ListItemTags' +import ListItemNotePreviewText from '../ListItemNotePreviewText' +import { ListableContentItem } from '../Types/ListableContentItem' +import { DailyItemsDay } from './DailyItemsDaySection' +import { ListItemTitle } from '../ListItemTitle' +import { EmptyPlaceholderBars } from './EmptyPlaceholderBars' + +type DaySquareProps = { + day: number + hasActivity: boolean + weekday: string +} + +const DaySquare: FunctionComponent = ({ day, hasActivity, weekday }) => { + return ( +
+
+
{weekday}
+
{day}
+
+
+ ) +} + +interface Props extends ComponentPropsWithoutRef<'div'> { + item?: ListableContentItem + onClick: () => void + section: DailyItemsDay + selected?: boolean + tags?: SNTag[] + hideDate?: boolean + hideTags?: boolean + hidePreview?: boolean +} + +export const DailyItemCell = forwardRef( + ( + { item, tags = [], section, onClick, selected, hideDate = false, hidePreview = false, hideTags = false }: Props, + ref: Ref, + ) => { + return ( +
+
+
+ + +
+ {item && ( + <> + + + + + + )} + {!item && ( +
+
{formatDateAndTimeForNote(section.date, false)}
+ +
+ )} +
+
+
+ {item && } +
+ ) + }, +) diff --git a/packages/web/src/javascripts/Components/ContentListView/Daily/DailyItemsDaySection.tsx b/packages/web/src/javascripts/Components/ContentListView/Daily/DailyItemsDaySection.tsx new file mode 100644 index 000000000..adcf468d9 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Daily/DailyItemsDaySection.tsx @@ -0,0 +1,8 @@ +export type DailyItemsDay = { + dateKey: string + day: number + weekday: string + date: Date + isToday: boolean + id: string +} diff --git a/packages/web/src/javascripts/Components/ContentListView/Daily/EmptyPlaceholderBars.tsx b/packages/web/src/javascripts/Components/ContentListView/Daily/EmptyPlaceholderBars.tsx new file mode 100644 index 000000000..6894f1c2e --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Daily/EmptyPlaceholderBars.tsx @@ -0,0 +1,61 @@ +import { FunctionComponent, useCallback, useEffect, useState } from 'react' + +type Props = { + rows: number +} + +function randomNumber(min: number, max: number) { + const r = Math.random() * (max - min) + min + return Math.floor(r) +} + +export const EmptyPlaceholderBars: FunctionComponent = ({ rows }) => { + const [barWidths, setBarWidths] = useState([]) + const [animationInterval, setAnimationInterval] = useState | null>(null) + + const reloadWidths = useCallback(() => { + const widths = [] + for (let i = 0; i < rows; i++) { + const width = randomNumber(70, 100) + widths.push(width) + } + setBarWidths(widths) + }, [setBarWidths, rows]) + + useEffect(() => { + reloadWidths() + }, [rows, reloadWidths]) + + const onHoverEnter = useCallback(() => { + reloadWidths() + + const interval = setInterval(() => { + reloadWidths() + }, 750) + + setAnimationInterval(interval) + }, [setAnimationInterval, reloadWidths]) + + const onHoverExit = useCallback(() => { + if (animationInterval) { + clearInterval(animationInterval) + setAnimationInterval(null) + } + }, [animationInterval, setAnimationInterval]) + + return ( +
+ {barWidths.map((width, index) => { + return ( +
+ ) + })} +
+ ) +} diff --git a/packages/web/src/javascripts/Components/ContentListView/Daily/Utils.ts b/packages/web/src/javascripts/Components/ContentListView/Daily/Utils.ts new file mode 100644 index 000000000..b47385957 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Daily/Utils.ts @@ -0,0 +1,3 @@ +export function dateToDailyDayIdentifier(date: Date): string { + return date.toLocaleDateString() +} diff --git a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx index e3050be11..5f9f980d6 100644 --- a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx @@ -14,11 +14,11 @@ import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent' const FileListItem: FunctionComponent = ({ application, filesController, - selectionController, hideDate, hideIcon, hideTags, item, + onSelect, selected, sortBy, tags, @@ -44,7 +44,7 @@ const FileListItem: FunctionComponent = ({ let shouldOpenContextMenu = selected if (!selected) { - const { didSelect } = await selectionController.selectItem(item.uuid) + const { didSelect } = await onSelect(item) if (didSelect) { shouldOpenContextMenu = true } @@ -54,15 +54,15 @@ const FileListItem: FunctionComponent = ({ openFileContextMenu(posX, posY) } }, - [selected, selectionController, item.uuid, openFileContextMenu], + [selected, onSelect, item, openFileContextMenu], ) const onClick = useCallback(async () => { - const { didSelect } = await selectionController.selectItem(item.uuid, true) + const { didSelect } = await onSelect(item, true) if (didSelect) { toggleAppPane(AppPaneId.Editor) } - }, [item.uuid, selectionController, toggleAppPane]) + }, [item, onSelect, toggleAppPane]) const IconComponent = () => getFileIconComponent( diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx index c40d5a8ba..802cb8066 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx @@ -5,7 +5,7 @@ import { classNames } from '@/Utils/ConcatenateClassNames' import Popover from '@/Components/Popover/Popover' import DisplayOptionsMenu from './DisplayOptionsMenu' import { NavigationMenuButton } from '@/Components/NavigationMenu/NavigationMenu' -import { IconType } from '@standardnotes/snjs' +import { IconType, isTag } from '@standardnotes/snjs' import RoundIconButton from '@/Components/Button/RoundIconButton' import { AnyTag } from '@/Controllers/Navigation/AnyTagType' @@ -32,6 +32,7 @@ const ContentListHeader = ({ }: Props) => { const displayOptionsContainerRef = useRef(null) const displayOptionsButtonRef = useRef(null) + const isDailyEntry = isTag(selectedTag) && selectedTag.isDailyEntry const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false) @@ -83,7 +84,13 @@ const ContentListHeader = ({
diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index ac8a745c3..16ddcf0d2 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -122,6 +122,9 @@ class NoteView extends PureComponent { this.controller = props.controller this.onEditorComponentLoad = () => { + if (!this.controller || this.controller.dealloced) { + return + } this.application.getDesktopService()?.redoSearch() } @@ -233,7 +236,11 @@ class NoteView extends PureComponent { if (this.controller.isTemplateNote) { setTimeout(() => { - this.focusTitle() + if (this.controller.templateNoteOptions?.autofocusBehavior === 'editor') { + this.focusEditor() + } else { + this.focusTitle() + } }) } } @@ -920,6 +927,10 @@ class NoteView extends PureComponent { } override render() { + if (this.controller.dealloced) { + return null + } + if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) { return ( - + Password protect diff --git a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts index 6629fd02b..80a9a8cda 100644 --- a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts +++ b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts @@ -20,6 +20,8 @@ import { WebAppEvent, NewNoteTitleFormat, useBoolean, + TemplateNoteViewAutofocusBehavior, + isTag, } from '@standardnotes/snjs' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { WebApplication } from '../../Application/Application' @@ -165,18 +167,20 @@ export class ItemListController this.disposers.push( application.addEventObserver(async () => { - void this.reloadItems(ItemsReloadSource.SyncEvent).then(() => { - if ( - this.notes.length === 0 && - this.navigationController.selected instanceof SmartView && - this.navigationController.selected.uuid === SystemViewId.AllNotes && - this.noteFilterText === '' && - !this.getActiveItemController() - ) { - this.createPlaceholderNote()?.catch(console.error) - } - }) - this.setCompletedFullSync(true) + if (!this.completedFullSync) { + void this.reloadItems(ItemsReloadSource.SyncEvent).then(() => { + if ( + this.notes.length === 0 && + this.navigationController.selected instanceof SmartView && + this.navigationController.selected.uuid === SystemViewId.AllNotes && + this.noteFilterText === '' && + !this.getActiveItemController() + ) { + this.createPlaceholderNote()?.catch(console.error) + } + }) + this.setCompletedFullSync(true) + } }, ApplicationEvent.CompletedFullSync), ) @@ -211,6 +215,7 @@ export class ItemListController notesToDisplay: observable, panelTitle: observable, panelWidth: observable, + items: observable, renderedItems: observable, showDisplayOptionsMenu: observable, @@ -253,7 +258,7 @@ export class ItemListController async handleEvent(event: InternalEventInterface): Promise { if (event.type === CrossControllerEvent.TagChanged) { const payload = event.payload as { userTriggered: boolean } - this.handleTagChange(payload.userTriggered) + await this.handleTagChange(payload.userTriggered) } else if (event.type === CrossControllerEvent.ActiveEditorChanged) { this.handleEditorChange().catch(console.error) } @@ -375,12 +380,6 @@ export class ItemListController ) } - private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => { - return ( - itemsReloadSource === ItemsReloadSource.UserTriggeredTagChange || !this.selectionController.selectedUuids.size - ) - } - private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => { const isSearching = this.noteFilterText.length > 0 const itemExistsInUpdatedResults = this.items.find((item) => item.uuid === activeItem?.uuid) @@ -404,6 +403,18 @@ export class ItemListController return activeItem && !this.selectionController.isItemSelected(activeItem) } + private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => { + const selectedTag = this.navigationController.selected + const isDailyEntry = selectedTag && isTag(selectedTag) && selectedTag.isDailyEntry + if (isDailyEntry) { + return false + } + + const userChangedTag = itemsReloadSource === ItemsReloadSource.UserTriggeredTagChange + const hasNoSelectedItem = !this.selectionController.selectedUuids.size + return userChangedTag || hasNoSelectedItem + } + private async recomputeSelectionAfterItemsReload(itemsReloadSource: ItemsReloadSource) { const activeController = this.getActiveItemController() @@ -561,48 +572,65 @@ export class ItemListController return { didReloadItems: true } } - async createNewNoteController(title?: string) { + async createNewNoteController( + title?: string, + createdAt?: Date, + autofocusBehavior?: TemplateNoteViewAutofocusBehavior, + ) { const selectedTag = this.navigationController.selected const activeRegularTagUuid = selectedTag instanceof SNTag ? selectedTag.uuid : undefined - await this.application.itemControllerGroup.createItemController({ + return this.application.itemControllerGroup.createItemController({ title, tag: activeRegularTagUuid, + createdAt, + autofocusBehavior, }) } - createNewNote = async () => { - this.notesController.unselectNotes() - - if (this.navigationController.isInSmartView() && !this.navigationController.isInHomeView()) { - await this.navigationController.selectHomeNavigationView() + titleForNewNote = (createdAt?: Date) => { + if (this.isFiltering) { + return this.noteFilterText } const titleFormat = this.navigationController.selected?.preferences?.newNoteTitleFormat || this.application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat]) - let title = formatDateAndTimeForNote(new Date()) - if (titleFormat === NewNoteTitleFormat.CurrentNoteCount) { - title = `Note ${this.notes.length + 1}` - } else if (titleFormat === NewNoteTitleFormat.CustomFormat) { + return `Note ${this.notes.length + 1}` + } + + if (titleFormat === NewNoteTitleFormat.CustomFormat) { const customFormat = this.navigationController.selected?.preferences?.customNoteTitleFormat || this.application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat]) - title = dayjs().format(customFormat) - } else if (titleFormat === NewNoteTitleFormat.Empty) { - title = '' + + return dayjs(createdAt).format(customFormat) } - if (this.isFiltering) { - title = this.noteFilterText + if (titleFormat === NewNoteTitleFormat.Empty) { + return '' } - await this.createNewNoteController(title) + return formatDateAndTimeForNote(createdAt || new Date()) + } + + createNewNote = async (title?: string, createdAt?: Date, autofocusBehavior?: TemplateNoteViewAutofocusBehavior) => { + this.notesController.unselectNotes() + + if (this.navigationController.isInSmartView() && !this.navigationController.isInHomeView()) { + await this.navigationController.selectHomeNavigationView() + } + + const useTitle = title || this.titleForNewNote(createdAt) + + const controller = await this.createNewNoteController(useTitle, createdAt, autofocusBehavior) this.linkingController.reloadAllLinks() + + this.selectionController.scrollToItem(controller.item) } createPlaceholderNote = () => { @@ -721,7 +749,7 @@ export class ItemListController this.application.itemControllerGroup.closeItemController(controller) } - handleTagChange = (userTriggered: boolean) => { + handleTagChange = async (userTriggered: boolean) => { const activeNoteController = this.getActiveItemController() if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) { this.closeItemController(activeNoteController) @@ -739,7 +767,7 @@ export class ItemListController this.reloadNotesDisplayOptions() - void this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange) + await this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange) } onFilterEnter = () => { diff --git a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts index a68e5d4c7..2b5e5d2a9 100644 --- a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts +++ b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts @@ -15,6 +15,7 @@ import { InternalEventBus, InternalEventPublishStrategy, VectorIconNameOrEmoji, + isTag, } from '@standardnotes/snjs' import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx' import { WebApplication } from '../../Application/Application' @@ -268,6 +269,13 @@ export class NavigationController return this.selected instanceof SmartView && this.selected.uuid === id } + public get selectedAsTag(): SNTag | undefined { + if (!this.selected || !isTag(this.selected)) { + return undefined + } + return this.selected + } + setAddingSubtagTo(tag: SNTag | undefined): void { this.addingSubtagTo = tag } @@ -440,19 +448,21 @@ export class NavigationController this.previouslySelected_ = this.selected_ - this.setSelectedTagInstance(tag) + await runInAction(async () => { + this.setSelectedTagInstance(tag) - if (tag && this.application.items.isTemplateItem(tag)) { - return - } + if (tag && this.application.items.isTemplateItem(tag)) { + return + } - await this.eventBus.publishSync( - { - type: CrossControllerEvent.TagChanged, - payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered }, - }, - InternalEventPublishStrategy.SEQUENCE, - ) + await this.eventBus.publishSync( + { + type: CrossControllerEvent.TagChanged, + payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered }, + }, + InternalEventPublishStrategy.SEQUENCE, + ) + }) } public async selectHomeNavigationView(): Promise { diff --git a/packages/web/src/javascripts/Controllers/SelectedItemsController.ts b/packages/web/src/javascripts/Controllers/SelectedItemsController.ts index 9f4c1c948..bf56d374b 100644 --- a/packages/web/src/javascripts/Controllers/SelectedItemsController.ts +++ b/packages/web/src/javascripts/Controllers/SelectedItemsController.ts @@ -287,18 +287,22 @@ export class SelectedItemsController item: { uuid: ListableContentItem['uuid'] }, - { userTriggered = false, scrollIntoView = true }, + { userTriggered = false, scrollIntoView = true, animated = true }, ): Promise => { const { didSelect } = await this.selectItem(item.uuid, userTriggered) if (didSelect && scrollIntoView) { - const itemElement = document.getElementById(item.uuid) - itemElement?.scrollIntoView({ - behavior: 'smooth', - }) + this.scrollToItem(item, animated) } } + scrollToItem = (item: { uuid: ListableContentItem['uuid'] }, animated = true): void => { + const itemElement = document.getElementById(item.uuid) + itemElement?.scrollIntoView({ + behavior: animated ? 'smooth' : 'auto', + }) + } + selectUuids = async (uuids: UuidString[], userTriggered = false) => { const itemsForUuids = this.application.items.findItems(uuids) if (itemsForUuids.length < 1) { diff --git a/packages/web/src/javascripts/Utils/DateUtils.spec.ts b/packages/web/src/javascripts/Utils/DateUtils.spec.ts new file mode 100644 index 000000000..5ceb74251 --- /dev/null +++ b/packages/web/src/javascripts/Utils/DateUtils.spec.ts @@ -0,0 +1,31 @@ +import { addDays } from '@/Utils/DateUtils' +import { numDaysBetweenDates } from './DateUtils' + +describe('date utils', () => { + describe('numDaysBetweenDates', () => { + it('should return full days diff accurately', () => { + const today = new Date() + + expect(numDaysBetweenDates(today, addDays(today, 1))).toEqual(1) + expect(numDaysBetweenDates(today, addDays(today, 2))).toEqual(2) + expect(numDaysBetweenDates(today, addDays(today, 3))).toEqual(3) + }) + + it('should return absolute value of difference', () => { + const today = new Date() + + expect(numDaysBetweenDates(today, addDays(today, 3))).toEqual(3) + expect(numDaysBetweenDates(addDays(today, 3), today)).toEqual(3) + }) + + it('should return 1 day difference between two dates on different days but 1 hour apart', () => { + const today = new Date() + const oneHourBeforeMidnight = new Date() + oneHourBeforeMidnight.setHours(0, 0, 0, 0) + oneHourBeforeMidnight.setHours(-1, 0, 0, 0) + + expect(today.toDateString()).not.toEqual(oneHourBeforeMidnight.toDateString()) + expect(numDaysBetweenDates(today, oneHourBeforeMidnight)).toEqual(1) + }) + }) +}) diff --git a/packages/web/src/javascripts/Utils/DateUtils.ts b/packages/web/src/javascripts/Utils/DateUtils.ts index a4076173f..8b8d637d7 100644 --- a/packages/web/src/javascripts/Utils/DateUtils.ts +++ b/packages/web/src/javascripts/Utils/DateUtils.ts @@ -12,13 +12,84 @@ export const formatDateForContextMenu = (date: Date | undefined) => { return `${date.toDateString()} ${date.toLocaleTimeString()}` } -export const formatDateAndTimeForNote = (date: Date) => { - return `${date.toLocaleDateString(undefined, { +export const formatDateAndTimeForNote = (date: Date, includeTime = true) => { + const dateString = `${date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'short', year: 'numeric', - })} at ${date.toLocaleTimeString(undefined, { - timeStyle: 'short', })}` + + if (includeTime) { + return `${dateString} at ${date.toLocaleTimeString(undefined, { + timeStyle: 'short', + })}` + } else { + return dateString + } +} + +export function numHoursBetweenDates(date1: Date, date2: Date): number { + return Math.abs(date1.getTime() - date2.getTime()) / 3600000 +} + +export function isDateInSameDay(date1: Date, date2: Date): boolean { + return date1.toLocaleDateString() === date2.toLocaleDateString() +} + +export function numDaysBetweenDates(date1: Date, date2: Date): number { + if (numHoursBetweenDates(date1, date2) < 24) { + const dayOfWeekDiffers = date1.toLocaleDateString() !== date2.toLocaleDateString() + if (dayOfWeekDiffers) { + return 1 + } + } + const diffInMs = date1.getTime() - date2.getTime() + const diffInDays = Math.abs(diffInMs / (1000 * 60 * 60 * 24)) + return Math.floor(diffInDays) +} + +export function addDays(date: Date, days: number) { + const result = new Date(date) + result.setDate(result.getDate() + days) + return result +} + +export function addMonths(date: Date, months: number) { + const result = new Date(date) + result.setMonth(result.getMonth() + months) + return result +} + +export function getWeekdayName(date: Date, format: 'long' | 'short'): string { + return date.toLocaleString('default', { weekday: format }) +} + +export function areDatesInSameMonth(date1: Date, date2: Date): boolean { + return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() +} + +export function numberOfMonthsBetweenDates(date1: Date, date2: Date, roundUpFractionalMonths = true) { + let startDate = date1 + let endDate = date2 + let inverse = false + + if (date1 > date2) { + startDate = date2 + endDate = date1 + inverse = true + } + + const yearsDifference = endDate.getFullYear() - startDate.getFullYear() + const monthsDifference = endDate.getMonth() - startDate.getMonth() + const daysDifference = endDate.getDate() - startDate.getDate() + + let monthCorrection = 0 + if (roundUpFractionalMonths === true && daysDifference > 0) { + monthCorrection = 1 + } else if (roundUpFractionalMonths !== true && daysDifference < 0) { + monthCorrection = -1 + } + + return (inverse ? -1 : 1) * (yearsDifference * 12 + monthsDifference + monthCorrection) } diff --git a/packages/web/src/stylesheets/_items-column.scss b/packages/web/src/stylesheets/_items-column.scss index 96a9bc570..d813495f3 100644 --- a/packages/web/src/stylesheets/_items-column.scss +++ b/packages/web/src/stylesheets/_items-column.scss @@ -146,7 +146,6 @@ &.selected { background-color: var(--item-cell-selected-background-color); - border-left: 2px solid var(--item-cell-selected-border-left-color); progress { background-color: var(--note-preview-selected-progress-background-color); diff --git a/packages/web/tailwind.config.js b/packages/web/tailwind.config.js index bfaa9d2a2..46c49a5ac 100644 --- a/packages/web/tailwind.config.js +++ b/packages/web/tailwind.config.js @@ -115,6 +115,7 @@ module.exports = { 'warning-contrast': 'var(--sn-stylekit-warning-contrast-color)', danger: 'var(--sn-stylekit-danger-color)', 'danger-contrast': 'var(--sn-stylekit-danger-contrast-color)', + 'danger-light': 'var(--sn-stylekit-danger-light-color, var(--sn-stylekit-danger-color))', default: 'var(--sn-stylekit-background-color)', foreground: 'var(--sn-stylekit-foreground-color)', contrast: 'var(--sn-stylekit-contrast-background-color)',