1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Add support for custom notebook icons (#6110)

This commit is contained in:
Laurent 2022-02-06 16:42:00 +00:00 committed by GitHub
parent db497ee0a5
commit 9f252ea673
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 151 additions and 20 deletions

View File

@ -235,6 +235,9 @@ packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js.map
packages/app-desktop/gui/ErrorBoundary.d.ts packages/app-desktop/gui/ErrorBoundary.d.ts
packages/app-desktop/gui/ErrorBoundary.js packages/app-desktop/gui/ErrorBoundary.js
packages/app-desktop/gui/ErrorBoundary.js.map packages/app-desktop/gui/ErrorBoundary.js.map
packages/app-desktop/gui/FolderIconBox.d.ts
packages/app-desktop/gui/FolderIconBox.js
packages/app-desktop/gui/FolderIconBox.js.map
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.d.ts packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.d.ts
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js.map packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js.map

3
.gitignore vendored
View File

@ -225,6 +225,9 @@ packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js.map
packages/app-desktop/gui/ErrorBoundary.d.ts packages/app-desktop/gui/ErrorBoundary.d.ts
packages/app-desktop/gui/ErrorBoundary.js packages/app-desktop/gui/ErrorBoundary.js
packages/app-desktop/gui/ErrorBoundary.js.map packages/app-desktop/gui/ErrorBoundary.js.map
packages/app-desktop/gui/FolderIconBox.d.ts
packages/app-desktop/gui/FolderIconBox.js
packages/app-desktop/gui/FolderIconBox.js.map
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.d.ts packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.d.ts
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js.map packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js.map

View File

@ -9,6 +9,13 @@ interface LastSelectedPath {
directory: string; directory: string;
} }
interface OpenDialogOptions {
properties?: string[];
defaultPath?: string;
createDirectory?: boolean;
filters?: any[];
}
export class Bridge { export class Bridge {
private electronWrapper_: ElectronAppWrapper; private electronWrapper_: ElectronAppWrapper;
@ -155,14 +162,14 @@ export class Bridge {
return filePath; return filePath;
} }
async showOpenDialog(options: any = null) { async showOpenDialog(options: OpenDialogOptions = null) {
const { dialog } = require('electron'); const { dialog } = require('electron');
if (!options) options = {}; if (!options) options = {};
let fileType = 'file'; let fileType = 'file';
if (options.properties && options.properties.includes('openDirectory')) fileType = 'directory'; if (options.properties && options.properties.includes('openDirectory')) fileType = 'directory';
if (!('defaultPath' in options) && (this.lastSelectedPaths_ as any)[fileType]) options.defaultPath = (this.lastSelectedPaths_ as any)[fileType]; if (!('defaultPath' in options) && (this.lastSelectedPaths_ as any)[fileType]) options.defaultPath = (this.lastSelectedPaths_ as any)[fileType];
if (!('createDirectory' in options)) options.createDirectory = true; if (!('createDirectory' in options)) options.createDirectory = true;
const { filePaths } = await dialog.showOpenDialog(this.window(), options); const { filePaths } = await dialog.showOpenDialog(this.window(), options as any);
if (filePaths && filePaths.length) { if (filePaths && filePaths.length) {
(this.lastSelectedPaths_ as any)[fileType] = dirname(filePaths[0]); (this.lastSelectedPaths_ as any)[fileType] = dirname(filePaths[0]);
} }

View File

@ -8,9 +8,11 @@ import StyledInput from '../style/StyledInput';
import { IconSelector, ChangeEvent } from './IconSelector'; import { IconSelector, ChangeEvent } from './IconSelector';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types'; import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import Button from '../Button/Button'; import Button from '../Button/Button';
import bridge from '../../services/bridge'; import bridge from '../../services/bridge';
import shim from '@joplin/lib/shim';
import FolderIconBox from '../FolderIconBox';
interface Props { interface Props {
themeId: number; themeId: number;
@ -93,6 +95,27 @@ export default function(props: Props) {
setFolderIcon(null); setFolderIcon(null);
}, []); }, []);
const onBrowseClick = useCallback(async () => {
const filePaths = await bridge().showOpenDialog();
if (filePaths.length !== 1) return;
const filePath = filePaths[0];
try {
const dataUrl = await shim.imageToDataUrl(filePath, 256);
setFolderIcon(icon => {
return {
...icon,
emoji: '',
name: '',
type: FolderIconType.DataUrl,
dataUrl,
};
});
} catch (error) {
await bridge().showErrorMessageBox(error.message);
}
}, []);
function renderForm() { function renderForm() {
return ( return (
<div> <div>
@ -105,11 +128,14 @@ export default function(props: Props) {
<div className="form-input-group"> <div className="form-input-group">
<label>{_('Icon')}</label> <label>{_('Icon')}</label>
<div className="icon-selector-row"> <div className="icon-selector-row">
{ folderIcon && <div className="foldericon"><FolderIconBox folderIcon={folderIcon} /></div> }
<IconSelector <IconSelector
title={_('Select emoji...')}
icon={folderIcon} icon={folderIcon}
onChange={onFolderIconChange} onChange={onFolderIconChange}
/> />
<Button ml={1} title={_('Clear')} onClick={onClearClick}/> <Button ml={1} title={_('Select file...')} onClick={onBrowseClick}/>
{ folderIcon && <Button ml={1} title={_('Clear')} onClick={onClearClick}/> }
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import { loadScript } from '../utils/loadScript'; import { loadScript } from '../utils/loadScript';
import Button from '../Button/Button'; import Button from '../Button/Button';
import { FolderIcon } from '@joplin/lib/services/database/types'; import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import bridge from '../../services/bridge'; import bridge from '../../services/bridge';
export interface ChangeEvent { export interface ChangeEvent {
@ -15,6 +15,7 @@ type ChangeHandler = (event: ChangeEvent)=> void;
interface Props { interface Props {
onChange: ChangeHandler; onChange: ChangeHandler;
icon: FolderIcon | null; icon: FolderIcon | null;
title: string;
} }
export const IconSelector = (props: Props) => { export const IconSelector = (props: Props) => {
@ -62,7 +63,7 @@ export const IconSelector = (props: Props) => {
}); });
const onEmoji = (selection: FolderIcon) => { const onEmoji = (selection: FolderIcon) => {
props.onChange({ value: selection }); props.onChange({ value: { ...selection, type: FolderIconType.Emoji } });
}; };
p.on('emoji', onEmoji); p.on('emoji', onEmoji);
@ -78,16 +79,25 @@ export const IconSelector = (props: Props) => {
picker.togglePicker(buttonRef.current); picker.togglePicker(buttonRef.current);
}, [picker]); }, [picker]);
const buttonText = props.icon ? props.icon.emoji : '...'; // const buttonText = props.icon ? props.icon.emoji : '...';
return ( return (
<Button <Button
disabled={!picker} disabled={!picker}
ref={buttonRef} ref={buttonRef}
onClick={onClick} onClick={onClick}
title={buttonText} title={props.title}
isSquare={true}
fontSize={20}
/> />
); );
// return (
// <Button
// disabled={!picker}
// ref={buttonRef}
// onClick={onClick}
// title={buttonText}
// isSquare={true}
// fontSize={20}
// />
// );
}; };

View File

@ -1,4 +1,9 @@
.icon-selector-row { .icon-selector-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
}
.icon-selector-row > .foldericon {
margin-right: 5px;
} }

View File

@ -0,0 +1,17 @@
import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
interface Props {
folderIcon: FolderIcon;
}
export default function(props: Props) {
const folderIcon = props.folderIcon;
if (folderIcon.type === FolderIconType.Emoji) {
return <span style={{ fontSize: 20 }}>{folderIcon.emoji}</span>;
} else if (folderIcon.type === FolderIconType.DataUrl) {
return <img style={{ width: 20, height: 20 }} src={folderIcon.dataUrl} />;
} else {
throw new Error(`Unsupported folder icon type: ${folderIcon.type}`);
}
}

View File

@ -17,11 +17,12 @@ import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag'; import Tag from '@joplin/lib/models/Tag';
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import { FolderEntity } from '@joplin/lib/services/database/types'; import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { store } from '@joplin/lib/reducer'; import { store } from '@joplin/lib/reducer';
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService'; import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import FolderIconBox from '../FolderIconBox';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const shared = require('@joplin/lib/components/shared/side-menu-shared.js'); const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
const { themeStyle } = require('@joplin/lib/theme'); const { themeStyle } = require('@joplin/lib/theme');
@ -77,6 +78,12 @@ function ExpandLink(props: any) {
); );
} }
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) return null;
return <div style={{ marginRight: 5, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
};
function FolderItem(props: any) { function FolderItem(props: any) {
const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props; const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
@ -84,8 +91,6 @@ function FolderItem(props: any) {
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null; const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
const icon = folderIcon ? <span style={{ fontSize: 20, marginRight: 5 }}>{folderIcon.emoji}</span> : null;
return ( return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}> <StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/> <ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
@ -105,7 +110,7 @@ function FolderItem(props: any) {
}} }}
onDoubleClick={onFolderToggleClick_} onDoubleClick={onFolderToggleClick_}
> >
{icon}<span className="title" style={{ lineHeight: 0 }}>{folderTitle}</span> {renderFolderIcon(folderIcon)}<span className="title" style={{ lineHeight: 0 }}>{folderTitle}</span>
{shareIcon} {noteCountComp} {shareIcon} {noteCountComp}
</StyledListItemAnchor> </StyledListItemAnchor>
</StyledListItem> </StyledListItem>

View File

@ -1,6 +1,6 @@
const React = require('react'); const React = require('react');
const Component = React.Component; const Component = React.Component;
const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Alert } = require('react-native'); const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Alert, Image } = require('react-native');
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const Icon = require('react-native-vector-icons/Ionicons').default; const Icon = require('react-native-vector-icons/Ionicons').default;
const Folder = require('@joplin/lib/models/Folder').default; const Folder = require('@joplin/lib/models/Folder').default;
@ -74,7 +74,7 @@ class SideMenuContentComponent extends Component {
styles.folderButton = Object.assign({}, styles.button); styles.folderButton = Object.assign({}, styles.button);
styles.folderButton.paddingLeft = 0; styles.folderButton.paddingLeft = 0;
styles.folderButtonText = Object.assign({}, styles.buttonText); styles.folderButtonText = Object.assign({}, styles.buttonText, { paddingLeft: 0 });
styles.folderButtonSelected = Object.assign({}, styles.folderButton); styles.folderButtonSelected = Object.assign({}, styles.folderButton);
styles.folderButtonSelected.backgroundColor = theme.selectedColor; styles.folderButtonSelected.backgroundColor = theme.selectedColor;
styles.folderIcon = Object.assign({}, theme.icon); styles.folderIcon = Object.assign({}, theme.icon);
@ -219,6 +219,18 @@ class SideMenuContentComponent extends Component {
if (actionDone === 'auth') this.props.dispatch({ type: 'SIDE_MENU_CLOSE' }); if (actionDone === 'auth') this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
} }
renderFolderIcon(theme, folderIcon) {
if (!folderIcon) return null;
if (folderIcon.type === 1) { // FolderIconType.Emoji
return <Text style={{ fontSize: theme.fontSize, marginRight: 4 }}>{folderIcon.emoji}</Text>;
} else if (folderIcon.type === 2) { // FolderIconType.DataUrl
return <Image style={{ width: 20, height: 20, marginRight: 4, resizeMode: 'contain' }} source={{ uri: folderIcon.dataUrl }}/>;
} else {
throw new Error(`Unsupported folder icon type: ${folderIcon.type}`);
}
}
renderFolderItem(folder, selected, hasChildren, depth) { renderFolderItem(folder, selected, hasChildren, depth) {
const theme = themeStyle(this.props.themeId); const theme = themeStyle(this.props.themeId);
@ -228,6 +240,7 @@ class SideMenuContentComponent extends Component {
height: 36, height: 36,
alignItems: 'center', alignItems: 'center',
paddingRight: theme.marginRight, paddingRight: theme.marginRight,
paddingLeft: 10,
}; };
if (selected) folderButtonStyle.backgroundColor = theme.selectedColor; if (selected) folderButtonStyle.backgroundColor = theme.selectedColor;
folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft; folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft;
@ -253,7 +266,6 @@ class SideMenuContentComponent extends Component {
); );
const folderIcon = Folder.unserializeIcon(folder.icon); const folderIcon = Folder.unserializeIcon(folder.icon);
const icon = folderIcon ? `${folderIcon.emoji} ` : '';
return ( return (
<View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}> <View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}>
@ -267,8 +279,9 @@ class SideMenuContentComponent extends Component {
}} }}
> >
<View style={folderButtonStyle}> <View style={folderButtonStyle}>
{this.renderFolderIcon(theme, folderIcon)}
<Text numberOfLines={1} style={this.styles().folderButtonText}> <Text numberOfLines={1} style={this.styles().folderButtonText}>
{icon + Folder.displayTitle(folder)} {Folder.displayTitle(folder)}
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>

