feat: daily notes (dev only) (#1894)

This commit is contained in:
Mo
2022-10-27 17:21:31 -05:00
committed by GitHub
parent 064e054587
commit 69c3f2be83
47 changed files with 1535 additions and 158 deletions

View File

@@ -28,6 +28,10 @@ export class SNTag extends DecryptedItem<TagContent> implements TagContentSpecia
this.preferences = this.payload.content.preferences this.preferences = this.payload.content.preferences
} }
get isDailyEntry(): boolean {
return this.preferences?.entryMode === 'daily'
}
get noteReferences(): ContentReference[] { get noteReferences(): ContentReference[] {
const references = this.payload.references const references = this.payload.references
return references.filter((ref) => ref.content_type === ContentType.Note) return references.filter((ref) => ref.content_type === ContentType.Note)

View File

@@ -16,4 +16,5 @@ export interface TagPreferences {
newNoteTitleFormat?: NewNoteTitleFormat newNoteTitleFormat?: NewNoteTitleFormat
customNoteTitleFormat?: string customNoteTitleFormat?: string
editorIdentifier?: FeatureIdentifier | string editorIdentifier?: FeatureIdentifier | string
entryMode?: 'normal' | 'daily'
} }

View File

@@ -11,6 +11,7 @@ import {
DeletedItemInterface, DeletedItemInterface,
ItemContent, ItemContent,
PredicateInterface, PredicateInterface,
DecryptedPayload,
} from '@standardnotes/models' } from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService' import { AbstractService } from '../Service/AbstractService'
@@ -93,6 +94,7 @@ export interface ItemManagerInterface extends AbstractService {
>( >(
contentType: ContentType, contentType: ContentType,
content?: C, content?: C,
override?: Partial<DecryptedPayload<C>>,
): I ): I
/** /**

View File

@@ -3,6 +3,7 @@ import {
BackupFile, BackupFile,
DecryptedItemInterface, DecryptedItemInterface,
DecryptedItemMutator, DecryptedItemMutator,
DecryptedPayload,
EncryptedItemInterface, EncryptedItemInterface,
FileItem, FileItem,
ItemContent, ItemContent,
@@ -117,6 +118,7 @@ export interface MutatorClientInterface {
>( >(
contentType: ContentType, contentType: ContentType,
content?: C, content?: C,
override?: Partial<DecryptedPayload<C>>,
): I ): I
/** /**

View File

@@ -28,6 +28,7 @@ describe('note view controller', () => {
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith( expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note, ContentType.Note,
expect.objectContaining({ noteType: NoteType.Plain }), expect.objectContaining({ noteType: NoteType.Plain }),
expect.anything(),
) )
}) })
}) })

View File

@@ -34,7 +34,6 @@ export class NoteViewController implements ItemViewControllerInterface {
private removeStreamObserver?: () => void private removeStreamObserver?: () => void
public isTemplateNote = false public isTemplateNote = false
private saveTimeout?: ReturnType<typeof setTimeout> private saveTimeout?: ReturnType<typeof setTimeout>
private defaultTitle: string | undefined
private defaultTagUuid: UuidString | undefined private defaultTagUuid: UuidString | undefined
private defaultTag?: SNTag private defaultTag?: SNTag
public runtimeId = `${Math.random()}` public runtimeId = `${Math.random()}`
@@ -42,14 +41,13 @@ export class NoteViewController implements ItemViewControllerInterface {
constructor( constructor(
private application: SNApplication, private application: SNApplication,
item?: SNNote, item?: SNNote,
templateNoteOptions?: TemplateNoteViewControllerOptions, public templateNoteOptions?: TemplateNoteViewControllerOptions,
) { ) {
if (item) { if (item) {
this.item = item this.item = item
} }
if (templateNoteOptions) { if (templateNoteOptions) {
this.defaultTitle = templateNoteOptions.title
this.defaultTagUuid = templateNoteOptions.tag this.defaultTagUuid = templateNoteOptions.tag
} }
@@ -80,13 +78,19 @@ export class NoteViewController implements ItemViewControllerInterface {
? this.application.componentManager.componentWithIdentifier(editorIdentifier) ? this.application.componentManager.componentWithIdentifier(editorIdentifier)
: undefined : undefined
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(ContentType.Note, { const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(
text: '', ContentType.Note,
title: this.defaultTitle || '', {
noteType: defaultEditor?.noteType || NoteType.Plain, text: '',
editorIdentifier: editorIdentifier, title: this.templateNoteOptions?.title || '',
references: [], noteType: defaultEditor?.noteType || NoteType.Plain,
}) editorIdentifier: editorIdentifier,
references: [],
},
{
created_at: this.templateNoteOptions?.createdAt || new Date(),
},
)
this.isTemplateNote = true this.isTemplateNote = true
this.item = note this.item = note
@@ -109,6 +113,10 @@ export class NoteViewController implements ItemViewControllerInterface {
} }
private streamItems() { private streamItems() {
if (this.dealloced) {
return
}
this.removeStreamObserver = this.application.streamItems<SNNote>( this.removeStreamObserver = this.application.streamItems<SNNote>(
ContentType.Note, ContentType.Note,
({ changed, inserted, source }) => { ({ changed, inserted, source }) => {

View File

@@ -3,4 +3,8 @@ import { UuidString } from '@Lib/Types/UuidString'
export type TemplateNoteViewControllerOptions = { export type TemplateNoteViewControllerOptions = {
title?: string title?: string
tag?: UuidString tag?: UuidString
createdAt?: Date
autofocusBehavior?: TemplateNoteViewAutofocusBehavior
} }
export type TemplateNoteViewAutofocusBehavior = 'title' | 'editor'

View File

@@ -3,6 +3,6 @@ export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN =
export const STRING_INVALID_NOTE = 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." "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 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_DEBOUNCE = 350
export const SAVE_TIMEOUT_NO_DEBOUNCE = 100 export const SAVE_TIMEOUT_NO_DEBOUNCE = 100

View File

@@ -3,3 +3,4 @@ export * from './NoteViewController'
export * from './FileViewController' export * from './FileViewController'
export * from './ItemGroupController' export * from './ItemGroupController'
export * from './ReactNativeToWebEvent' export * from './ReactNativeToWebEvent'
export * from './TemplateNoteViewControllerOptions'

View File

@@ -805,12 +805,13 @@ export class ItemManager
public createTemplateItem< public createTemplateItem<
C extends Models.ItemContent = Models.ItemContent, C extends Models.ItemContent = Models.ItemContent,
I extends Models.DecryptedItemInterface<C> = Models.DecryptedItemInterface<C>, I extends Models.DecryptedItemInterface<C> = Models.DecryptedItemInterface<C>,
>(contentType: ContentType, content?: C): I { >(contentType: ContentType, content?: C, override?: Partial<Models.DecryptedPayload<C>>): I {
const payload = new Models.DecryptedPayload<C>({ const payload = new Models.DecryptedPayload<C>({
uuid: UuidGenerator.GenerateUuid(), uuid: UuidGenerator.GenerateUuid(),
content_type: contentType, content_type: contentType,
content: Models.FillItemContent<C>(content || {}), content: Models.FillItemContent<C>(content || {}),
...Models.PayloadTimestampDefaults(), ...Models.PayloadTimestampDefaults(),
...override,
}) })
const item = Models.CreateDecryptedItemFromPayload<C, I>(payload) const item = Models.CreateDecryptedItemFromPayload<C, I>(payload)
return item return item

View File

@@ -29,6 +29,7 @@ import {
CreateEncryptedBackupFileContextPayload, CreateEncryptedBackupFileContextPayload,
DecryptedItemInterface, DecryptedItemInterface,
DecryptedItemMutator, DecryptedItemMutator,
DecryptedPayload,
DecryptedPayloadInterface, DecryptedPayloadInterface,
EncryptedItemInterface, EncryptedItemInterface,
FileItem, FileItem,
@@ -221,8 +222,8 @@ export class MutatorService extends AbstractService implements MutatorClientInte
public createTemplateItem< public createTemplateItem<
C extends ItemContent = ItemContent, C extends ItemContent = ItemContent,
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>, I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(contentType: ContentType, content?: C): I { >(contentType: ContentType, content?: C, override?: Partial<DecryptedPayload<C>>): I {
return this.itemManager.createTemplateItem(contentType, content) return this.itemManager.createTemplateItem(contentType, content, override)
} }
public async setItemNeedsSync( public async setItemNeedsSync(

View File

@@ -15,6 +15,7 @@
--sn-stylekit-danger-color: #cc2128; --sn-stylekit-danger-color: #cc2128;
--sn-stylekit-danger-contrast-color: #ffffff; --sn-stylekit-danger-contrast-color: #ffffff;
--sn-stylekit-danger-light-color: #f9e4e5;
--sn-stylekit-shadow-color: #c8c8c8; --sn-stylekit-shadow-color: #c8c8c8;
--sn-stylekit-background-color: #ffffff; --sn-stylekit-background-color: #ffffff;

View File

@@ -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<Props> = ({ 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 (
<div className={`w-300 ${className} border-left border-right border border-neutral`}>
<div className="mr-auto ml-auto w-70">
<div className="flex w-full flex-wrap">
{CalendarDaysOfTheWeek.map((d) => (
<div className={'flex h-8 w-[14.2%] items-center justify-center'} key={d}>
{d}
</div>
))}
</div>
<div className="flex w-full flex-wrap">
{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 (
<CalendarDay
key={index}
day={d}
isToday={isDateInSameDay(date, today)}
activities={activities}
onClick={() => onDateSelect(date)}
hasPendingEntry={selectedDay && isDateInSameDay(selectedDay, date)}
/>
)
})}
</div>
</div>
</div>
)
}
export default Calendar

View File

@@ -0,0 +1,7 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
export type CalendarActivityType = 'created' | 'edited'
export type CalendarActivity = {
date: Date
item: ListableContentItem
}

View File

@@ -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<Props> = ({ 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 (
<div className="h-7 w-[14.2%] p-0.5">
<div
className={`${
!hasActivity && !isToday ? defaultClassNames : ''
} flex h-full w-full cursor-pointer items-center justify-center rounded ${
isToday ? todayClassNames : hasActivity ? hasActivityClassNames : ''
} ${hasPendingEntry ? hasPendingEntryNames : ''}`}
key={day}
onClick={onClick}
>
{day > 0 ? day : ''}
</div>
</div>
)
}
export default CalendarDay

View File

@@ -0,0 +1,4 @@
export type CalendarMonth = {
/** Any date in the month */
date: Date
}

