Skip to content

Commit

Permalink
#9338 Add link toolbar action (#9445)
Browse files Browse the repository at this point in the history
Co-authored-by: Graham Langford <[email protected]>
Co-authored-by: Graham Langford <[email protected]>
  • Loading branch information
3 people authored Nov 12, 2024
1 parent f427855 commit 0571b08
Show file tree
Hide file tree
Showing 16 changed files with 645 additions and 91 deletions.
1 change: 1 addition & 0 deletions applications/browser-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@rjsf/core": "^5.22.3",
"@rjsf/utils": "^5.22.3",
"@szhsin/react-menu": "^4.2.2",
"@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,35 +41,3 @@
outline: 0;
}
}

.toolbarButtons {
:global(.btn-group) {
flex-direction: row;
}

:global(.btn) {
&:global(.active) {
color: #007bff;
}

&:not([disabled]):hover {
color: #0056b3;
}

&:focus {
outline: none;
box-shadow: none;
}
}

:global(.heading-level__control) {
border: none;
box-shadow: none;
}
}

.toolbar {
border-bottom: 1px solid #ced4da;

@extend .toolbarButtons;
}
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,30 @@ describe("RichTextEditor", () => {

expect(editor?.innerHTML).toBe("<p>regular<s>struck through</s></p>");
});

// User.pointer is successfully selecting the test, but Tiptap isn't seeing it
// eslint-disable-next-line jest/no-disabled-tests -- TODO: playwright test for this when RichTextEditor is complete
test.skip("applies link formatting using toolbar button", async () => {
render(<RichTextEditor />);

const editor = screen.getByRole("textbox");
await user.type(editor, "This is my link");

await user.pointer([
// First char of "link"
{ target: editor, offset: 11, keys: "[MouseLeft>]" },
// Drag to end of "link"
{ offset: 15 },
{ keys: "[/MouseLeft]" },
]);

const selection = document.getSelection()?.toString();
expect(selection).toBe("link");

await user.click(screen.getByRole("button", { name: /link/i }));

expect(
screen.getByRole("textbox", { name: /newurl/i }),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import styles from "./RichTextEditor.module.scss";
import { EditorProvider, type EditorProviderProps } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import React from "react";
import Toolbar from "@/components/richTextEditor/toolbar/Toolbar";

Expand All @@ -27,7 +28,11 @@ const RichTextEditor: React.FunctionComponent<EditorProviderProps> = (
) => (
<div className={styles.root}>
<EditorProvider
extensions={[StarterKit, Underline]}
extensions={[
StarterKit,
Underline,
Link.extend({ inclusive: false }).configure({ openOnClick: false }),
]}
slotBefore={<Toolbar />}
{...props}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*!
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "@/themes/colors";

.linkPopover {
font-size: 1rem;
padding: 4px 8px;
max-width: none;

:global(.form-label) {
margin: 0 8px 0 0;
text-wrap: nowrap;
}

:global(.form-control) {
height: 26px;
width: 150px;
margin: 0 8px 0 0;
}

:global(.btn) {
padding: 0;
}

span {
color: $N800;
}

label {
color: $N800;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { type Dispatch, type SetStateAction, useCallback } from "react";
import {
type PopoverState,
POPOVER_VIEW,
} from "@/components/richTextEditor/toolbar/LinkButton/types";
import { assertNotNullish } from "@/utils/nullishUtils";
import { useCurrentEditor } from "@tiptap/react";
// eslint-disable-next-line no-restricted-imports -- Not a schema-driven form
import { Formik } from "formik";
// eslint-disable-next-line no-restricted-imports -- Not a schema-driven form
import { Form, Button } from "react-bootstrap";

const LinkEditForm: React.FC<{
initialHref: string;
setPopoverState: Dispatch<SetStateAction<PopoverState>>;
}> = ({ initialHref, setPopoverState }) => {
const { editor } = useCurrentEditor();
assertNotNullish(editor, "Tiptap editor must be in scope");

const onSubmit = useCallback(
(url: string) => {
if (!url || url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}

setPopoverState({
showPopover: false,
popoverView: POPOVER_VIEW.editForm,
});
},
[editor, setPopoverState],
);

return (
<Formik
initialValues={{ newUrl: initialHref }}
onSubmit={(values) => {
onSubmit(values.newUrl);
}}
>
{({ handleSubmit, handleChange, handleBlur, values }) => (
<Form inline onSubmit={handleSubmit}>
<Form.Label htmlFor="newUrl">Enter link:</Form.Label>
<Form.Control
id="newUrl"
name="newUrl"
size="sm"
onChange={handleChange}
onBlur={handleBlur}
value={values.newUrl}
/>
<Button variant="link" type="submit" size="sm">
Submit
</Button>
</Form>
)}
</Formik>
);
};

export default LinkEditForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { truncate } from "lodash";
import React from "react";
import { Button } from "react-bootstrap";

const LinkPreviewActions: React.FC<{
href: string;
onEdit: () => void;
onRemove: () => void;
}> = ({ href, onEdit, onRemove }) => (
<span className="d-flex align-items-center">
<span className="text-nowrap mr-1">Visit url:</span>
<a href={href} target="_blank" rel="noopener noreferrer" className="mr-2">
{truncate(href, {
length: 20,
omission: "...",
})}
</a>
<Button variant="link" onClick={onEdit} className="mr-2">
Edit
</Button>
<Button variant="link" onClick={onRemove}>
Remove
</Button>
</span>
);

export default LinkPreviewActions;
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { type Dispatch, type SetStateAction } from "react";
import LinkEditForm from "@/components/richTextEditor/toolbar/LinkButton/LinkEditForm";
import LinkPreviewActions from "@/components/richTextEditor/toolbar/LinkButton/LinkPreviewActions";
import {
type PopoverState,
POPOVER_VIEW,
} from "@/components/richTextEditor/toolbar/LinkButton/types";
import { assertNotNullish } from "@/utils/nullishUtils";
import { useCurrentEditor } from "@tiptap/react";

const UrlInputPopover = ({
setPopoverState,
popoverView,
}: {
setPopoverState: Dispatch<SetStateAction<PopoverState>>;
popoverView: PopoverState["popoverView"];
}) => {
const { editor } = useCurrentEditor();
assertNotNullish(editor, "Tiptap editor must be in scope");

switch (popoverView) {
case POPOVER_VIEW.linkPreview: {
return (
<LinkPreviewActions
href={editor.getAttributes("link").href as string}
onEdit={() => {
setPopoverState({
showPopover: true,
popoverView: POPOVER_VIEW.editForm,
});
}}
onRemove={() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setPopoverState({
showPopover: false,
popoverView: POPOVER_VIEW.linkPreview,
});
}}
/>
);
}

case POPOVER_VIEW.editForm: {
return (
<LinkEditForm
initialHref={(editor.getAttributes("link").href as string) ?? ""}
setPopoverState={setPopoverState}
/>
);
}

default: {
return null;
}
}
};

export default UrlInputPopover;
Loading

0 comments on commit 0571b08

Please sign in to comment.