View File

@ -1,4 +1,4 @@
import { FolderEntity, FolderIcon, NoteEntity } from '../services/database/types'; import { defaultFolderIcon, FolderEntity, FolderIcon, NoteEntity } from '../services/database/types';
import BaseModel, { DeleteOptions } from '../BaseModel'; import BaseModel, { DeleteOptions } from '../BaseModel';
import time from '../time'; import time from '../time';
import { _ } from '../locale'; import { _ } from '../locale';
@ -767,7 +767,11 @@ export default class Folder extends BaseItem {
} }
public static unserializeIcon(icon: string): FolderIcon { public static unserializeIcon(icon: string): FolderIcon {
return icon ? JSON.parse(icon) : null; if (!icon) return null;
return {
...defaultFolderIcon(),
...JSON.parse(icon),
};
} }
} }

View File

@ -15,9 +15,26 @@ export interface BaseItemEntity {
created_time?: number; created_time?: number;
} }
export enum FolderIconType {
Emoji = 1,
DataUrl = 2,
}
export interface FolderIcon { export interface FolderIcon {
type: FolderIconType;
emoji: string; emoji: string;
name: string; name: string;
dataUrl: string;
}
export const defaultFolderIcon = () => {
const icon:FolderIcon = {
type: FolderIconType.Emoji,
emoji: '',
name: '',
dataUrl: '',
};
return icon;
} }