View File

@@ -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))
})
})

View File

@@ -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<DateOnlyString, CalendarActivity[]>
export function createActivityRecord(activities: CalendarActivity[]): ActivityRecord {
const map: Record<string, CalendarActivity[]> = {}
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)
}
}

View File

@@ -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',
]

View File

@@ -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<InfiniteCalendarInterface, Props>(
({ 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<CalendarMonth[]>(() => {
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<HTMLDivElement | null>(null)
const [lastElement, setLastElement] = useState<HTMLDivElement | null>(null)
const [didPaginateLeft, setDidPaginateLeft] = useState(false)
const [restoreScrollAfterExpand, setRestoreScrollAfterExpand] = useState(false)
const scrollArea = useRef<HTMLDivElement>(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 (
<div className="w-full flex-shrink-0 border-b border-solid border-border">
<div
className="text-md flex cursor-pointer items-center justify-center py-2 px-4 text-center font-bold hover:bg-contrast"
onClick={toggleVisibility}
>
{CalendarMonths[month]} {year}
</div>
{expanded && (
<div
style={{ scrollBehavior: 'smooth' }}
ref={scrollArea}
id="calendar-scroller"
className="flex w-full overflow-x-scroll pb-2 md:max-w-full"
>
{months.map((month, index) => {
const isFirst = index === 0
const isLast = index === months.length - 1
const id = elementIdForMonth(month.date)
return (
<div
id={id}
key={id}
ref={(ref) => {
isFirst ? setFirstElement(ref) : isLast ? setLastElement(ref) : null
if (ref) {
visibilityObserver.observe(ref)
}
}}
>
<Calendar
key={id}
className="mx-2"
activities={activities}
onDateSelect={handleDaySelection}
startDate={month.date}
selectedDay={selectedTemplateDay}
/>
</div>
)
})}
</div>
)}
</div>
)
},
)
export default InfiniteCalendar

View File

@@ -12,6 +12,7 @@ import { NavigationController } from '@/Controllers/Navigation/NavigationControl
import { NotesController } from '@/Controllers/NotesController' import { NotesController } from '@/Controllers/NotesController'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { SNTag } from '@standardnotes/snjs'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -64,6 +65,33 @@ const ContentList: FunctionComponent<Props> = ({
[selectNextItem, selectPreviousItem], [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 ( return (
<div <div
className={classNames( className={classNames(
@@ -88,8 +116,8 @@ const ContentList: FunctionComponent<Props> = ({
hideIcon={hideEditorIcon} hideIcon={hideEditorIcon}
sortBy={sortBy} sortBy={sortBy}
filesController={filesController} filesController={filesController}
selectionController={selectionController} onSelect={selectItem}
navigationController={navigationController} tags={getTagsForItem(item)}
notesController={notesController} notesController={notesController}
/> />
))} ))}

View File

@@ -1,35 +1,15 @@
import { ContentType, SNTag } from '@standardnotes/snjs' import { ContentType } from '@standardnotes/snjs'
import { FunctionComponent } from 'react' import { FunctionComponent } from 'react'
import FileListItem from './FileListItem' import FileListItem from './FileListItem'
import NoteListItem from './NoteListItem' import NoteListItem from './NoteListItem'
import { AbstractListItemProps } from './Types/AbstractListItemProps' import { AbstractListItemProps } from './Types/AbstractListItemProps'
const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => { const ContentListItem: FunctionComponent<AbstractListItemProps> = (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) { switch (props.item.content_type) {
case ContentType.Note: case ContentType.Note:
return <NoteListItem tags={getTags()} {...props} /> return <NoteListItem {...props} />
case ContentType.File: case ContentType.File:
return <FileListItem tags={getTags()} {...props} /> return <FileListItem {...props} />
default: default:
return null return null
} }

View File

@@ -26,6 +26,8 @@ import { classNames } from '@/Utils/ConcatenateClassNames'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider' import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import DailyContentList from './Daily/DailyContentList'
import { ListableContentItem } from './Types/ListableContentItem'
type Props = { type Props = {
accountMenuController: AccountMenuController accountMenuController: AccountMenuController
@@ -106,12 +108,13 @@ const ContentListView: FunctionComponent<Props> = ({
panelTitle, panelTitle,
panelWidth, panelWidth,
renderedItems, renderedItems,
items,
searchBarElement, searchBarElement,
} = itemListController } = itemListController
const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController
const { selected: selectedTag } = navigationController const { selected: selectedTag, selectedAsTag } = navigationController
const icon = selectedTag?.iconString const icon = selectedTag?.iconString
@@ -227,6 +230,19 @@ const ContentListView: FunctionComponent<Props> = ({
const matchesXLBreakpoint = useMediaQuery(MediaQueryBreakpoints.xl) const matchesXLBreakpoint = useMediaQuery(MediaQueryBreakpoints.xl)
const isTabletScreenSize = matchesMediumBreakpoint && !matchesXLBreakpoint 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 ( return (
<div <div
id="items-column" id="items-column"
@@ -279,20 +295,35 @@ const ContentListView: FunctionComponent<Props> = ({
/> />
</div> </div>
</div> </div>
{completedFullSync && !renderedItems.length ? <p className="empty-items-list opacity-50">No items.</p> : null} {selectedAsTag && dailyMode && (
{!completedFullSync && !renderedItems.length ? <p className="empty-items-list opacity-50">Loading...</p> : null} <DailyContentList
{renderedItems.length ? ( items={items}
<ContentList selectedTag={selectedAsTag}
items={renderedItems}
selectedUuids={selectedUuids} selectedUuids={selectedUuids}
application={application}
paginate={paginate}
filesController={filesController}
itemListController={itemListController} itemListController={itemListController}
navigationController={navigationController} onSelect={handleDailyListSelection}
notesController={notesController}
selectionController={selectionController}
/> />
)}
{!dailyMode && renderedItems.length ? (
<>
{completedFullSync && !renderedItems.length ? (
<p className="empty-items-list opacity-50">No items.</p>
) : null}
{!completedFullSync && !renderedItems.length ? (
<p className="empty-items-list opacity-50">Loading...</p>
) : null}
<ContentList
items={renderedItems}
selectedUuids={selectedUuids}
application={application}
paginate={paginate}
filesController={filesController}
itemListController={itemListController}
navigationController={navigationController}
notesController={notesController}
selectionController={selectionController}
/>
</>
) : null} ) : null}
<div className="absolute bottom-0 h-safe-bottom w-full" /> <div className="absolute bottom-0 h-safe-bottom w-full" />
</ResponsivePaneContent> </ResponsivePaneContent>

View File

@@ -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<string, ListableContentItem[]> = {}
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
}

View File

@@ -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<void>
selectedTag: SNTag
selectedUuids: SelectedItemsController['selectedUuids']
}
const PageSize = 10
const DailyContentList: FunctionComponent<Props> = ({
items,
itemListController,
onSelect,
selectedUuids,
selectedTag,
}) => {
const { toggleAppPane } = useResponsiveAppPane()
const [needsSelectionReload, setNeedsSelectionReload] = useState(false)
const [todayItem, setTodayItem] = useState<DailyItemsDay>()
const [selectedDay, setSelectedDay] = useState<Date>()
const [lastElement, setLastElement] = useState<HTMLDivElement | null>(null)
const [firstElement, setFirstElement] = useState<HTMLDivElement | null>(null)
const [lastScrollHeight, setLastScrollHeight] = useState(0)
const [didPaginateTop, setDidPaginateTop] = useState(false)
const scrollArea = useRef<HTMLDivElement>(null)
const calendarRef = useRef<InfiniteCalendarInterface | null>(null)
const [dailyItems, setDailyItems] = useState<DailyItemsDay[]>(() => {
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 (
<>
<InfiniteCalendar
activities={calendarActivities}
activityType={'created'}
onDateSelect={onCalendarSelect}
selectedTemplateDay={selectedDay}
selectedItemDay={selectedDay}
ref={calendarRef}
/>
<div
className={classNames(
'infinite-scroll overflow-y-auto overflow-x-hidden focus:shadow-none focus:outline-none',
'md:max-h-full md:overflow-y-hidden md:hover:overflow-y-auto pointer-coarse:md:overflow-y-auto',
'md:hover:[overflow-y:_overlay]',
)}
ref={scrollArea}
id={ElementIds.ContentList}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{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) => (
<DailyItemCell
selected={selectedUuids.has(item.uuid)}
section={dailyItem}
key={item.uuid}
id={dailyItem.id}
item={item}
hideDate={hideDate}
hidePreview={hideNotePreview}
hideTags={hideTags}
onClick={() => onClickItem(dailyItem, item, true)}
ref={(ref) => {
isLast ? setLastElement(ref) : isFirst ? setFirstElement(ref) : null
if (ref) {
visibilityObserver.observe(ref)
}
}}
/>
))
} else {
return (
<DailyItemCell
selected={selectedDay && dailyItem.id === dateToDailyDayIdentifier(selectedDay)}
section={dailyItem}
id={dailyItem.id}
key={dailyItem.dateKey}
onClick={() => onClickTemplate(dailyItem.date)}
ref={(ref) => {
isLast ? setLastElement(ref) : isFirst ? setFirstElement(ref) : null
if (ref) {
visibilityObserver.observe(ref)
}
}}
/>
)
}
})}
</div>
</>
)
}
export default DailyContentList

View File

@@ -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<DaySquareProps> = ({ day, hasActivity, weekday }) => {
return (
<div className="mr-5">
<div
className={`${
hasActivity ? 'bg-danger text-danger-contrast' : 'bg-neutral text-neutral-contrast'
} h-15 w-18 rounded p-2 text-center`}
>
<div className="text-sm font-bold">{weekday}</div>
<div className="text-4xl font-bold">{day}</div>
</div>
</div>
)
}
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<HTMLDivElement>,
) => {
return (
<div
ref={ref}
onClick={onClick}
className={`content-list-item flex w-full cursor-pointer items-stretch text-text ${
selected && 'selected border-l-2 border-solid border-danger'
}`}
id={section.id}
>
<div className="min-w-0 flex-grow border-b border-solid border-border py-4 px-4">
<div className="flex items-start overflow-hidden text-base">
<DaySquare weekday={section.weekday} hasActivity={item != undefined} day={section.day} />
<div className="w-full leading-[1.3]">
{item && (
<>
<ListItemTitle item={item} />
<ListItemNotePreviewText hidePreview={hidePreview} item={item} lineLimit={5} />
<ListItemMetadata item={item} hideDate={hideDate} sortBy={'created_at'} />
<ListItemTags hideTags={hideTags} tags={tags} />
</>
)}
{!item && (
<div className="w-full">
<div className="break-word mr-2 font-semibold">{formatDateAndTimeForNote(section.date, false)}</div>
<EmptyPlaceholderBars rows={4} />
</div>
)}
</div>
</div>
</div>
{item && <ListItemFlagIcons item={item} hasFiles={false} />}
</div>
)
},
)

View File

@@ -0,0 +1,8 @@
export type DailyItemsDay = {
dateKey: string
day: number
weekday: string
date: Date
isToday: boolean
id: string
}

View File

@@ -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<Props> = ({ rows }) => {
const [barWidths, setBarWidths] = useState<number[]>([])
const [animationInterval, setAnimationInterval] = useState<ReturnType<typeof setTimeout> | 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 (
<div onMouseEnter={onHoverEnter} onMouseLeave={onHoverExit} className="w-full">
{barWidths.map((width, index) => {
return (
<div
style={{ width: `${width}%` }}
key={index}
className={
'transition-slowest ease my-4 h-7 bg-passive-4-opacity-variant pb-3 transition-width duration-1000'
}
></div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,3 @@
export function dateToDailyDayIdentifier(date: Date): string {
return date.toLocaleDateString()
}

View File

@@ -14,11 +14,11 @@ import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
const FileListItem: FunctionComponent<DisplayableListItemProps> = ({ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
application, application,
filesController, filesController,
selectionController,
hideDate, hideDate,
hideIcon, hideIcon,
hideTags, hideTags,
item, item,
onSelect,
selected, selected,
sortBy, sortBy,
tags, tags,
@@ -44,7 +44,7 @@ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
let shouldOpenContextMenu = selected let shouldOpenContextMenu = selected
if (!selected) { if (!selected) {
const { didSelect } = await selectionController.selectItem(item.uuid) const { didSelect } = await onSelect(item)
if (didSelect) { if (didSelect) {
shouldOpenContextMenu = true shouldOpenContextMenu = true
} }
@@ -54,15 +54,15 @@ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
openFileContextMenu(posX, posY) openFileContextMenu(posX, posY)
} }
}, },
[selected, selectionController, item.uuid, openFileContextMenu], [selected, onSelect, item, openFileContextMenu],
) )
const onClick = useCallback(async () => { const onClick = useCallback(async () => {
const { didSelect } = await selectionController.selectItem(item.uuid, true) const { didSelect } = await onSelect(item, true)
if (didSelect) { if (didSelect) {
toggleAppPane(AppPaneId.Editor) toggleAppPane(AppPaneId.Editor)
} }
}, [item.uuid, selectionController, toggleAppPane]) }, [item, onSelect, toggleAppPane])
const IconComponent = () => const IconComponent = () =>
getFileIconComponent( getFileIconComponent(

View File

@@ -5,7 +5,7 @@ import { classNames } from '@/Utils/ConcatenateClassNames'
import Popover from '@/Components/Popover/Popover' import Popover from '@/Components/Popover/Popover'
import DisplayOptionsMenu from './DisplayOptionsMenu' import DisplayOptionsMenu from './DisplayOptionsMenu'
import { NavigationMenuButton } from '@/Components/NavigationMenu/NavigationMenu' import { NavigationMenuButton } from '@/Components/NavigationMenu/NavigationMenu'
import { IconType } from '@standardnotes/snjs' import { IconType, isTag } from '@standardnotes/snjs'
import RoundIconButton from '@/Components/Button/RoundIconButton' import RoundIconButton from '@/Components/Button/RoundIconButton'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType' import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
@@ -32,6 +32,7 @@ const ContentListHeader = ({
}: Props) => { }: Props) => {
const displayOptionsContainerRef = useRef<HTMLDivElement>(null) const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null) const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
const isDailyEntry = isTag(selectedTag) && selectedTag.isDailyEntry
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false) const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
@@ -83,7 +84,13 @@ const ContentListHeader = ({
</Popover> </Popover>
</div> </div>
<button <button
className="absolute bottom-6 right-6 z-editor-title-bar ml-3 flex h-13 w-13 cursor-pointer items-center justify-center rounded-full border border-solid border-transparent bg-info text-info-contrast hover:brightness-125 md:static md:h-8 md:w-8" className={classNames(
'absolute bottom-6 right-6 z-editor-title-bar ml-3 flex h-13 w-13 cursor-pointer items-center',
`justify-center rounded-full border border-solid border-transparent ${
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
}`,
'hover:brightness-125 md:static md:h-8 md:w-8',
)}
title={addButtonLabel} title={addButtonLabel}
aria-label={addButtonLabel} aria-label={addButtonLabel}
onClick={addNewItem} onClick={addNewItem}

View File

@@ -23,6 +23,9 @@ import { PreferenceMode } from './PreferenceMode'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
import Button from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { isDev } from '@/Utils'
const DailyEntryModeEnabled = isDev
const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
closeDisplayOptionsMenu, closeDisplayOptionsMenu,
@@ -36,6 +39,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
const [preferences, setPreferences] = useState<TagPreferences>({}) const [preferences, setPreferences] = useState<TagPreferences>({})
const hasSubscription = application.hasValidSubscription() const hasSubscription = application.hasValidSubscription()
const controlsDisabled = currentMode === 'tag' && !hasSubscription const controlsDisabled = currentMode === 'tag' && !hasSubscription
const isDailyEntry = selectedTag.preferences?.entryMode === 'daily'
const reloadPreferences = useCallback(() => { const reloadPreferences = useCallback(() => {
const globalValues: TagPreferences = { const globalValues: TagPreferences = {
@@ -162,6 +166,13 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
void changePreferences({ hideEditorIcon: !preferences.hideEditorIcon }) void changePreferences({ hideEditorIcon: !preferences.hideEditorIcon })
}, [preferences, changePreferences]) }, [preferences, changePreferences])
const setEntryMode = useCallback(
(mode: 'normal' | 'daily') => {
void changePreferences({ entryMode: mode })
},
[changePreferences],
)
const TabButton: FunctionComponent<{ const TabButton: FunctionComponent<{
label: string label: string
mode: PreferenceMode mode: PreferenceMode
@@ -230,7 +241,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
<div className="my-1 px-3 text-xs font-semibold uppercase text-text">Sort by</div> <div className="my-1 px-3 text-xs font-semibold uppercase text-text">Sort by</div>
<MenuItem <MenuItem
disabled={controlsDisabled} disabled={controlsDisabled || isDailyEntry}
className="py-2" className="py-2"
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified} onClick={toggleSortByDateModified}
@@ -248,7 +259,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
</div> </div>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={controlsDisabled} disabled={controlsDisabled || isDailyEntry}
className="py-2" className="py-2"
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate} onClick={toggleSortByCreationDate}
@@ -266,7 +277,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
</div> </div>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={controlsDisabled} disabled={controlsDisabled || isDailyEntry}
className="py-2" className="py-2"
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={toggleSortByTitle} onClick={toggleSortByTitle}
@@ -362,6 +373,33 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
Show trashed Show trashed
</MenuItem> </MenuItem>
{currentMode === 'tag' && DailyEntryModeEnabled && (
<>
<MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold uppercase text-text">Entry Mode</div>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={() => setEntryMode('normal')}
checked={!selectedTag.preferences?.entryMode || selectedTag.preferences?.entryMode === 'normal'}
>
<div className="ml-2 flex flex-grow items-center justify-between">Normal</div>
</MenuItem>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={() => setEntryMode('daily')}
checked={isDailyEntry}
>
<div className="ml-2 flex flex-grow items-center justify-between">Daily</div>
</MenuItem>
</>
)}
<MenuItemSeparator /> <MenuItemSeparator />
<NewNotePreferences <NewNotePreferences

View File

@@ -0,0 +1,36 @@
import { sanitizeHtmlString } from '@standardnotes/snjs'
import { FunctionComponent } from 'react'
import { ListableContentItem } from './Types/ListableContentItem'
type Props = {
item: ListableContentItem
hidePreview: boolean
lineLimit?: number
}
const ListItemNotePreviewText: FunctionComponent<Props> = ({ item, hidePreview, lineLimit = 1 }) => {
if (item.hidePreview || item.protected || hidePreview) {
return null
}
return (
<div className={`overflow-hidden overflow-ellipsis text-sm ${item.archived ? 'opacity-60' : ''}`}>
{item.preview_html && (
<div
className="my-1"
dangerouslySetInnerHTML={{
__html: sanitizeHtmlString(item.preview_html),
}}
></div>
)}
{!item.preview_html && item.preview_plain && (
<div className={`leading-1.3 line-clamp-${lineLimit} mt-1 overflow-hidden`}>{item.preview_plain}</div>
)}
{!item.preview_html && !item.preview_plain && item.text && (
<div className={`leading-1.3 line-clamp-${lineLimit} mt-1 overflow-hidden`}>{item.text}</div>
)}
</div>
)
}
export default ListItemNotePreviewText

View File

@@ -0,0 +1,10 @@
import { FunctionComponent } from 'react'
import { ListableContentItem } from './Types/ListableContentItem'
export const ListItemTitle: FunctionComponent<{ item: ListableContentItem }> = ({ item }) => {
return (
<div className="flex items-start justify-between overflow-hidden text-base font-semibold leading-[1.3]">
<div className={`break-word mr-2 ${item.archived ? 'opacity-60' : ''}`}>{item.title}</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import { isFile, sanitizeHtmlString, SNNote } from '@standardnotes/snjs' import { isFile, SNNote } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useRef } from 'react' import { FunctionComponent, useCallback, useRef } from 'react'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
@@ -11,11 +11,13 @@ import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider' import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata' import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent' import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
import ListItemNotePreviewText from './ListItemNotePreviewText'
import { ListItemTitle } from './ListItemTitle'
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
application, application,
notesController, notesController,
selectionController, onSelect,
hideDate, hideDate,
hideIcon, hideIcon,
hideTags, hideTags,
@@ -48,7 +50,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
let shouldOpenContextMenu = selected let shouldOpenContextMenu = selected
if (!selected) { if (!selected) {
const { didSelect } = await selectionController.selectItem(item.uuid) const { didSelect } = await onSelect(item)
if (didSelect) { if (didSelect) {
shouldOpenContextMenu = true shouldOpenContextMenu = true
} }
@@ -60,11 +62,11 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
} }
const onClick = useCallback(async () => { const onClick = useCallback(async () => {
const { didSelect } = await selectionController.selectItem(item.uuid, true) const { didSelect } = await onSelect(item, true)
if (didSelect) { if (didSelect) {
toggleAppPane(AppPaneId.Editor) toggleAppPane(AppPaneId.Editor)
} }
}, [item.uuid, selectionController, toggleAppPane]) }, [item, onSelect, toggleAppPane])
useContextMenuEvent(listItemRef, openContextMenu) useContextMenuEvent(listItemRef, openContextMenu)
@@ -72,7 +74,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
<div <div
ref={listItemRef} ref={listItemRef}
className={`content-list-item flex w-full cursor-pointer items-stretch text-text ${ className={`content-list-item flex w-full cursor-pointer items-stretch text-text ${
selected && 'selected border-l-2 border-solid border-info' selected && `selected border-l-2 border-solid border-accessory-tint-${tint}`
}`} }`}
id={item.uuid} id={item.uuid}
onClick={onClick} onClick={onClick}
@@ -85,27 +87,8 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
<div className="pr-4" /> <div className="pr-4" />
)} )}
<div className="min-w-0 flex-grow border-b border-solid border-border py-4 px-0"> <div className="min-w-0 flex-grow border-b border-solid border-border py-4 px-0">
<div className="flex items-start justify-between overflow-hidden text-base font-semibold leading-[1.3]"> <ListItemTitle item={item} />
<div className="break-word mr-2">{item.title}</div> <ListItemNotePreviewText item={item} hidePreview={hidePreview} />
</div>
{!hidePreview && !item.hidePreview && !item.protected && (
<div className="overflow-hidden overflow-ellipsis text-sm">
{item.preview_html && (
<div
className="my-1"
dangerouslySetInnerHTML={{
__html: sanitizeHtmlString(item.preview_html),
}}
></div>
)}
{!item.preview_html && item.preview_plain && (
<div className="leading-1.3 line-clamp-1 mt-1 overflow-hidden">{item.preview_plain}</div>
)}
{!item.preview_html && !item.preview_plain && item.text && (
<div className="leading-1.3 line-clamp-1 mt-1 overflow-hidden">{item.text}</div>
)}
</div>
)}
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} /> <ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} /> <ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} /> <ListItemConflictIndicator item={item} />

View File

@@ -1,17 +1,14 @@
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { FilesController } from '@/Controllers/FilesController' import { FilesController } from '@/Controllers/FilesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController' import { NotesController } from '@/Controllers/NotesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController' import { SortableItem, SNTag } from '@standardnotes/snjs'
import { SortableItem } from '@standardnotes/snjs'
import { ListableContentItem } from './ListableContentItem' import { ListableContentItem } from './ListableContentItem'
export type AbstractListItemProps = { export type AbstractListItemProps = {
application: WebApplication application: WebApplication
filesController: FilesController filesController: FilesController
selectionController: SelectedItemsController
navigationController: NavigationController
notesController: NotesController notesController: NotesController
onSelect: (item: ListableContentItem, userTriggered?: boolean) => Promise<{ didSelect: boolean }>
hideDate: boolean hideDate: boolean
hideIcon: boolean hideIcon: boolean
hideTags: boolean hideTags: boolean
@@ -19,4 +16,5 @@ export type AbstractListItemProps = {
item: ListableContentItem item: ListableContentItem
selected: boolean selected: boolean
sortBy: keyof SortableItem | undefined sortBy: keyof SortableItem | undefined
tags: SNTag[]
} }

View File

@@ -89,8 +89,8 @@ const FileMenuOptions: FunctionComponent<Props> = ({
void filesController.setProtectionForFiles(hasProtectedFiles, selectionController.selectedFiles) void filesController.setProtectionForFiles(hasProtectedFiles, selectionController.selectedFiles)
}} }}
> >
<Icon type="password" className="mr-2 text-neutral" /> <Icon type="lock" className="mr-2 text-neutral" />
Password protection Password protect
</MenuItem> </MenuItem>
<HorizontalSeparator classes="my-1" /> <HorizontalSeparator classes="my-1" />
<MenuItem <MenuItem

View File

@@ -49,8 +49,8 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
}} }}
> >
<span className="flex items-center"> <span className="flex items-center">
<Icon type="password" className="mr-2 text-neutral" /> <Icon type="lock" className="mr-2 text-neutral" />
Password protection Password protect
</span> </span>
<Switch className="pointer-events-none px-0" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} checked={isFileProtected} /> <Switch className="pointer-events-none px-0" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} checked={isFileProtected} />
</button> </button>

View File

@@ -122,6 +122,9 @@ class NoteView extends PureComponent<NoteViewProps, State> {
this.controller = props.controller this.controller = props.controller
this.onEditorComponentLoad = () => { this.onEditorComponentLoad = () => {
if (!this.controller || this.controller.dealloced) {
return
}
this.application.getDesktopService()?.redoSearch() this.application.getDesktopService()?.redoSearch()
} }
@@ -233,7 +236,11 @@ class NoteView extends PureComponent<NoteViewProps, State> {
if (this.controller.isTemplateNote) { if (this.controller.isTemplateNote) {
setTimeout(() => { setTimeout(() => {
this.focusTitle() if (this.controller.templateNoteOptions?.autofocusBehavior === 'editor') {
this.focusEditor()
} else {
this.focusTitle()
}
}) })
} }
} }
@@ -920,6 +927,10 @@ class NoteView extends PureComponent<NoteViewProps, State> {
} }
override render() { override render() {
if (this.controller.dealloced) {
return null
}
if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) { if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) {
return ( return (
<ProtectedItemOverlay <ProtectedItemOverlay

View File

@@ -318,7 +318,7 @@ const NotesOptions = ({
}} }}
> >
<span className="flex items-center"> <span className="flex items-center">
<Icon type="password" className={iconClass} /> <Icon type="lock" className={iconClass} />
Password protect Password protect
</span> </span>
<Switch className="px-0" checked={protect} /> <Switch className="px-0" checked={protect} />

View File

@@ -20,6 +20,8 @@ import {
WebAppEvent, WebAppEvent,
NewNoteTitleFormat, NewNoteTitleFormat,
useBoolean, useBoolean,
TemplateNoteViewAutofocusBehavior,
isTag,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application' import { WebApplication } from '../../Application/Application'
@@ -165,18 +167,20 @@ export class ItemListController
this.disposers.push( this.disposers.push(
application.addEventObserver(async () => { application.addEventObserver(async () => {
void this.reloadItems(ItemsReloadSource.SyncEvent).then(() => { if (!this.completedFullSync) {
if ( void this.reloadItems(ItemsReloadSource.SyncEvent).then(() => {
this.notes.length === 0 && if (
this.navigationController.selected instanceof SmartView && this.notes.length === 0 &&
this.navigationController.selected.uuid === SystemViewId.AllNotes && this.navigationController.selected instanceof SmartView &&
this.noteFilterText === '' && this.navigationController.selected.uuid === SystemViewId.AllNotes &&
!this.getActiveItemController() this.noteFilterText === '' &&
) { !this.getActiveItemController()
this.createPlaceholderNote()?.catch(console.error) ) {
} this.createPlaceholderNote()?.catch(console.error)
}) }
this.setCompletedFullSync(true) })
this.setCompletedFullSync(true)
}
}, ApplicationEvent.CompletedFullSync), }, ApplicationEvent.CompletedFullSync),
) )
@@ -211,6 +215,7 @@ export class ItemListController
notesToDisplay: observable, notesToDisplay: observable,
panelTitle: observable, panelTitle: observable,
panelWidth: observable, panelWidth: observable,
items: observable,
renderedItems: observable, renderedItems: observable,
showDisplayOptionsMenu: observable, showDisplayOptionsMenu: observable,
@@ -253,7 +258,7 @@ export class ItemListController
async handleEvent(event: InternalEventInterface): Promise<void> { async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.TagChanged) { if (event.type === CrossControllerEvent.TagChanged) {
const payload = event.payload as { userTriggered: boolean } const payload = event.payload as { userTriggered: boolean }
this.handleTagChange(payload.userTriggered) await this.handleTagChange(payload.userTriggered)
} else if (event.type === CrossControllerEvent.ActiveEditorChanged) { } else if (event.type === CrossControllerEvent.ActiveEditorChanged) {
this.handleEditorChange().catch(console.error) 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) => { private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => {
const isSearching = this.noteFilterText.length > 0 const isSearching = this.noteFilterText.length > 0
const itemExistsInUpdatedResults = this.items.find((item) => item.uuid === activeItem?.uuid) const itemExistsInUpdatedResults = this.items.find((item) => item.uuid === activeItem?.uuid)
@@ -404,6 +403,18 @@ export class ItemListController
return activeItem && !this.selectionController.isItemSelected(activeItem) 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) { private async recomputeSelectionAfterItemsReload(itemsReloadSource: ItemsReloadSource) {
const activeController = this.getActiveItemController() const activeController = this.getActiveItemController()
@@ -561,48 +572,65 @@ export class ItemListController
return { didReloadItems: true } return { didReloadItems: true }
} }
async createNewNoteController(title?: string) { async createNewNoteController(
title?: string,
createdAt?: Date,
autofocusBehavior?: TemplateNoteViewAutofocusBehavior,
) {
const selectedTag = this.navigationController.selected const selectedTag = this.navigationController.selected
const activeRegularTagUuid = selectedTag instanceof SNTag ? selectedTag.uuid : undefined const activeRegularTagUuid = selectedTag instanceof SNTag ? selectedTag.uuid : undefined
await this.application.itemControllerGroup.createItemController({ return this.application.itemControllerGroup.createItemController({
title, title,
tag: activeRegularTagUuid, tag: activeRegularTagUuid,
createdAt,
autofocusBehavior,
}) })
} }
createNewNote = async () => { titleForNewNote = (createdAt?: Date) => {
this.notesController.unselectNotes() if (this.isFiltering) {
return this.noteFilterText
if (this.navigationController.isInSmartView() && !this.navigationController.isInHomeView()) {
await this.navigationController.selectHomeNavigationView()
} }
const titleFormat = const titleFormat =
this.navigationController.selected?.preferences?.newNoteTitleFormat || this.navigationController.selected?.preferences?.newNoteTitleFormat ||
this.application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat]) this.application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat])
let title = formatDateAndTimeForNote(new Date())
if (titleFormat === NewNoteTitleFormat.CurrentNoteCount) { if (titleFormat === NewNoteTitleFormat.CurrentNoteCount) {
title = `Note ${this.notes.length + 1}` return `Note ${this.notes.length + 1}`
} else if (titleFormat === NewNoteTitleFormat.CustomFormat) { }
if (titleFormat === NewNoteTitleFormat.CustomFormat) {
const customFormat = const customFormat =
this.navigationController.selected?.preferences?.customNoteTitleFormat || this.navigationController.selected?.preferences?.customNoteTitleFormat ||
this.application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat]) this.application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat])
title = dayjs().format(customFormat)
} else if (titleFormat === NewNoteTitleFormat.Empty) { return dayjs(createdAt).format(customFormat)
title = ''
} }
if (this.isFiltering) { if (titleFormat === NewNoteTitleFormat.Empty) {
title = this.noteFilterText 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.linkingController.reloadAllLinks()
this.selectionController.scrollToItem(controller.item)
} }
createPlaceholderNote = () => { createPlaceholderNote = () => {
@@ -721,7 +749,7 @@ export class ItemListController
this.application.itemControllerGroup.closeItemController(controller) this.application.itemControllerGroup.closeItemController(controller)
} }
handleTagChange = (userTriggered: boolean) => { handleTagChange = async (userTriggered: boolean) => {
const activeNoteController = this.getActiveItemController() const activeNoteController = this.getActiveItemController()
if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) { if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) {
this.closeItemController(activeNoteController) this.closeItemController(activeNoteController)
@@ -739,7 +767,7 @@ export class ItemListController
this.reloadNotesDisplayOptions() this.reloadNotesDisplayOptions()
void this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange) await this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange)
} }
onFilterEnter = () => { onFilterEnter = () => {

View File

@@ -15,6 +15,7 @@ import {
InternalEventBus, InternalEventBus,
InternalEventPublishStrategy, InternalEventPublishStrategy,
VectorIconNameOrEmoji, VectorIconNameOrEmoji,
isTag,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx' import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application' import { WebApplication } from '../../Application/Application'
@@ -268,6 +269,13 @@ export class NavigationController
return this.selected instanceof SmartView && this.selected.uuid === id 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 { setAddingSubtagTo(tag: SNTag | undefined): void {
this.addingSubtagTo = tag this.addingSubtagTo = tag
} }
@@ -440,19 +448,21 @@ export class NavigationController
this.previouslySelected_ = this.selected_ this.previouslySelected_ = this.selected_
this.setSelectedTagInstance(tag) await runInAction(async () => {
this.setSelectedTagInstance(tag)
if (tag && this.application.items.isTemplateItem(tag)) { if (tag && this.application.items.isTemplateItem(tag)) {
return return
} }
await this.eventBus.publishSync( await this.eventBus.publishSync(
{ {
type: CrossControllerEvent.TagChanged, type: CrossControllerEvent.TagChanged,
payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered }, payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered },
}, },
InternalEventPublishStrategy.SEQUENCE, InternalEventPublishStrategy.SEQUENCE,
) )
})
} }
public async selectHomeNavigationView(): Promise<void> { public async selectHomeNavigationView(): Promise<void> {

View File

@@ -287,18 +287,22 @@ export class SelectedItemsController
item: { item: {
uuid: ListableContentItem['uuid'] uuid: ListableContentItem['uuid']
}, },
{ userTriggered = false, scrollIntoView = true }, { userTriggered = false, scrollIntoView = true, animated = true },
): Promise<void> => { ): Promise<void> => {
const { didSelect } = await this.selectItem(item.uuid, userTriggered) const { didSelect } = await this.selectItem(item.uuid, userTriggered)
if (didSelect && scrollIntoView) { if (didSelect && scrollIntoView) {
const itemElement = document.getElementById(item.uuid) this.scrollToItem(item, animated)
itemElement?.scrollIntoView({
behavior: 'smooth',
})
} }
} }
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) => { selectUuids = async (uuids: UuidString[], userTriggered = false) => {
const itemsForUuids = this.application.items.findItems(uuids) const itemsForUuids = this.application.items.findItems(uuids)
if (itemsForUuids.length < 1) { if (itemsForUuids.length < 1) {

View File

@@ -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)
})
})
})

