feat: dedicated files layout (#1928)
This commit is contained in:
@@ -17,4 +17,5 @@ export interface TagPreferences {
|
|||||||
customNoteTitleFormat?: string
|
customNoteTitleFormat?: string
|
||||||
editorIdentifier?: FeatureIdentifier | string
|
editorIdentifier?: FeatureIdentifier | string
|
||||||
entryMode?: 'normal' | 'daily'
|
entryMode?: 'normal' | 'daily'
|
||||||
|
panelWidth?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +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'
|
import { ContentType, SNTag } from '@standardnotes/snjs'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -95,35 +95,44 @@ const ContentList: FunctionComponent<Props> = ({
|
|||||||
[hideTags, selectedTag, application],
|
[hideTags, selectedTag, application],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasNotes = items.some((item) => item.content_type === ContentType.Note)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'infinite-scroll overflow-y-auto overflow-x-hidden focus:shadow-none focus:outline-none',
|
'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:max-h-full md:overflow-y-hidden md:hover:overflow-y-auto pointer-coarse:md:overflow-y-auto',
|
||||||
'md:hover:[overflow-y:_overlay]',
|
'flex flex-wrap pb-2 md:hover:[overflow-y:_overlay]',
|
||||||
|
hasNotes ? 'justify-center' : 'justify-center md:justify-start md:pl-1',
|
||||||
)}
|
)}
|
||||||
id={ElementIds.ContentList}
|
id={ElementIds.ContentList}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item, index) => {
|
||||||
<ContentListItem
|
const previousItem = items[index - 1]
|
||||||
key={item.uuid}
|
const nextItem = items[index + 1]
|
||||||
application={application}
|
return (
|
||||||
item={item}
|
<ContentListItem
|
||||||
selected={selectedUuids.has(item.uuid)}
|
key={item.uuid}
|
||||||
hideDate={hideDate}
|
application={application}
|
||||||
hidePreview={hideNotePreview}
|
item={item}
|
||||||
hideTags={hideTags}
|
selected={selectedUuids.has(item.uuid)}
|
||||||
hideIcon={hideEditorIcon}
|
hideDate={hideDate}
|
||||||
sortBy={sortBy}
|
hidePreview={hideNotePreview}
|
||||||
filesController={filesController}
|
hideTags={hideTags}
|
||||||
onSelect={selectItem}
|
hideIcon={hideEditorIcon}
|
||||||
tags={getTagsForItem(item)}
|
sortBy={sortBy}
|
||||||
notesController={notesController}
|
filesController={filesController}
|
||||||
/>
|
onSelect={selectItem}
|
||||||
))}
|
tags={getTagsForItem(item)}
|
||||||
|
notesController={notesController}
|
||||||
|
isPreviousItemTiled={previousItem?.content_type === ContentType.File}
|
||||||
|
isNextItemTiled={nextItem?.content_type === ContentType.File}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { ContentType } from '@standardnotes/snjs'
|
import { ContentType, FileItem, SNNote } from '@standardnotes/snjs'
|
||||||
import React, { FunctionComponent } from 'react'
|
import React, { FunctionComponent } from 'react'
|
||||||
import FileListItem from './FileListItem'
|
import FileListItem from './FileListItem'
|
||||||
import NoteListItem from './NoteListItem'
|
import NoteListItem from './NoteListItem'
|
||||||
import { AbstractListItemProps, doListItemPropsMeritRerender } from './Types/AbstractListItemProps'
|
import { AbstractListItemProps, doListItemPropsMeritRerender } from './Types/AbstractListItemProps'
|
||||||
|
import { ListableContentItem } from './Types/ListableContentItem'
|
||||||
|
|
||||||
const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
|
const ContentListItem: FunctionComponent<AbstractListItemProps<ListableContentItem>> = (props) => {
|
||||||
switch (props.item.content_type) {
|
switch (props.item.content_type) {
|
||||||
case ContentType.Note:
|
case ContentType.Note:
|
||||||
return <NoteListItem {...props} />
|
return <NoteListItem {...props} item={props.item as SNNote} />
|
||||||
case ContentType.File:
|
case ContentType.File:
|
||||||
return <FileListItem {...props} />
|
return <FileListItem {...props} item={props.item as FileItem} />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
|
import { KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
|
||||||
import { WebApplication } from '@/Application/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
|
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
|
||||||
import { FileItem, PrefKey, SystemViewId } from '@standardnotes/snjs'
|
import { FileItem, PrefKey } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'
|
import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import ContentList from '@/Components/ContentListView/ContentList'
|
import ContentList from '@/Components/ContentListView/ContentList'
|
||||||
@@ -118,10 +118,7 @@ const ContentListView: FunctionComponent<Props> = ({
|
|||||||
|
|
||||||
const icon = selectedTag?.iconString
|
const icon = selectedTag?.iconString
|
||||||
|
|
||||||
const isFilesSmartView = useMemo(
|
const isFilesSmartView = useMemo(() => navigationController.isInFilesView, [navigationController.isInFilesView])
|
||||||
() => navigationController.selected?.uuid === SystemViewId.Files,
|
|
||||||
[navigationController.selected?.uuid],
|
|
||||||
)
|
|
||||||
|
|
||||||
const addNewItem = useCallback(async () => {
|
const addNewItem = useCallback(async () => {
|
||||||
if (isFilesSmartView) {
|
if (isFilesSmartView) {
|
||||||
@@ -215,10 +212,14 @@ const ContentListView: FunctionComponent<Props> = ({
|
|||||||
|
|
||||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||||
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
if (selectedAsTag) {
|
||||||
|
navigationController.setPanelWidthForTag(selectedAsTag, width)
|
||||||
|
} else {
|
||||||
|
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
||||||
|
}
|
||||||
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed)
|
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed)
|
||||||
},
|
},
|
||||||
[application],
|
[application, selectedAsTag, navigationController],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addButtonLabel = useMemo(
|
const addButtonLabel = useMemo(
|
||||||
@@ -243,15 +244,26 @@ const ContentListView: FunctionComponent<Props> = ({
|
|||||||
[selectionController],
|
[selectionController],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasEditorPane = selectedUuids.size > 0
|
||||||
|
if (!hasEditorPane) {
|
||||||
|
itemsViewPanelRef.current?.style.removeProperty('width')
|
||||||
|
}
|
||||||
|
}, [selectedUuids, itemsViewPanelRef])
|
||||||
|
|
||||||
|
const hasEditorPane = selectedUuids.size > 0 || renderedItems.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="items-column"
|
id="items-column"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'sn-component section app-column flex h-full flex-col overflow-hidden pt-safe-top',
|
'sn-component section app-column flex h-full flex-col overflow-hidden pt-safe-top',
|
||||||
'xl:w-87.5 xsm-only:!w-full sm-only:!w-full',
|
hasEditorPane ? 'xl:w-[24rem] xsm-only:!w-full sm-only:!w-full' : 'w-full md:min-w-[400px]',
|
||||||
isTabletScreenSize && !isNotesListVisibleOnTablets
|
hasEditorPane
|
||||||
? 'pointer-coarse:md-only:!w-0 pointer-coarse:lg-only:!w-0'
|
? isTabletScreenSize && !isNotesListVisibleOnTablets
|
||||||
: 'pointer-coarse:md-only:!w-60 pointer-coarse:lg-only:!w-60',
|
? 'pointer-coarse:md-only:!w-0 pointer-coarse:lg-only:!w-0'
|
||||||
|
: 'pointer-coarse:md-only:!w-60 pointer-coarse:lg-only:!w-60'
|
||||||
|
: '',
|
||||||
)}
|
)}
|
||||||
aria-label={'Notes & Files'}
|
aria-label={'Notes & Files'}
|
||||||
ref={itemsViewPanelRef}
|
ref={itemsViewPanelRef}
|
||||||
@@ -327,7 +339,7 @@ const ContentListView: FunctionComponent<Props> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="absolute bottom-0 h-safe-bottom w-full" />
|
<div className="absolute bottom-0 h-safe-bottom w-full" />
|
||||||
</ResponsivePaneContent>
|
</ResponsivePaneContent>
|
||||||
{itemsViewPanelRef.current && (
|
{hasEditorPane && itemsViewPanelRef.current && (
|
||||||
<PanelResizer
|
<PanelResizer
|
||||||
collapsable={true}
|
collapsable={true}
|
||||||
hoverable={true}
|
hoverable={true}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
||||||
import { SNTag } from '@standardnotes/snjs'
|
import { isNote, SNTag } from '@standardnotes/snjs'
|
||||||
import { ComponentPropsWithoutRef, forwardRef, FunctionComponent, Ref } from 'react'
|
import { ComponentPropsWithoutRef, forwardRef, FunctionComponent, Ref } from 'react'
|
||||||
import ListItemFlagIcons from '../ListItemFlagIcons'
|
import ListItemFlagIcons from '../ListItemFlagIcons'
|
||||||
import ListItemMetadata from '../ListItemMetadata'
|
import ListItemMetadata from '../ListItemMetadata'
|
||||||
@@ -65,7 +65,7 @@ export const DailyItemCell = forwardRef(
|
|||||||
{item && (
|
{item && (
|
||||||
<>
|
<>
|
||||||
<ListItemTitle item={item} />
|
<ListItemTitle item={item} />
|
||||||
<ListItemNotePreviewText hidePreview={hidePreview} item={item} lineLimit={5} />
|
{isNote(item) && <ListItemNotePreviewText hidePreview={hidePreview} item={item} lineLimit={5} />}
|
||||||
<ListItemMetadata item={item} hideDate={hideDate} sortBy={'created_at'} />
|
<ListItemMetadata item={item} hideDate={hideDate} sortBy={'created_at'} />
|
||||||
<ListItemTags hideTags={hideTags} tags={tags} />
|
<ListItemTags hideTags={hideTags} tags={tags} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import { observer } from 'mobx-react-lite'
|
|||||||
import { FunctionComponent, useCallback, useRef } from 'react'
|
import { FunctionComponent, useCallback, useRef } from 'react'
|
||||||
import { getFileIconComponent } from '../FilePreview/getFileIconComponent'
|
import { getFileIconComponent } from '../FilePreview/getFileIconComponent'
|
||||||
import ListItemConflictIndicator from './ListItemConflictIndicator'
|
import ListItemConflictIndicator from './ListItemConflictIndicator'
|
||||||
import ListItemFlagIcons from './ListItemFlagIcons'
|
|
||||||
import ListItemTags from './ListItemTags'
|
import ListItemTags from './ListItemTags'
|
||||||
import ListItemMetadata from './ListItemMetadata'
|
import ListItemMetadata from './ListItemMetadata'
|
||||||
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
|
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 { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||||
|
|
||||||
const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
|
const FileListItem: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
|
||||||
application,
|
application,
|
||||||
filesController,
|
filesController,
|
||||||
hideDate,
|
hideDate,
|
||||||
@@ -67,7 +68,7 @@ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
|
|||||||
const IconComponent = () =>
|
const IconComponent = () =>
|
||||||
getFileIconComponent(
|
getFileIconComponent(
|
||||||
application.iconsController.getIconForFileType((item as FileItem).mimeType),
|
application.iconsController.getIconForFileType((item as FileItem).mimeType),
|
||||||
'w-5 h-5 flex-shrink-0',
|
'w-10 h-10 flex-shrink-0',
|
||||||
)
|
)
|
||||||
|
|
||||||
useContextMenuEvent(listItemRef, openContextMenu)
|
useContextMenuEvent(listItemRef, openContextMenu)
|
||||||
@@ -75,28 +76,41 @@ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={listItemRef}
|
ref={listItemRef}
|
||||||
className={`content-list-item flex w-full cursor-pointer items-stretch text-text ${
|
className={classNames('flex max-h-[300px] w-[190px] cursor-pointer px-1 pt-2 text-text md:w-[200px]')}
|
||||||
selected && 'selected border-l-2px border-solid border-info'
|
|
||||||
}`}
|
|
||||||
id={item.uuid}
|
id={item.uuid}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{!hideIcon ? (
|
<div
|
||||||
<div className="mr-0 flex flex-col items-center justify-between p-4.5 pr-3">
|
className={`flex flex-col justify-between overflow-hidden rounded bg-passive-5 pt-5 transition-all hover:bg-passive-4 ${
|
||||||
<IconComponent />
|
selected ? 'border-[1px] border-solid border-info' : 'border-[1px] border-solid border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={'px-5'}>
|
||||||
|
{!hideIcon ? (
|
||||||
|
<div className="mr-0">
|
||||||
|
<IconComponent />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pr-4" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-grow py-4 px-0">
|
||||||
|
<div className="line-clamp-2 overflow-hidden text-editor font-semibold">
|
||||||
|
<div className="break-word line-clamp-2 mr-2 overflow-hidden">{item.title}</div>
|
||||||
|
</div>
|
||||||
|
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
|
||||||
|
<ListItemTags hideTags={hideTags} tags={tags} />
|
||||||
|
<ListItemConflictIndicator item={item} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div
|
||||||
<div className="pr-4" />
|
className={classNames(
|
||||||
)}
|
'border-t-[1px] border-solid border-border p-3 text-xs font-bold',
|
||||||
<div className="min-w-0 flex-grow border-b border-solid border-border py-4 px-0">
|
selected ? 'bg-info text-info-contrast' : 'bg-passive-4 text-neutral',
|
||||||
<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>
|
>
|
||||||
|
{formatSizeToReadableString(item.decryptedSize)}
|
||||||
</div>
|
</div>
|
||||||
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
|
|
||||||
<ListItemTags hideTags={hideTags} tags={tags} />
|
|
||||||
<ListItemConflictIndicator item={item} />
|
|
||||||
</div>
|
</div>
|
||||||
<ListItemFlagIcons item={item} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ type Props = {
|
|||||||
starred: ListableContentItem['starred']
|
starred: ListableContentItem['starred']
|
||||||
}
|
}
|
||||||
hasFiles?: boolean
|
hasFiles?: boolean
|
||||||
|
hasBorder?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = false }) => {
|
const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = false, hasBorder = true, className }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start border-b border-solid border-border p-4 pl-0">
|
<div className={`flex items-start ${hasBorder && 'border-b border-solid border-border'} ${className} pl-0`}>
|
||||||
{item.locked && (
|
{item.locked && (
|
||||||
<span className="flex items-center" title="Editing Disabled">
|
<span className="flex items-center" title="Editing Disabled">
|
||||||
<Icon ariaLabel="Editing Disabled" type="pencil-off" className="text-info" size="medium" />
|
<Icon ariaLabel="Editing Disabled" type="pencil-off" className="text-info" size="medium" />
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { sanitizeHtmlString } from '@standardnotes/snjs'
|
import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'react'
|
import { FunctionComponent } from 'react'
|
||||||
import { ListableContentItem } from './Types/ListableContentItem'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: ListableContentItem
|
item: SNNote
|
||||||
hidePreview: boolean
|
hidePreview: boolean
|
||||||
lineLimit?: number
|
lineLimit?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FunctionComponent } from 'react'
|
import { FunctionComponent } from 'react'
|
||||||
import Icon from '@/Components/Icon/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
|
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
|
||||||
|
import { ListableContentItem } from './Types/ListableContentItem'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
hideTags: boolean
|
hideTags: boolean
|
||||||
tags: DisplayableListItemProps['tags']
|
tags: DisplayableListItemProps<ListableContentItem>['tags']
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
|
const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
|
|||||||
import ListItemNotePreviewText from './ListItemNotePreviewText'
|
import ListItemNotePreviewText from './ListItemNotePreviewText'
|
||||||
import { ListItemTitle } from './ListItemTitle'
|
import { ListItemTitle } from './ListItemTitle'
|
||||||
import { log, LoggingDomain } from '@/Logging'
|
import { log, LoggingDomain } from '@/Logging'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
|
||||||
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
|
const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
||||||
application,
|
application,
|
||||||
notesController,
|
notesController,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -27,6 +28,8 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
|
|||||||
selected,
|
selected,
|
||||||
sortBy,
|
sortBy,
|
||||||
tags,
|
tags,
|
||||||
|
isPreviousItemTiled,
|
||||||
|
isNextItemTiled,
|
||||||
}) => {
|
}) => {
|
||||||
const { toggleAppPane } = useResponsiveAppPane()
|
const { toggleAppPane } = useResponsiveAppPane()
|
||||||
|
|
||||||
@@ -73,12 +76,17 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
|
|||||||
|
|
||||||
log(LoggingDomain.ItemsList, 'Rendering note list item', item.title)
|
log(LoggingDomain.ItemsList, 'Rendering note list item', item.title)
|
||||||
|
|
||||||
|
const hasOffsetBorder = !isNextItemTiled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={listItemRef}
|
ref={listItemRef}
|
||||||
className={`content-list-item flex w-full cursor-pointer items-stretch text-text ${
|
className={classNames(
|
||||||
selected && `selected border-l-2 border-solid border-accessory-tint-${tint}`
|
'content-list-item text-tex flex w-full cursor-pointer items-stretch',
|
||||||
}`}
|
selected && `selected border-l-2 border-solid border-accessory-tint-${tint}`,
|
||||||
|
isPreviousItemTiled && 'mt-3 border-t border-solid border-t-border',
|
||||||
|
isNextItemTiled && 'mb-3 border-b border-solid border-b-border',
|
||||||
|
)}
|
||||||
id={item.uuid}
|
id={item.uuid}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@@ -89,14 +97,14 @@ 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 ${hasOffsetBorder && 'border-b border-solid border-border'} py-4 px-0`}>
|
||||||
<ListItemTitle item={item} />
|
<ListItemTitle item={item} />
|
||||||
<ListItemNotePreviewText item={item} hidePreview={hidePreview} />
|
<ListItemNotePreviewText item={item} hidePreview={hidePreview} />
|
||||||
<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} />
|
||||||
</div>
|
</div>
|
||||||
<ListItemFlagIcons item={item} hasFiles={hasFiles} />
|
<ListItemFlagIcons className="p-4" item={item} hasFiles={hasFiles} hasBorder={hasOffsetBorder} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { NotesController } from '@/Controllers/NotesController'
|
|||||||
import { SortableItem, SNTag, Uuids } from '@standardnotes/snjs'
|
import { SortableItem, SNTag, Uuids } from '@standardnotes/snjs'
|
||||||
import { ListableContentItem } from './ListableContentItem'
|
import { ListableContentItem } from './ListableContentItem'
|
||||||
|
|
||||||
export type AbstractListItemProps = {
|
type KeysOfUnion<T> = T extends T ? keyof T : never
|
||||||
|
|
||||||
|
export type AbstractListItemProps<I extends ListableContentItem> = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
filesController: FilesController
|
filesController: FilesController
|
||||||
notesController: NotesController
|
notesController: NotesController
|
||||||
@@ -13,14 +15,19 @@ export type AbstractListItemProps = {
|
|||||||
hideIcon: boolean
|
hideIcon: boolean
|
||||||
hideTags: boolean
|
hideTags: boolean
|
||||||
hidePreview: boolean
|
hidePreview: boolean
|
||||||
item: ListableContentItem
|
item: I
|
||||||
selected: boolean
|
selected: boolean
|
||||||
sortBy: keyof SortableItem | undefined
|
sortBy: keyof SortableItem | undefined
|
||||||
tags: SNTag[]
|
tags: SNTag[]
|
||||||
|
isPreviousItemTiled?: boolean
|
||||||
|
isNextItemTiled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doListItemPropsMeritRerender(previous: AbstractListItemProps, next: AbstractListItemProps): boolean {
|
export function doListItemPropsMeritRerender(
|
||||||
const simpleComparison: (keyof AbstractListItemProps)[] = [
|
previous: AbstractListItemProps<ListableContentItem>,
|
||||||
|
next: AbstractListItemProps<ListableContentItem>,
|
||||||
|
): boolean {
|
||||||
|
const simpleComparison: (keyof AbstractListItemProps<ListableContentItem>)[] = [
|
||||||
'onSelect',
|
'onSelect',
|
||||||
'hideDate',
|
'hideDate',
|
||||||
'hideIcon',
|
'hideIcon',
|
||||||
@@ -28,6 +35,8 @@ export function doListItemPropsMeritRerender(previous: AbstractListItemProps, ne
|
|||||||
'hidePreview',
|
'hidePreview',
|
||||||
'selected',
|
'selected',
|
||||||
'sortBy',
|
'sortBy',
|
||||||
|
'isPreviousItemTiled',
|
||||||
|
'isNextItemTiled',
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const key of simpleComparison) {
|
for (const key of simpleComparison) {
|
||||||
@@ -83,7 +92,7 @@ function doesItemChangeMeritRerender(previous: ListableContentItem, next: Listab
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const propertiesMeritingRerender: (keyof ListableContentItem)[] = [
|
const propertiesMeritingRerender: KeysOfUnion<ListableContentItem>[] = [
|
||||||
'title',
|
'title',
|
||||||
'protected',
|
'protected',
|
||||||
'updatedAtString',
|
'updatedAtString',
|
||||||
@@ -97,7 +106,7 @@ function doesItemChangeMeritRerender(previous: ListableContentItem, next: Listab
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const key of propertiesMeritingRerender) {
|
for (const key of propertiesMeritingRerender) {
|
||||||
if (previous[key] !== next[key]) {
|
if (previous[key as keyof ListableContentItem] !== next[key as keyof ListableContentItem]) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { SNTag } from '@standardnotes/snjs'
|
import { SNTag } from '@standardnotes/snjs'
|
||||||
import { AbstractListItemProps } from './AbstractListItemProps'
|
import { AbstractListItemProps } from './AbstractListItemProps'
|
||||||
|
import { ListableContentItem } from './ListableContentItem'
|
||||||
|
|
||||||
export type DisplayableListItemProps = AbstractListItemProps & {
|
export type DisplayableListItemProps<I extends ListableContentItem> = AbstractListItemProps<I> & {
|
||||||
tags: {
|
tags: {
|
||||||
uuid: SNTag['uuid']
|
uuid: SNTag['uuid']
|
||||||
title: SNTag['title']
|
title: SNTag['title']
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
import { DecryptedItem, ItemContent } from '@standardnotes/snjs'
|
import { FileItem, SNNote } from '@standardnotes/snjs'
|
||||||
|
|
||||||
export type ListableContentItem = DecryptedItem<ItemContent> & {
|
export type ListableContentItem = SNNote | FileItem
|
||||||
title: string
|
|
||||||
protected: boolean
|
|
||||||
updatedAtString?: string
|
|
||||||
createdAtString?: string
|
|
||||||
hidePreview?: boolean
|
|
||||||
preview_html?: string
|
|
||||||
preview_plain?: string
|
|
||||||
text?: string
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
useBoolean,
|
useBoolean,
|
||||||
TemplateNoteViewAutofocusBehavior,
|
TemplateNoteViewAutofocusBehavior,
|
||||||
isTag,
|
isTag,
|
||||||
|
isFile,
|
||||||
} 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'
|
||||||
@@ -37,6 +38,7 @@ import dayjs from 'dayjs'
|
|||||||
import { LinkingController } from '../LinkingController'
|
import { LinkingController } from '../LinkingController'
|
||||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||||
import { Persistable } from '../Abstract/Persistable'
|
import { Persistable } from '../Abstract/Persistable'
|
||||||
|
import { log, LoggingDomain } from '@/Logging'
|
||||||
|
|
||||||
const MinNoteCellHeight = 51.0
|
const MinNoteCellHeight = 51.0
|
||||||
const DefaultListNumNotes = 20
|
const DefaultListNumNotes = 20
|
||||||
@@ -380,11 +382,42 @@ export class ItemListController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In some cases we want to keep the selected item open even if it doesn't appear in results,
|
||||||
|
* for example if you are inside tag Foo and remove tag Foo from the note, we want to keep the note open.
|
||||||
|
*/
|
||||||
private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => {
|
private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => {
|
||||||
const isSearching = this.noteFilterText.length > 0
|
const activeItemExistsInUpdatedResults = this.items.find((item) => item.uuid === activeItem?.uuid)
|
||||||
const itemExistsInUpdatedResults = this.items.find((item) => item.uuid === activeItem?.uuid)
|
|
||||||
|
|
||||||
return !itemExistsInUpdatedResults && !isSearching && this.navigationController.isInAnySystemView()
|
const closeBecauseActiveItemIsFileAndDoesntExistInUpdatedResults =
|
||||||
|
activeItem && isFile(activeItem) && !activeItemExistsInUpdatedResults
|
||||||
|
|
||||||
|
if (closeBecauseActiveItemIsFileAndDoesntExistInUpdatedResults) {
|
||||||
|
log(LoggingDomain.Selection, 'shouldCloseActiveItem closeBecauseActiveItemIsFileAndDoesntExistInUpdatedResults')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItemInNewResults = this.getFirstNonProtectedItem()
|
||||||
|
|
||||||
|
const closePreviousItemWhenSwitchingToFilesBasedView =
|
||||||
|
firstItemInNewResults && isFile(firstItemInNewResults) && !activeItemExistsInUpdatedResults
|
||||||
|
|
||||||
|
if (closePreviousItemWhenSwitchingToFilesBasedView) {
|
||||||
|
log(LoggingDomain.Selection, 'shouldCloseActiveItem closePreviousItemWhenSwitchingToFilesBasedView')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSearching = this.noteFilterText.length > 0
|
||||||
|
|
||||||
|
const closeBecauseActiveItemDoesntExistInCurrentSystemView =
|
||||||
|
!activeItemExistsInUpdatedResults && !isSearching && this.navigationController.isInAnySystemView()
|
||||||
|
|
||||||
|
if (closeBecauseActiveItemDoesntExistInCurrentSystemView) {
|
||||||
|
log(LoggingDomain.Selection, 'shouldCloseActiveItem closePreviousItemWhenSwitchingToFilesBasedView')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldSelectNextItemOrCreateNewNote = (activeItem: SNNote | FileItem | undefined) => {
|
private shouldSelectNextItemOrCreateNewNote = (activeItem: SNNote | FileItem | undefined) => {
|
||||||
@@ -404,6 +437,11 @@ export class ItemListController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => {
|
private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => {
|
||||||
|
const item = this.getFirstNonProtectedItem()
|
||||||
|
if (item && isFile(item)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const selectedTag = this.navigationController.selected
|
const selectedTag = this.navigationController.selected
|
||||||
const isDailyEntry = selectedTag && isTag(selectedTag) && selectedTag.isDailyEntry
|
const isDailyEntry = selectedTag && isTag(selectedTag) && selectedTag.isDailyEntry
|
||||||
if (isDailyEntry) {
|
if (isDailyEntry) {
|
||||||
@@ -424,10 +462,17 @@ export class ItemListController
|
|||||||
|
|
||||||
const activeItem = activeController?.item
|
const activeItem = activeController?.item
|
||||||
|
|
||||||
if (this.shouldCloseActiveItem(activeItem) && activeController) {
|
if (activeController && activeItem && this.shouldCloseActiveItem(activeItem)) {
|
||||||
this.closeItemController(activeController)
|
this.closeItemController(activeController)
|
||||||
this.selectionController.selectNextItem()
|
|
||||||
|
this.selectionController.deselectItem(activeItem)
|
||||||
|
|
||||||
|
if (this.shouldSelectFirstItem(itemsReloadSource)) {
|
||||||
|
log(LoggingDomain.Selection, 'Selecting next item after closing active one')
|
||||||
|
this.selectionController.selectNextItem()
|
||||||
|
}
|
||||||
} else if (this.shouldSelectActiveItem(activeItem) && activeItem) {
|
} else if (this.shouldSelectActiveItem(activeItem) && activeItem) {
|
||||||
|
log(LoggingDomain.Selection, 'Selecting active item')
|
||||||
await this.selectionController.selectItem(activeItem.uuid).catch(console.error)
|
await this.selectionController.selectItem(activeItem.uuid).catch(console.error)
|
||||||
} else if (this.shouldSelectFirstItem(itemsReloadSource)) {
|
} else if (this.shouldSelectFirstItem(itemsReloadSource)) {
|
||||||
await this.selectFirstItem()
|
await this.selectFirstItem()
|
||||||
@@ -547,9 +592,11 @@ export class ItemListController
|
|||||||
this.displayOptions = newDisplayOptions
|
this.displayOptions = newDisplayOptions
|
||||||
this.webDisplayOptions = newWebDisplayOptions
|
this.webDisplayOptions = newWebDisplayOptions
|
||||||
|
|
||||||
const newWidth = this.application.getPreference(PrefKey.NotesPanelWidth)
|
const listColumnWidth =
|
||||||
if (newWidth && newWidth !== this.panelWidth) {
|
selectedTag?.preferences?.panelWidth || this.application.getPreference(PrefKey.NotesPanelWidth)
|
||||||
this.panelWidth = newWidth
|
|
||||||
|
if (listColumnWidth && listColumnWidth !== this.panelWidth) {
|
||||||
|
this.panelWidth = listColumnWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!displayOptionsChanged) {
|
if (!displayOptionsChanged) {
|
||||||
@@ -560,7 +607,10 @@ export class ItemListController
|
|||||||
|
|
||||||
await this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
|
await this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
|
||||||
|
|
||||||
if (newDisplayOptions.sortBy !== currentSortBy) {
|
if (
|
||||||
|
newDisplayOptions.sortBy !== currentSortBy &&
|
||||||
|
this.shouldSelectFirstItem(ItemsReloadSource.DisplayOptionsChange)
|
||||||
|
) {
|
||||||
await this.selectFirstItem()
|
await this.selectFirstItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,6 +739,8 @@ export class ItemListController
|
|||||||
const item = this.getFirstNonProtectedItem()
|
const item = this.getFirstNonProtectedItem()
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
|
log(LoggingDomain.Selection, 'Selecting first item', item.uuid)
|
||||||
|
|
||||||
await this.selectionController.selectItemWithScrollHandling(item, {
|
await this.selectionController.selectItemWithScrollHandling(item, {
|
||||||
userTriggered: false,
|
userTriggered: false,
|
||||||
scrollIntoView: false,
|
scrollIntoView: false,
|
||||||
@@ -702,6 +754,7 @@ export class ItemListController
|
|||||||
const item = this.getFirstNonProtectedItem()
|
const item = this.getFirstNonProtectedItem()
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
|
log(LoggingDomain.Selection, 'selectNextItemOrCreateNewNote')
|
||||||
await this.selectionController
|
await this.selectionController
|
||||||
.selectItemWithScrollHandling(item, {
|
.selectItemWithScrollHandling(item, {
|
||||||
userTriggered: false,
|
userTriggered: false,
|
||||||
@@ -746,6 +799,7 @@ export class ItemListController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private closeItemController(controller: NoteViewController | FileViewController): void {
|
private closeItemController(controller: NoteViewController | FileViewController): void {
|
||||||
|
log(LoggingDomain.Selection, 'Closing item controller', controller.runtimeId)
|
||||||
this.application.itemControllerGroup.closeItemController(controller)
|
this.application.itemControllerGroup.closeItemController(controller)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -431,6 +431,15 @@ export class NavigationController
|
|||||||
return this.selected_
|
return this.selected_
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setPanelWidthForTag(tag: SNTag, width: number): Promise<void> {
|
||||||
|
await this.application.mutator.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||||
|
mutator.preferences = {
|
||||||
|
...mutator.preferences,
|
||||||
|
panelWidth: width,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
public async setSelectedTag(tag: AnyTag | undefined, { userTriggered } = { userTriggered: false }) {
|
public async setSelectedTag(tag: AnyTag | undefined, { userTriggered } = { userTriggered: false }) {
|
||||||
if (tag && tag.conflictOf) {
|
if (tag && tag.conflictOf) {
|
||||||
this.application.mutator
|
this.application.mutator
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
|
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
|
||||||
|
import { log, LoggingDomain } from '@/Logging'
|
||||||
import {
|
import {
|
||||||
ChallengeReason,
|
ChallengeReason,
|
||||||
ContentType,
|
ContentType,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
UuidString,
|
UuidString,
|
||||||
InternalEventBus,
|
InternalEventBus,
|
||||||
isFile,
|
isFile,
|
||||||
|
Uuids,
|
||||||
} 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'
|
||||||
@@ -248,12 +250,15 @@ export class SelectedItemsController
|
|||||||
didSelect: boolean
|
didSelect: boolean
|
||||||
}> => {
|
}> => {
|
||||||
const item = this.application.items.findItem<ListableContentItem>(uuid)
|
const item = this.application.items.findItem<ListableContentItem>(uuid)
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return {
|
return {
|
||||||
didSelect: false,
|
didSelect: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log(LoggingDomain.Selection, 'selectItem', item.uuid)
|
||||||
|
|
||||||
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
|
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
|
||||||
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
|
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
|
||||||
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
|
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
|
||||||
@@ -304,14 +309,18 @@ export class SelectedItemsController
|
|||||||
}
|
}
|
||||||
|
|
||||||
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).filter((item) => !isFile(item))
|
||||||
|
|
||||||
if (itemsForUuids.length < 1) {
|
if (itemsForUuids.length < 1) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userTriggered && itemsForUuids.some((item) => item.protected && isFile(item))) {
|
if (!userTriggered && itemsForUuids.some((item) => item.protected && isFile(item))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.setSelectedUuids(new Set(uuids))
|
|
||||||
|
this.setSelectedUuids(new Set(Uuids(itemsForUuids)))
|
||||||
|
|
||||||
if (itemsForUuids.length === 1) {
|
if (itemsForUuids.length === 1) {
|
||||||
void this.openSingleSelectedItem()
|
void this.openSingleSelectedItem()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export enum LoggingDomain {
|
|||||||
ItemsList,
|
ItemsList,
|
||||||
NavigationList,
|
NavigationList,
|
||||||
Viewport,
|
Viewport,
|
||||||
|
Selection,
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoggingStatus: Record<LoggingDomain, boolean> = {
|
const LoggingStatus: Record<LoggingDomain, boolean> = {
|
||||||
@@ -14,7 +15,8 @@ const LoggingStatus: Record<LoggingDomain, boolean> = {
|
|||||||
[LoggingDomain.NoteView]: false,
|
[LoggingDomain.NoteView]: false,
|
||||||
[LoggingDomain.ItemsList]: false,
|
[LoggingDomain.ItemsList]: false,
|
||||||
[LoggingDomain.NavigationList]: false,
|
[LoggingDomain.NavigationList]: false,
|
||||||
[LoggingDomain.Viewport]: true,
|
[LoggingDomain.Viewport]: false,
|
||||||
|
[LoggingDomain.Selection]: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function log(domain: LoggingDomain, ...args: any[]): void {
|
export function log(domain: LoggingDomain, ...args: any[]): void {
|
||||||
|
|||||||
@@ -109,6 +109,12 @@
|
|||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
@mixin DimmedBackground($color, $opacity) {
|
@mixin DimmedBackground($color, $opacity) {
|
||||||
content: '';
|
content: '';
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user