View File

@ -330,6 +330,23 @@ function shimInit(options = null) {
return Note.save(newNote); return Note.save(newNote);
}; };
shim.imageToDataUrl = async (filePath, maxSize) => {
if (shim.isElectron()) {
const nativeImage = require('electron').nativeImage;
const image = nativeImage.createFromPath(filePath);
if (!image) throw new Error(`Could not load image: ${filePath}`);
if (maxSize) {
const size = image.getSize();
if (size.width > maxSize || size.height > maxSize) throw new Error(`Image cannot be larger than ${maxSize}x${maxSize} pixels`);
}
return image.toDataURL();
} else {
throw new Error('Unsupported method');
}
},
shim.imageFromDataUrl = async function(imageDataUrl, filePath, options = null) { shim.imageFromDataUrl = async function(imageDataUrl, filePath, options = null) {
if (options === null) options = {}; if (options === null) options = {};

View File

@ -228,6 +228,10 @@ const shim = {
throw new Error('Not implemented'); throw new Error('Not implemented');
}, },
imageToDataUrl: async (_filePath: string, _maxSize: number = 0): Promise<string> => {
throw new Error('Not implemented');
},
imageFromDataUrl: async (_imageDataUrl: string, _filePath: string, _options: any = null) => { imageFromDataUrl: async (_imageDataUrl: string, _filePath: string, _options: any = null) => {
throw new Error('Not implemented'); throw new Error('Not implemented');
}, },