一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
303 行
8.0 KiB
TypeScript
303 行
8.0 KiB
TypeScript
import * as React from 'react';
|
|
import { useCallback, useId, useMemo } from 'react';
|
|
import { _ } from '@joplin/lib/locale';
|
|
import styled from 'styled-components';
|
|
import ToggleButton from '../../../lib/ToggleButton/ToggleButton';
|
|
import Button, { ButtonLevel } from '../../../Button/Button';
|
|
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
|
import bridge from '../../../../services/bridge';
|
|
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
|
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
|
import getPluginHelpUrl from '@joplin/lib/services/plugins/utils/getPluginHelpUrl';
|
|
|
|
export enum InstallState {
|
|
NotInstalled = 1,
|
|
Installing = 2,
|
|
Installed = 3,
|
|
}
|
|
|
|
export enum UpdateState {
|
|
Idle = 1,
|
|
CanUpdate = 2,
|
|
Updating = 3,
|
|
HasBeenUpdated = 4,
|
|
}
|
|
|
|
interface Props {
|
|
item?: PluginItem;
|
|
manifest?: PluginManifest;
|
|
installState?: InstallState;
|
|
updateState?: UpdateState;
|
|
themeId: number;
|
|
isCompatible: boolean;
|
|
onToggle?: (event: ItemEvent)=> void;
|
|
onDelete?: (event: ItemEvent)=> void;
|
|
onInstall?: (event: ItemEvent)=> void;
|
|
onUpdate?: (event: ItemEvent)=> void;
|
|
}
|
|
|
|
function manifestToItem(manifest: PluginManifest): PluginItem {
|
|
return {
|
|
manifest: manifest,
|
|
installed: true,
|
|
enabled: true,
|
|
deleted: false,
|
|
devMode: false,
|
|
builtIn: false,
|
|
hasBeenUpdated: false,
|
|
};
|
|
}
|
|
|
|
const CellRoot = styled.div<{ isCompatible: boolean }>`
|
|
display: flex;
|
|
box-sizing: border-box;
|
|
background-color: ${props => props.theme.backgroundColor};
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
min-height: 164px;
|
|
padding: 20px;
|
|
border: 1px solid ${props => props.theme.dividerColor};
|
|
border-radius: 8px;
|
|
width: 100%;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 8px 20px rgba(0,0,0,0.04);
|
|
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
|
|
|
opacity: ${props => props.isCompatible ? '1' : '0.6'};
|
|
|
|
&:hover {
|
|
border-color: ${props => props.theme.color};
|
|
box-shadow: 0 3px 6px rgba(0,0,0,0.07), 0 12px 28px rgba(0,0,0,0.08);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
&:focus-within {
|
|
border-color: ${props => props.theme.color};
|
|
box-shadow: 0 0 0 2px ${props => props.theme.color}33, 0 3px 6px rgba(0,0,0,0.07);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
transition: none;
|
|
&:hover { transform: none; }
|
|
}
|
|
`;
|
|
|
|
const CellTop = styled.div`
|
|
display: flex;
|
|
flex-direction: row;
|
|
width: 100%;
|
|
margin-bottom: 14px;
|
|
align-items: flex-start;
|
|
`;
|
|
|
|
const CellContent = styled.div`
|
|
display: flex;
|
|
margin-bottom: 10px;
|
|
flex: 1;
|
|
`;
|
|
|
|
const CellFooter = styled.div`
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 32px;
|
|
`;
|
|
|
|
const NeedUpgradeMessage = styled.span`
|
|
font-family: ${props => props.theme.fontFamily};
|
|
color: ${props => props.theme.colorWarn};
|
|
font-size: ${props => props.theme.fontSize}px;
|
|
`;
|
|
|
|
const BoxedLabel = styled.div`
|
|
border: 1px solid ${props => props.theme.color};
|
|
border-radius: 6px;
|
|
padding: 5px 8px;
|
|
font-size: ${props => props.theme.fontSize * 0.78}px;
|
|
font-weight: 600;
|
|
color: ${props => props.theme.color};
|
|
flex-grow: 0;
|
|
height: min-content;
|
|
margin-top: auto;
|
|
`;
|
|
|
|
const StyledNameAndVersion = styled.div<{ mb: string | number }>`
|
|
font-family: ${props => props.theme.fontFamily};
|
|
color: ${props => props.theme.color};
|
|
font-size: ${props => props.theme.fontSize * 1.08}px;
|
|
font-weight: 650;
|
|
padding-right: 12px;
|
|
flex: 1;
|
|
`;
|
|
|
|
const StyledName = styled.a`
|
|
color: ${props => props.theme.color};
|
|
|
|
&:hover {
|
|
text-decoration: underline;
|
|
}
|
|
`;
|
|
|
|
const StyledVersion = styled.span`
|
|
color: ${props => props.theme.colorFaded};
|
|
font-size: ${props => props.theme.fontSize * 0.82}px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
const StyledDescription = styled.div`
|
|
font-family: ${props => props.theme.fontFamily};
|
|
color: ${props => props.theme.colorFaded};
|
|
font-size: ${props => props.theme.fontSize}px;
|
|
line-height: 1.55;
|
|
`;
|
|
|
|
const RecommendedBadge = styled.a`
|
|
font-family: ${props => props.theme.fontFamily};
|
|
color: ${props => props.theme.colorWarn};
|
|
font-size: ${props => props.theme.fontSize}px;
|
|
border: 1px solid ${props => props.theme.colorWarn};
|
|
padding: 5px 8px;
|
|
border-radius: 6px;
|
|
opacity: 0.8;
|
|
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
`;
|
|
|
|
export default function(props: Props) {
|
|
const item = useMemo(() => {
|
|
return props.item ? props.item : manifestToItem(props.manifest);
|
|
}, [props.item, props.manifest]);
|
|
|
|
const onNameClick = useCallback(() => {
|
|
const manifest = item.manifest;
|
|
void bridge().openExternal(getPluginHelpUrl(manifest.id));
|
|
}, [item]);
|
|
|
|
const onRecommendedClick = useCallback(() => {
|
|
void bridge().openExternal('https://github.com/joplin/plugins/blob/master/readme/recommended.md#recommended-plugins');
|
|
}, []);
|
|
|
|
// For plugins in dev mode things like enabling/disabling or
|
|
// uninstalling them doesn't make sense, as that should be done by
|
|
// adding/removing them from wherever they were loaded from.
|
|
|
|
function renderToggleButton() {
|
|
if (!props.onToggle) return null;
|
|
|
|
if (item.devMode) {
|
|
return <BoxedLabel>DEV</BoxedLabel>;
|
|
}
|
|
|
|
return <ToggleButton
|
|
themeId={props.themeId}
|
|
value={item.enabled}
|
|
onToggle={() => props.onToggle({ item })}
|
|
aria-label={item.enabled ? _('Disable %s', item.manifest.name) : _('Enable %s', item.manifest.name)}
|
|
/>;
|
|
}
|
|
|
|
function renderDeleteButton() {
|
|
// Built-in plugins can only be disabled
|
|
if (item.builtIn) return null;
|
|
if (!props.onDelete) return null;
|
|
|
|
return <Button level={ButtonLevel.Secondary} onClick={() => props.onDelete({ item })} title={_('Delete')}/>;
|
|
}
|
|
|
|
function renderInstallButton() {
|
|
if (!props.onInstall) return null;
|
|
|
|
let title = _('Install');
|
|
if (props.installState === InstallState.Installing) title = _('Installing...');
|
|
if (props.installState === InstallState.Installed) title = _('Installed');
|
|
|
|
return <Button
|
|
level={ButtonLevel.Secondary}
|
|
disabled={props.installState !== InstallState.NotInstalled}
|
|
onClick={() => props.onInstall({ item })}
|
|
title={title}
|
|
/>;
|
|
}
|
|
|
|
function renderUpdateButton() {
|
|
if (!props.onUpdate) return null;
|
|
|
|
let title = _('Update');
|
|
if (props.updateState === UpdateState.Updating) title = _('Updating...');
|
|
if (props.updateState === UpdateState.Idle) title = _('Updated');
|
|
if (props.updateState === UpdateState.HasBeenUpdated) title = _('Updated');
|
|
|
|
return <Button
|
|
ml={1}
|
|
level={ButtonLevel.Recommended}
|
|
onClick={() => props.onUpdate({ item })}
|
|
title={title}
|
|
disabled={props.updateState === UpdateState.HasBeenUpdated}
|
|
/>;
|
|
}
|
|
|
|
const renderDefaultPluginLabel = () => {
|
|
if (item.builtIn) {
|
|
return (
|
|
<BoxedLabel>{_('Built-in')}</BoxedLabel>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
function renderFooter() {
|
|
if (item.devMode) return null;
|
|
|
|
if (!props.isCompatible) {
|
|
return (
|
|
<CellFooter>
|
|
<NeedUpgradeMessage>
|
|
{PluginService.instance().describeIncompatibility(item.manifest)}
|
|
</NeedUpgradeMessage>
|
|
</CellFooter>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<CellFooter>
|
|
{renderDeleteButton()}
|
|
{renderInstallButton()}
|
|
{renderUpdateButton()}
|
|
<div style={{ display: 'flex', flex: 1 }}/>
|
|
{renderDefaultPluginLabel()}
|
|
</CellFooter>
|
|
);
|
|
}
|
|
|
|
function renderRecommendedBadge() {
|
|
if (props.onToggle) return null;
|
|
if (!item.manifest._recommended) return null;
|
|
return <RecommendedBadge href="#" title={_('The Joplin team has vetted this plugin and it meets our standards for security and performance.')} onClick={onRecommendedClick}><i className="fas fa-crown"></i></RecommendedBadge>;
|
|
}
|
|
|
|
const nameLabelId = useId();
|
|
|
|
return (
|
|
<CellRoot isCompatible={props.isCompatible} role='group' aria-labelledby={nameLabelId}>
|
|
<CellTop>
|
|
<StyledNameAndVersion mb={'5px'}>
|
|
<StyledName onClick={onNameClick} href="#" style={{ marginRight: 5 }} id={nameLabelId}>
|
|
{item.manifest.name} {item.deleted ? _('(%s)', 'Deleted') : ''}
|
|
</StyledName>
|
|
<StyledVersion>v{item.manifest.version}</StyledVersion>
|
|
</StyledNameAndVersion>
|
|
{renderToggleButton()}
|
|
{renderRecommendedBadge()}
|
|
</CellTop>
|
|
<CellContent>
|
|
<StyledDescription>{item.manifest.description}</StyledDescription>
|
|
</CellContent>
|
|
{renderFooter()}
|
|
</CellRoot>
|
|
);
|
|
}
|