View File

@@ -12,13 +12,84 @@ export const formatDateForContextMenu = (date: Date | undefined) => {
return `${date.toDateString()} ${date.toLocaleTimeString()}` return `${date.toDateString()} ${date.toLocaleTimeString()}`
} }
export const formatDateAndTimeForNote = (date: Date) => { export const formatDateAndTimeForNote = (date: Date, includeTime = true) => {
return `${date.toLocaleDateString(undefined, { const dateString = `${date.toLocaleDateString(undefined, {
weekday: 'long', weekday: 'long',
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
year: 'numeric', 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)
} }

View File

@@ -146,7 +146,6 @@
&.selected { &.selected {
background-color: var(--item-cell-selected-background-color); background-color: var(--item-cell-selected-background-color);
border-left: 2px solid var(--item-cell-selected-border-left-color);
progress { progress {
background-color: var(--note-preview-selected-progress-background-color); background-color: var(--note-preview-selected-progress-background-color);

View File

@@ -115,6 +115,7 @@ module.exports = {
'warning-contrast': 'var(--sn-stylekit-warning-contrast-color)', 'warning-contrast': 'var(--sn-stylekit-warning-contrast-color)',
danger: 'var(--sn-stylekit-danger-color)', danger: 'var(--sn-stylekit-danger-color)',
'danger-contrast': 'var(--sn-stylekit-danger-contrast-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)', default: 'var(--sn-stylekit-background-color)',
foreground: 'var(--sn-stylekit-foreground-color)', foreground: 'var(--sn-stylekit-foreground-color)',
contrast: 'var(--sn-stylekit-contrast-background-color)', contrast: 'var(--sn-stylekit-contrast-background-color)',