feat: Optional full-width toggle for document display (#2869)

* Migration, model, presenter

* Working implementation

* fix: Account for table of contents

* Checkbox -> Toggle

* Checkbox -> Toggle
This commit is contained in:
Tom Moor
2021-12-19 13:58:16 -08:00
committed by GitHub
parent 73bc7d9f2a
commit 66d5a567c2
18 changed files with 239 additions and 146 deletions

View File

@@ -1,62 +0,0 @@
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "~/components/HelpText";
export type Props = {
checked?: boolean;
label?: React.ReactNode;
labelHidden?: boolean;
className?: string;
name?: string;
disabled?: boolean;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
note?: React.ReactNode;
small?: boolean;
};
const LabelText = styled.span<{ small?: boolean }>`
font-weight: 500;
margin-left: ${(props) => (props.small ? "6px" : "10px")};
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
`;
const Wrapper = styled.div<{ small?: boolean }>`
padding-bottom: 8px;
${(props) => (props.small ? "font-size: 14px" : "")};
width: 100%;
`;
const Label = styled.label`
display: flex;
align-items: center;
user-select: none;
`;
export default function Checkbox({
label,
labelHidden,
note,
className,
small,
...rest
}: Props) {
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
return (
<>
<Wrapper small={small} className={className}>
<Label>
<input type="checkbox" {...rest} />
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
</Label>
{note && <HelpText small>{note}</HelpText>}
</Wrapper>
</>
);
}

View File

@@ -7,7 +7,7 @@ import Arrow from "~/components/Arrow";
type Props = {
direction: "left" | "right";
style?: React.CSSProperties;
onClick?: () => any;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
const Toggle = React.forwardRef<HTMLButtonElement, Props>(

104
app/components/Toggle.tsx Normal file
View File

@@ -0,0 +1,104 @@
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "~/components/HelpText";
export type Props = {
checked?: boolean;
label?: React.ReactNode;
labelHidden?: boolean;
className?: string;
name?: string;
disabled?: boolean;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
note?: React.ReactNode;
};
const LabelText = styled.span`
font-weight: 500;
margin-left: 10px;
`;
const Wrapper = styled.div`
padding-bottom: 8px;
width: 100%;
`;
const Label = styled.label`
display: flex;
align-items: center;
user-select: none;
`;
const SlideToggle = styled.label`
cursor: pointer;
text-indent: -9999px;
width: 26px;
height: 14px;
background: ${(props) => props.theme.slate};
display: block;
border-radius: 10px;
position: relative;
&:after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
background: ${(props) => props.theme.white};
border-radius: 5px;
transition: width 100ms ease-in-out;
}
&:active:after {
width: 12px;
}
`;
const HiddenInput = styled.input`
height: 0;
width: 0;
visibility: hidden;
&:checked + ${SlideToggle} {
background: ${(props) => props.theme.primary};
}
&:checked + ${SlideToggle}:after {
left: calc(100% - 2px);
transform: translateX(-100%);
}
`;
let inputId = 0;
export default function Toggle({
label,
labelHidden,
note,
className,
...rest
}: Props) {
const wrappedLabel = <LabelText>{label}</LabelText>;
const [id] = React.useState(`checkbox-input-${inputId++}`);
return (
<>
<Wrapper className={className}>
<Label>
<HiddenInput type="checkbox" id={id} {...rest} />
<SlideToggle htmlFor={id} />
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
</Label>
{note && <HelpText small>{note}</HelpText>}
</Wrapper>
</>
);
}

View File

@@ -25,6 +25,7 @@ import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
@@ -33,9 +34,11 @@ import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Toggle from "~/components/Toggle";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -52,7 +55,8 @@ type Props = {
document: Document;
className?: string;
isRevision?: boolean;
showPrint?: boolean;
/** Pass true if the document is currently being displayed */
showDisplayOptions?: boolean;
modal?: boolean;
showToggleEmbeds?: boolean;
showPin?: boolean;
@@ -67,7 +71,7 @@ function DocumentMenu({
className,
modal = true,
showToggleEmbeds,
showPrint,
showDisplayOptions,
showPin,
label,
onOpen,
@@ -448,11 +452,26 @@ function DocumentMenu({
type: "button",
title: t("Print"),
onClick: handlePrint,
visible: !!showPrint,
visible: !!showDisplayOptions,
icon: <PrintIcon />,
},
]}
/>
{showDisplayOptions && (
<>
<Separator />
<Style>
<ToggleMenuItem
label={t("Full width")}
checked={document.fullWidth}
onChange={(ev) => {
document.fullWidth = ev.target.checked;
document.save();
}}
/>
</Style>
</>
)}
</ContextMenu>
{renderModals && (
<>
@@ -516,6 +535,21 @@ function DocumentMenu({
);
}
const ToggleMenuItem = styled(Toggle)`
span {
font-weight: normal;
}
`;
const Style = styled.div`
padding: 12px;
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
`};
`;
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;

View File

@@ -50,6 +50,10 @@ export default class Document extends BaseModel {
@observable
template: boolean;
@Field
@observable
fullWidth: boolean;
@Field
@observable
templateId: string | undefined;
@@ -311,6 +315,7 @@ export default class Document extends BaseModel {
{
id: this.id,
title: options.title || this.title,
fullWidth: this.fullWidth,
},
{
lastRevision: options.lastRevision,
@@ -327,7 +332,7 @@ export default class Document extends BaseModel {
};
@action
save = async (options: SaveOptions | undefined) => {
save = async (options?: SaveOptions | undefined) => {
if (this.isSaving) return this;
const isCreating = !this.id;
this.isSaving = true;
@@ -349,24 +354,21 @@ export default class Document extends BaseModel {
);
}
if (options?.lastRevision) {
return await this.store.update(
{
id: this.id,
title: this.title,
text: this.text,
templateId: this.templateId,
},
{
lastRevision: options?.lastRevision,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
}
);
}
throw new Error("Attempting to update without a lastRevision");
return await this.store.update(
{
id: this.id,
title: this.title,
text: this.text,
fullWidth: this.fullWidth,
templateId: this.templateId,
},
{
lastRevision: options?.lastRevision || this.revision,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
}
);
} finally {
this.isSaving = false;
}

View File

@@ -7,6 +7,7 @@ import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
const HEADING_OFFSET = 20;
type Props = {
isFullWidth: boolean;
headings: {
title: string;
level: number;
@@ -14,7 +15,7 @@ type Props = {
}[];
};
export default function Contents({ headings }: Props) {
export default function Contents({ headings, isFullWidth }: Props) {
const [activeSlug, setActiveSlug] = React.useState<string>();
const position = useWindowScrollPosition({
throttle: 100,
@@ -49,8 +50,8 @@ export default function Contents({ headings }: Props) {
const headingAdjustment = minHeading - 1;
return (
<div>
<Wrapper>
<Wrapper isFullWidth={isFullWidth}>
<Sticky>
<Heading>Contents</Heading>
{headings.length ? (
<List>
@@ -67,30 +68,38 @@ export default function Contents({ headings }: Props) {
) : (
<Empty>Headings you add to the document will appear here</Empty>
)}
</Wrapper>
</div>
</Sticky>
</Wrapper>
);
}
const Wrapper = styled.div`
const Wrapper = styled.div<{ isFullWidth: boolean }>`
width: 256px;
display: none;
${breakpoint("tablet")`
display: block;
`};
${(props) =>
!props.isFullWidth &&
breakpoint("desktopLarge")`
transform: translateX(-256px);
width: 0;
`}
`;
const Sticky = styled.div`
position: sticky;
top: 80px;
max-height: calc(100vh - 80px);
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
margin-right: 3.2em;
margin-right: 32px;
width: 224px;
min-height: 40px;
overflow-y: auto;
${breakpoint("desktopLarge")`
margin-left: -16em;
`};
${breakpoint("tablet")`
display: block;
`};
`;
const Heading = styled.h3`

View File

@@ -493,6 +493,7 @@ class DocumentScene extends React.Component<Props> {
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
@@ -544,7 +545,12 @@ class DocumentScene extends React.Component<Props> {
)}
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
{showContents && (
<Contents
headings={headings}
isFullWidth={document.fullWidth}
/>
)}
<Editor
id={document.id}
key={disableEmbeds ? "disabled" : "enabled"}
@@ -628,6 +634,7 @@ const ReferencesWrapper = styled.div<{ isOnlyTitle?: boolean }>`
type MaxWidthProps = {
isEditing?: boolean;
isFullWidth?: boolean;
archived?: boolean;
showContents?: boolean;
};
@@ -636,22 +643,23 @@ const MaxWidth = styled(Flex)<MaxWidthProps>`
${(props) =>
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
// Adds space to the gutter to make room for heading annotations on mobile
// Adds space to the gutter to make room for heading annotations
padding: 0 32px;
transition: padding 100ms;
max-width: 100vw;
width: 100%;
${breakpoint("tablet")`
padding: 0 24px;
margin: 4px auto 12px;
max-width: calc(48px + ${(props: MaxWidthProps) =>
props.showContents ? "64em" : "46em"});
max-width: ${(props: MaxWidthProps) =>
props.isFullWidth
? "100vw"
: `calc(64px + 46em + ${props.showContents ? "256px" : "0px"});`}
`};
${breakpoint("desktopLarge")`
max-width: calc(48px + 52em);
max-width: ${(props: MaxWidthProps) =>
props.isFullWidth ? "100vw" : `calc(64px + 52em);`}
`};
`;

View File

@@ -306,7 +306,7 @@ function DocumentHeader({
/>
)}
showToggleEmbeds={canToggleEmbeds}
showPrint
showDisplayOptions
/>
</Action>
</>

View File

@@ -4,10 +4,10 @@ import { BeakerIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Checkbox from "~/components/Checkbox";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import Scene from "~/components/Scene";
import Toggle from "~/components/Toggle";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -52,7 +52,7 @@ function Features() {
the experience for all team members.
</Trans>
</HelpText>
<Checkbox
<Toggle
label={t("Collaborative editing")}
name="collaborativeEditing"
checked={data.collaborativeEditing}

View File

@@ -4,11 +4,11 @@ import { PadlockIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Checkbox from "~/components/Checkbox";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Toggle from "~/components/Toggle";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
@@ -69,7 +69,7 @@ function Security() {
</Trans>
</HelpText>
<Checkbox
<Toggle
label={t("Allow email authentication")}
name="guestSignin"
checked={data.guestSignin}
@@ -81,7 +81,7 @@ function Security() {
}
disabled={!env.EMAIL_ENABLED}
/>
<Checkbox
<Toggle
label={t("Public document sharing")}
name="sharing"
checked={data.sharing}
@@ -90,7 +90,7 @@ function Security() {
"When enabled, documents can be shared publicly on the internet by any team member"
)}
/>
<Checkbox
<Toggle
label={t("Rich service embeds")}
name="documentEmbeds"
checked={data.documentEmbeds}

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import NotificationSetting from "~/models/NotificationSetting";
import Checkbox from "~/components/Checkbox";
import Toggle from "~/components/Toggle";
type Props = {
setting?: NotificationSetting;
@@ -20,7 +20,7 @@ const NotificationListItem = ({
description,
}: Props) => {
return (
<Checkbox
<Toggle
label={title}
name={event}
checked={!!setting}

View File

@@ -8,12 +8,12 @@ import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
import Checkbox from "~/components/Checkbox";
import CollectionIcon from "~/components/CollectionIcon";
import Flex from "~/components/Flex";
import HelpText from "~/components/HelpText";
import ListItem from "~/components/List/Item";
import Popover from "~/components/Popover";
import Toggle from "~/components/Toggle";
import useToasts from "~/hooks/useToasts";
type Props = {
@@ -82,13 +82,13 @@ function SlackListItem({ integration, collection }: Props) {
<Events>
<h3>{t("Notifications")}</h3>
<HelpText>{t("These events should be posted to Slack")}</HelpText>
<Checkbox
<Toggle
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange}
/>
<Checkbox
<Toggle
label={t("Document updated")}
name="documents.update"
checked={integration.events.includes("documents.update")}

View File

@@ -643,6 +643,7 @@ export default class DocumentsStore extends BaseStore<Document> {
id: string;
title: string;
text?: string;
fullWidth?: boolean;
templateId?: string;
},
options?: {

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("documents", "fullWidth", {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("documents", "fullWidth");
},
};

View File

@@ -81,6 +81,7 @@ const Document = sequelize.define(
previousTitles: DataTypes.ARRAY(DataTypes.STRING),
version: DataTypes.SMALLINT,
template: DataTypes.BOOLEAN,
fullWidth: DataTypes.BOOLEAN,
editorVersion: DataTypes.STRING,
text: DataTypes.TEXT,
state: DataTypes.BLOB,

View File

@@ -1,4 +1,4 @@
import { Attachment, Document } from "@server/models";
import { Attachment } from "@server/models";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { getSignedUrl } from "@server/utils/s3";
import presentUser from "./user";
@@ -25,80 +25,58 @@ async function replaceImageAttachments(text: string) {
}
export default async function present(
document: Document,
document: any,
options: Options | null | undefined
) {
options = {
isPublic: false,
...options,
};
// @ts-expect-error ts-migrate(2339) FIXME: Property 'migrateVersion' does not exist on type '... Remove this comment to see the full error message
await document.migrateVersion();
const text = options.isPublic
? // @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
await replaceImageAttachments(document.text)
: // @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
document.text;
? await replaceImageAttachments(document.text)
: document.text;
const data = {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
id: document.id,
// @ts-expect-error ts-migrate(2551) FIXME: Property 'url' does not exist on type 'Document'. ... Remove this comment to see the full error message
url: document.url,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'urlId' does not exist on type 'Document'... Remove this comment to see the full error message
urlId: document.urlId,
title: document.title,
text,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'emoji' does not exist on type 'Document'... Remove this comment to see the full error message
emoji: document.emoji,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'tasks' does not exist on type 'Document'... Remove this comment to see the full error message
tasks: document.tasks,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'createdAt' does not exist on type 'Docum... Remove this comment to see the full error message
createdAt: document.createdAt,
createdBy: undefined,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type 'Docum... Remove this comment to see the full error message
updatedAt: document.updatedAt,
updatedBy: undefined,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'publishedAt' does not exist on type 'Doc... Remove this comment to see the full error message
publishedAt: document.publishedAt,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'archivedAt' does not exist on type 'Docu... Remove this comment to see the full error message
archivedAt: document.archivedAt,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'deletedAt' does not exist on type 'Docum... Remove this comment to see the full error message
deletedAt: document.deletedAt,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
teamId: document.teamId,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'template' does not exist on type 'Docume... Remove this comment to see the full error message
template: document.template,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'templateId' does not exist on type 'Docu... Remove this comment to see the full error message
templateId: document.templateId,
collaboratorIds: [],
// @ts-expect-error ts-migrate(2339) FIXME: Property 'starred' does not exist on type 'Documen... Remove this comment to see the full error message
starred: document.starred ? !!document.starred.length : undefined,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'revisionCount' does not exist on type 'D... Remove this comment to see the full error message
revision: document.revisionCount,
fullWidth: document.fullWidth,
pinned: undefined,
collectionId: undefined,
parentDocumentId: undefined,
lastViewedAt: undefined,
};
// @ts-expect-error ts-migrate(2339) FIXME: Property 'views' does not exist on type 'Document'... Remove this comment to see the full error message
if (!!document.views && document.views.length > 0) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'views' does not exist on type 'Document'... Remove this comment to see the full error message
data.lastViewedAt = document.views[0].updatedAt;
}
if (!options.isPublic) {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean' is not assignable to type 'undefine... Remove this comment to see the full error message
data.pinned = !!document.pinnedById;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
data.collectionId = document.collectionId;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
data.parentDocumentId = document.parentDocumentId;
// @ts-expect-error ts-migrate(2322) FIXME: Type 'UserPresentation | null | undefined' is not ... Remove this comment to see the full error message
data.createdBy = presentUser(document.createdBy);
// @ts-expect-error ts-migrate(2322) FIXME: Type 'UserPresentation | null | undefined' is not ... Remove this comment to see the full error message
data.updatedBy = presentUser(document.updatedBy);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collaboratorIds' does not exist on type ... Remove this comment to see the full error message
data.collaboratorIds = document.collaboratorIds;
}

View File

@@ -1106,6 +1106,7 @@ router.post("documents.update", auth(), async (ctx) => {
id,
title,
text,
fullWidth,
publish,
autosave,
done,
@@ -1128,10 +1129,12 @@ router.post("documents.update", auth(), async (ctx) => {
}
const previousTitle = document.title;
// Update document
if (title) document.title = title;
if (editorVersion) document.editorVersion = editorVersion;
if (templateId) document.templateId = templateId;
if (fullWidth !== undefined) document.fullWidth = fullWidth;
if (!user.team?.collaborativeEditing) {
if (append) {

View File

@@ -241,6 +241,7 @@
"Move": "Move",
"Enable embeds": "Enable embeds",
"Disable embeds": "Disable embeds",
"Full width": "Full width",
"Move {{ documentName }}": "Move {{ documentName }}",
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
"Export options": "Export options",