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

View File

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

View File

@@ -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<DecryptedPayload<C>>,
): I
/**

View File

@@ -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<DecryptedPayload<C>>,
): I
/**

View File

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

View File

@@ -34,7 +34,6 @@ export class NoteViewController implements ItemViewControllerInterface {
private removeStreamObserver?: () => void
public isTemplateNote = false
private saveTimeout?: ReturnType<typeof setTimeout>
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<NoteContent, SNNote>(ContentType.Note, {
text: '',
title: this.defaultTitle || '',
noteType: defaultEditor?.noteType || NoteType.Plain,
editorIdentifier: editorIdentifier,
references: [],
})
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(
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<SNNote>(
ContentType.Note,
({ changed, inserted, source }) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<C> = DecryptedItemInterface<C>,
>(contentType: ContentType, content?: C): I {
return this.itemManager.createTemplateItem(contentType, content)
>(contentType: ContentType, content?: C, override?: Partial<DecryptedPayload<C>>): I {
return this.itemManager.createTemplateItem(contentType, content, override)
}
public async setItemNeedsSync(

View File

@@ -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;

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 { 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<Props> = ({
[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 (
<div
className={classNames(
@@ -88,8 +116,8 @@ const ContentList: FunctionComponent<Props> = ({
hideIcon={hideEditorIcon}
sortBy={sortBy}
filesController={filesController}
selectionController={selectionController}
navigationController={navigationController}
onSelect={selectItem}
tags={getTagsForItem(item)}
notesController={notesController}
/>
))}

View File

@@ -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<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) {
case ContentType.Note:
return <NoteListItem tags={getTags()} {...props} />
return <NoteListItem {...props} />
case ContentType.File:
return <FileListItem tags={getTags()} {...props} />
return <FileListItem {...props} />
default:
return null
}

View File

@@ -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<Props> = ({
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<Props> = ({
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 (
<div
id="items-column"
@@ -279,20 +295,35 @@ const ContentListView: FunctionComponent<Props> = ({
/>
</div>
</div>
{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}
{renderedItems.length ? (
<ContentList
items={renderedItems}
{selectedAsTag && dailyMode && (
<DailyContentList
items={items}
selectedTag={selectedAsTag}
selectedUuids={selectedUuids}
application={application}
paginate={paginate}
filesController={filesController}
itemListController={itemListController}
navigationController={navigationController}
notesController={notesController}
selectionController={selectionController}
onSelect={handleDailyListSelection}
/>
)}
{!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}
<div className="absolute bottom-0 h-safe-bottom w-full" />
</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> = ({
application,
filesController,
selectionController,
hideDate,
hideIcon,
hideTags,
item,
onSelect,
selected,
sortBy,
tags,
@@ -44,7 +44,7 @@ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
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<DisplayableListItemProps> = ({
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(

View File

@@ -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<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
const isDailyEntry = isTag(selectedTag) && selectedTag.isDailyEntry
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
@@ -83,7 +84,13 @@ const ContentListHeader = ({
</Popover>
</div>
<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}
aria-label={addButtonLabel}
onClick={addNewItem}

View File

@@ -23,6 +23,9 @@ import { PreferenceMode } from './PreferenceMode'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
import Button from '@/Components/Button/Button'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { isDev } from '@/Utils'
const DailyEntryModeEnabled = isDev
const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
closeDisplayOptionsMenu,
@@ -36,6 +39,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
const [preferences, setPreferences] = useState<TagPreferences>({})
const hasSubscription = application.hasValidSubscription()
const controlsDisabled = currentMode === 'tag' && !hasSubscription
const isDailyEntry = selectedTag.preferences?.entryMode === 'daily'
const reloadPreferences = useCallback(() => {
const globalValues: TagPreferences = {
@@ -162,6 +166,13 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
void changePreferences({ hideEditorIcon: !preferences.hideEditorIcon })
}, [preferences, changePreferences])
const setEntryMode = useCallback(
(mode: 'normal' | 'daily') => {
void changePreferences({ entryMode: mode })
},
[changePreferences],
)
const TabButton: FunctionComponent<{
label: string
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>
<MenuItem
disabled={controlsDisabled}
disabled={controlsDisabled || isDailyEntry}
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified}
@@ -248,7 +259,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
</div>
</MenuItem>
<MenuItem
disabled={controlsDisabled}
disabled={controlsDisabled || isDailyEntry}
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate}
@@ -266,7 +277,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
</div>
</MenuItem>
<MenuItem
disabled={controlsDisabled}
disabled={controlsDisabled || isDailyEntry}
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByTitle}
@@ -362,6 +373,33 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
Show trashed
</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 />
<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 { isFile, sanitizeHtmlString, SNNote } from '@standardnotes/snjs'
import { isFile, SNNote } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useRef } from 'react'
import Icon from '@/Components/Icon/Icon'
@@ -11,11 +11,13 @@ import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
import ListItemNotePreviewText from './ListItemNotePreviewText'
import { ListItemTitle } from './ListItemTitle'
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
application,
notesController,
selectionController,
onSelect,
hideDate,
hideIcon,
hideTags,
@@ -48,7 +50,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
let shouldOpenContextMenu = selected
if (!selected) {
const { didSelect } = await selectionController.selectItem(item.uuid)
const { didSelect } = await onSelect(item)
if (didSelect) {
shouldOpenContextMenu = true
}
@@ -60,11 +62,11 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
}
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])
useContextMenuEvent(listItemRef, openContextMenu)
@@ -72,7 +74,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
<div
ref={listItemRef}
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}
onClick={onClick}
@@ -85,27 +87,8 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
<div className="pr-4" />
)}
<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]">
<div className="break-word mr-2">{item.title}</div>
</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>
)}
<ListItemTitle item={item} />
<ListItemNotePreviewText item={item} hidePreview={hidePreview} />
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,9 @@ class NoteView extends PureComponent<NoteViewProps, State> {
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<NoteViewProps, State> {
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<NoteViewProps, State> {
}
override render() {
if (this.controller.dealloced) {
return null
}
if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) {
return (
<ProtectedItemOverlay

View File

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

View File

@@ -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<void> {
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 = () => {

View File

@@ -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<void> {

View File

@@ -287,18 +287,22 @@ export class SelectedItemsController
item: {
uuid: ListableContentItem['uuid']
},
{ userTriggered = false, scrollIntoView = true },
{ userTriggered = false, scrollIntoView = true, animated = true },
): Promise<void> => {
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) {

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()}`
}
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)
}

View File

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

View File

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