diff --git a/app/components/Checkbox.tsx b/app/components/Checkbox.tsx deleted file mode 100644 index ecf01d422..000000000 --- a/app/components/Checkbox.tsx +++ /dev/null @@ -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) => 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 = {label}; - - return ( - <> - - - {note && {note}} - - - ); -} diff --git a/app/components/Sidebar/components/Toggle.tsx b/app/components/Sidebar/components/Toggle.tsx index ec7f5e7f5..965ac1273 100644 --- a/app/components/Sidebar/components/Toggle.tsx +++ b/app/components/Sidebar/components/Toggle.tsx @@ -7,7 +7,7 @@ import Arrow from "~/components/Arrow"; type Props = { direction: "left" | "right"; style?: React.CSSProperties; - onClick?: () => any; + onClick?: React.MouseEventHandler; }; const Toggle = React.forwardRef( diff --git a/app/components/Toggle.tsx b/app/components/Toggle.tsx new file mode 100644 index 000000000..ef1375771 --- /dev/null +++ b/app/components/Toggle.tsx @@ -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) => 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 = {label}; + const [id] = React.useState(`checkbox-input-${inputId++}`); + + return ( + <> + + + {note && {note}} + + + ); +} diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index df8985d52..881a7acfd 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -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: , }, ]} /> + {showDisplayOptions && ( + <> + + + + )} {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; diff --git a/app/models/Document.ts b/app/models/Document.ts index ebd0dbf04..0a72e9d0c 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -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; } diff --git a/app/scenes/Document/components/Contents.tsx b/app/scenes/Document/components/Contents.tsx index a5821086d..ca18242db 100644 --- a/app/scenes/Document/components/Contents.tsx +++ b/app/scenes/Document/components/Contents.tsx @@ -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(); const position = useWindowScrollPosition({ throttle: 100, @@ -49,8 +50,8 @@ export default function Contents({ headings }: Props) { const headingAdjustment = minHeading - 1; return ( -
- + + Contents {headings.length ? ( @@ -67,30 +68,38 @@ export default function Contents({ headings }: Props) { ) : ( Headings you add to the document will appear here )} - -
+ + ); } -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` diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index c7fc2399d..038bd1df9 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -493,6 +493,7 @@ class DocumentScene extends React.Component { archived={document.isArchived} showContents={showContents} isEditing={!readOnly} + isFullWidth={document.fullWidth} column auto > @@ -544,7 +545,12 @@ class DocumentScene extends React.Component { )} }> - {showContents && } + {showContents && ( + + )} ` type MaxWidthProps = { isEditing?: boolean; + isFullWidth?: boolean; archived?: boolean; showContents?: boolean; }; @@ -636,22 +643,23 @@ const MaxWidth = styled(Flex)` ${(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);`} `}; `; diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 6cff47b45..d073828f9 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -306,7 +306,7 @@ function DocumentHeader({ /> )} showToggleEmbeds={canToggleEmbeds} - showPrint + showDisplayOptions /> diff --git a/app/scenes/Settings/Features.tsx b/app/scenes/Settings/Features.tsx index 8d5e16b6e..a9d157292 100644 --- a/app/scenes/Settings/Features.tsx +++ b/app/scenes/Settings/Features.tsx @@ -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. - - - - { return ( -

{t("Notifications")}

{t("These events should be posted to Slack")} - - { id: string; title: string; text?: string; + fullWidth?: boolean; templateId?: string; }, options?: { diff --git a/server/migrations/20211218193004-documents-full-width.js b/server/migrations/20211218193004-documents-full-width.js new file mode 100644 index 000000000..1851ef922 --- /dev/null +++ b/server/migrations/20211218193004-documents-full-width.js @@ -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"); + }, +}; diff --git a/server/models/Document.ts b/server/models/Document.ts index 56cec8a76..1312783c4 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -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, diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 0d0e20a9d..2780fca2d 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -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; } diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts index 4785be063..6bed62fcd 100644 --- a/server/routes/api/documents.ts +++ b/server/routes/api/documents.ts @@ -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) { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 0824b7ebb..a0d2c6893 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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",