Compare commits

...

17 Commits

Author SHA1 Message Date
hmacr f06c1d95fa rename source owner, remove actorId 2025-04-13 20:29:55 +05:30
hmacr c9c5e86b72 BaseIssueProvider class 2025-04-13 19:43:29 +05:30
hmacr 84f46e0f96 cache issue sources 2025-04-13 19:43:14 +05:30
hmacr 3219cf7dbe recent issue sources 2025-04-13 19:43:01 +05:30
hmacr f00bec87d7 issues ui and editor 2025-04-13 19:42:50 +05:30
hmacr 41c8d664b2 issues api and plugin 2025-04-13 19:42:15 +05:30
Hemachandar bf6a56849e Show GitHub issues and pull requests as mentions (#8870)
* mention issue works

* pr and loading works

* error node

* tweak mention display

* handle multiple creation error

* tidy

* store unfurl in mention attrs

* simplify mention code creation

* test fix

* base feedback

* update node when pos is available

* delete local UnfurlsStore

* use unfurl from store

* Optimize lodash isMatch import statement

* fix: Copy/paste of issue mentions
fix: Icon alignment
fix: Error and loading mentions are unselectable

* Switch order in paste menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-13 06:09:13 -07:00
Tom Moor 68e8b2791a fix: Line numbers flash in on load (#8948)
fix: Text color of plain text and markdown code blocks
2025-04-12 18:25:15 -07:00
Tom Moor 89db519b72 Replace embed icon (#8947) 2025-04-12 19:40:08 +00:00
codegen-sh[bot] 31c412b4a6 refactor: Convert ImageUpload component to functional (#8944)
* refactor: Convert ImageUpload component to functional

* fix: Fix linting issues by removing trailing whitespace and unused imports

* Applied automatic fixes

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-12 19:26:38 +00:00
Tom Moor 199584428a fix: Markdown copy should not occur for single node situations (#8946) 2025-04-12 12:15:52 -07:00
Tom Moor f22780e944 Move editor syntax highlighting to async (#8934)
* Move editor syntax highlighting to async, add a bunch more languages

* Remove vestigial referenecs to Prism

* fix: bundle-size job not triggering

* Add webpackStatsFile
2025-04-12 10:55:47 -07:00
dependabot[bot] a71381785c chore(deps): bump vite from 5.4.17 to 5.4.18 (#8941)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.17 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-12 10:55:27 -07:00
Tom Moor a61b53aa74 Require collection manage permissions to export (#8942) 2025-04-12 10:55:15 -07:00
Tom Moor 45f0885533 fix: bundle-size CI (#8940) 2025-04-12 10:07:48 -07:00
Hemachandar 9c9657f4cc eslint: Increase severity to error for lodash imports (#8932) 2025-04-11 17:56:28 -07:00
Tom Moor c3de0cf0ec v0.83.0 (#8928) 2025-04-10 19:53:08 -07:00
60 changed files with 1786 additions and 473 deletions
+1 -1
View File
@@ -65,7 +65,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"lodash/import-scope": ["error", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+4 -2
View File
@@ -63,6 +63,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
@@ -81,7 +82,7 @@ jobs:
- 'yarn.lock'
test:
needs: build
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -143,7 +144,7 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -161,3 +162,4 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.0
Licensed Work: Outline 0.83.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-02-15
Change Date: 2029-04-11
Change License: Apache License, Version 2.0
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
+1 -71
View File
@@ -1,73 +1,3 @@
import styled, { css } from "styled-components";
import { ellipsis } from "@shared/styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
import Text from "@shared/components/Text";
export default Text;
+192
View File
@@ -0,0 +1,192 @@
import { IssueSource } from "@shared/schema";
import { ellipsis } from "@shared/styles";
import { observer } from "mobx-react";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import PluginIcon from "~/components/PluginIcon";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useRequest from "~/hooks/useRequest";
import { client } from "~/utils/ApiClient";
type Props = {
issueTitle: string;
open: boolean;
onCreate: (source: IssueSource) => Promise<void>;
onClose: () => void;
};
export const CreateIssueDialog = observer(
({ issueTitle, open, onCreate, onClose }: Props) => {
const { t } = useTranslation();
const [isCreating, setCreating, unsetCreating] = useBoolean();
const [selectedSource, selectSource] = React.useState<IssueSource>();
const {
data: sources,
loading,
request,
} = useRequest<IssueSource[]>(
React.useCallback(async () => {
try {
const res = await client.post("/issues.list_sources");
return res.data;
} catch (err) {
toast.error(t("Couldn't load issue sources, try again?"));
throw err;
}
}, [t])
);
const handleCreateIssue = React.useCallback(async () => {
setCreating();
await onCreate(selectedSource!);
unsetCreating();
}, [selectedSource, onCreate, setCreating, unsetCreating]);
React.useEffect(() => {
if (open) {
void request();
} else {
selectSource(undefined);
}
}, [open, request]);
return (
<Modal
title={t("Create issue")}
isOpen={open}
onRequestClose={onClose}
fullscreen={false}
>
<FlexContainer column>
<ListContainer>
{loading ? (
"Loading..."
) : !sources?.length ? (
"No source available"
) : (
<Flex column gap={6}>
{sources.map((source) => (
<SourceItem
key={source.id}
source={source}
selected={source === selectedSource}
onSelect={selectSource}
/>
))}
</Flex>
)}
</ListContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedSource ? (
<Trans
defaults="Create issue in <em>{{ location }}</em>"
values={{
location: `${selectedSource.owner.name}/${selectedSource.name} `,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a source to create issue")
)}
</StyledText>
<Button
disabled={!selectedSource || isCreating}
onClick={handleCreateIssue}
>
{isCreating ? `${t("Creating")}` : t("Create")}
</Button>
</Footer>
</FlexContainer>
</Modal>
);
}
);
const SourceItem = ({
source,
selected,
onSelect,
}: {
source: IssueSource;
selected: boolean;
onSelect: (source: IssueSource) => void;
}) => (
<SourceItemWrapper
justify="space-between"
onClick={() => onSelect(source)}
$selected={selected}
>
<Text>{source.name}</Text>
<Flex align="center" gap={2}>
<PluginIcon id={source.service} size={20} />
<SourceAccount type="tertiary" size="xsmall">
{source.owner.name}
</SourceAccount>
</Flex>
</SourceItemWrapper>
);
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const ListContainer = styled.div`
height: 65vh;
padding: 0 24px 12px;
overflow: scroll;
${breakpoint("tablet")`
height: 40vh;
`}
`;
const SourceAccount = styled(Text)``;
const SourceItemWrapper = styled(Flex)<{ $selected: boolean }>`
font-size: 16px;
cursor: var(--pointer);
padding: 12px;
border-radius: 6px;
${(props) =>
props.$selected &&
`
background: ${props.theme.accent};
color: ${props.theme.white};
${SourceAccount} {
color: ${props.theme.white};
}
`}
${breakpoint("tablet")`
padding: 4px 6px;
font-size: 15px;
`}
`;
const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding: 0 24px;
`;
const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
+59 -24
View File
@@ -1,7 +1,15 @@
import { LinkIcon } from "outline-icons";
import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -15,34 +23,65 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser();
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
}, [embeds, pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
() =>
[
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention",
title: t("Mention"),
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
actorId: user.id,
},
appendSpace: true,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
] satisfies MenuItem[],
[t, embed, mentionType, pastedText, user]
);
return (
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
items={items}
/>
);
};
export default PasteMenu;
});
+9 -1
View File
@@ -9,6 +9,7 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { useRecentIssueSources } from "~/editor/hooks/useRecentIssueSources";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -106,6 +107,7 @@ export default function SelectionToolbar(props: Props) {
const isActive = useIsActive(view.state) || isMobile;
const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive);
const { issueSources: recentIssueSources } = useRecentIssueSources();
React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed
@@ -213,7 +215,13 @@ export default function SelectionToolbar(props: Props) {
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
items = getFormattingMenuItems(
state,
isTemplate,
isMobile,
recentIssueSources,
dictionary
);
}
// Some extensions may be disabled, remove corresponding items
@@ -1,5 +1,6 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) => {
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
+303
View File
@@ -0,0 +1,303 @@
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
import { isInCode } from "@shared/editor/queries/isInCode";
import { IssueSource } from "@shared/schema";
import {
MentionPlaceholder,
MentionType,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { t } from "i18next";
import { action, observable } from "mobx";
import {
Command,
EditorState,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import React from "react";
import { toast } from "sonner";
import { Primitive } from "utility-types";
import { v4 } from "uuid";
import stores from "~/stores";
import { client } from "~/utils/ApiClient";
import { CreateIssueDialog } from "../components/CreateIssueDialog";
import { addRecentIssueSource } from "../hooks/useRecentIssueSources";
export default class CreateIssue extends Extension {
private state: {
open: boolean;
title: string;
} = observable({
open: false,
title: "",
});
private key = new PluginKey(this.name);
get name() {
return "issue";
}
get plugins() {
return [
new Plugin({
key: this.key,
state: {
init: () => DecorationSet.empty,
apply: (tr, set) => {
// See if the transaction adds, replaces, or removes any placeholders.
const meta = tr.getMeta(this.key);
// We only want a single paste placeholder at a time, so if we're adding a new
// placeholder we can just return a new DecorationSet and avoid mapping logic.
if (meta?.add) {
const { from, to, id } = meta.add;
const decorations = [
Decoration.inline(
from,
to,
{},
{
id,
}
),
];
return DecorationSet.create(tr.doc, decorations);
}
let mapping = tr.mapping;
const hasDecorations = set.find().length;
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
try {
mapping = recreateTransform(tr.before, tr.doc, {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
}).mapping;
} catch (err) {
// eslint-disable-next-line no-console
console.warn("Failed to recreate transform: ", err);
}
}
set = set.map(mapping, tr.doc);
if (meta?.replace) {
const { id } = meta.replace;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return DecorationSet.create(tr.doc, decorations);
}
if (meta?.remove) {
const { id } = meta.remove;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return set.remove(decorations);
}
return set;
},
},
}),
];
}
keys(): Record<string, Command> {
return {
"Mod-Alt-i": (state, dispatch) => {
const isCode = isInCode(state);
const isEmpty = state.selection.empty;
if (isCode || isEmpty) {
return false;
}
const title = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
const { from } = state.selection;
const to = from + title.length;
const tr = state.tr
.setSelection(TextSelection.near(state.doc.resolve(from)))
.setMeta(this.key, { add: { from, to, id: title } });
dispatch?.(tr);
this.openDialog(title);
return true;
},
};
}
commands() {
return (attrs: Record<string, Primitive>): Command =>
action((state, dispatch) => {
const title = attrs.title as string;
const source = attrs.source
? (JSON.parse(attrs.source as string) as IssueSource)
: undefined;
this.state.title = title;
const { from } = state.selection;
const to = from + title.length;
const tr = state.tr
.setSelection(TextSelection.near(state.doc.resolve(to)))
.setMeta(this.key, { add: { from, to, id: title } });
dispatch?.(tr);
if (source) {
tr.replaceWith(
from,
to,
state.schema.nodes.mention.create({
id: v4(),
type: MentionPlaceholder,
label: title,
href: title,
modelId: v4(),
actorId: stores.auth.currentUserId,
})
).setMeta(this.key, { replace: { id: title } });
}
dispatch?.(tr);
if (source) {
void this.createIssue(source);
} else {
this.openDialog(title);
}
return true;
});
}
widget = () => (
<CreateIssueDialog
issueTitle={this.state.title}
open={this.state.open}
onCreate={this.createIssue}
onClose={this.closeDialog}
/>
);
private createIssue = async (source: IssueSource) => {
try {
addRecentIssueSource(source);
const res = await client.post("/issues.create", {
title: this.state.title,
source,
});
this.addMentionNode(res.data);
toast.success(t("Issue created"));
} catch (err) {
this.removeDecorations(source);
toast.error(t("Couldnt create the issue, try again?"));
}
};
private addMentionNode = (
issue: UnfurlResponse[UnfurlResourceType.Issue]
) => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.title);
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr
.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
.setMeta(this.key, {
remove: { id: this.state.title },
})
);
}
this.editor.commands.mention({
id: v4(),
type: MentionType.Issue,
label: this.state.title,
href: issue.url,
modelId: v4(),
actorId: stores.auth.currentUserId,
});
};
private removeDecorations = action((source: IssueSource) => {
const { view } = this.editor;
const { state } = view;
const tr = state.tr.setMeta(this.key, {
remove: { id: this.state.title },
});
const result = this.findPlaceholder(state, this.state.title);
// Placeholder node would have been inserted in recent issue menu flow only.
// We want to reset it with the selected text.
if (source && result) {
tr.replaceWith(
result[0],
result[1],
state.schema.nodeFromJSON({ type: "text", text: this.state.title })
);
}
view.dispatch(tr);
this.state.title = "";
});
private openDialog = action((title: string) => {
this.state.title = title;
this.state.open = true;
});
private closeDialog = action(() => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.title);
if (result) {
const tr = state.tr
.setSelection(TextSelection.near(state.doc.resolve(result[0])))
.setMeta(this.key, {
remove: { id: this.state.title },
});
view.dispatch(tr);
}
this.state.title = "";
this.state.open = false;
});
private findPlaceholder = (
state: EditorState,
id: string
): [number, number] | null => {
const decos = this.key.getState(state) as DecorationSet;
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
return found?.length ? [found[0].from, found[0].to] : null;
};
}
+21 -1
View File
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
import { PasteMenu } from "../components/PasteMenu";
export default class PasteHandler extends Extension {
state: {
@@ -415,6 +415,21 @@ export default class PasteHandler extends Extension {
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
@@ -450,6 +465,11 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
+2
View File
@@ -3,6 +3,7 @@ import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import CreateIssueExtension from "~/editor/extensions/CreateIssue";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
CreateIssueExtension,
// Order these default key handlers last
PreventTab,
Keys,
+47
View File
@@ -0,0 +1,47 @@
import { IssueSource } from "@shared/schema";
import Storage from "@shared/utils/Storage";
import React from "react";
import usePersistedState, {
setPersistedState,
} from "~/hooks/usePersistedState";
const StorageKey = "recent-issue-sources";
const MaxCount = 5;
export function useRecentIssueSources() {
const [issueSources, setIssueSources] = usePersistedState<IssueSource[]>(
StorageKey,
[] as IssueSource[]
);
const addIssueSource = React.useCallback(
(source: IssueSource) => {
const newIssueSources = insertAndTrim(issueSources, source);
setIssueSources(newIssueSources);
},
[issueSources, setIssueSources]
);
return { issueSources, addIssueSource };
}
export function addRecentIssueSource(source: IssueSource) {
const issueSources: IssueSource[] = Storage.get(StorageKey) ?? [];
const newIssueSources = insertAndTrim(issueSources, source);
setPersistedState(StorageKey, newIssueSources);
}
function insertAndTrim(issueSources: IssueSource[], source: IssueSource) {
const newIssueSources = issueSources.filter((s) => s.id !== source.id);
newIssueSources.unshift(source);
if (newIssueSources.length > MaxCount) {
newIssueSources.pop();
}
return newIssueSources;
}
export type RecentIssueSourcesResponse = ReturnType<
typeof useRecentIssueSources
>;
+48 -1
View File
@@ -1,3 +1,4 @@
import { t } from "i18next";
import {
BoldIcon,
CodeIcon,
@@ -17,8 +18,9 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
PlusIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { EditorState, TextSelection } from "prosemirror-state";
import * as React from "react";
import styled from "styled-components";
import Highlight from "@shared/editor/marks/Highlight";
@@ -28,14 +30,17 @@ import { isInList } from "@shared/editor/queries/isInList";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { IssueSource } from "@shared/schema";
import { metaDisplay } from "@shared/utils/keyboard";
import CircleIcon from "~/components/Icons/CircleIcon";
import PluginIcon from "~/components/PluginIcon";
import { Dictionary } from "~/hooks/useDictionary";
export default function formattingMenuItems(
state: EditorState,
isTemplate: boolean,
isMobile: boolean,
recentIssueSources: IssueSource[],
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
@@ -43,12 +48,39 @@ export default function formattingMenuItems(
const isCodeBlock = isInCode(state, { onlyBlock: true });
const isEmpty = state.selection.empty;
const selectedText =
!isEmpty && state.selection instanceof TextSelection
? state.doc.cut(state.selection.from, state.selection.to).textContent
: undefined;
const highlight = getMarksBetween(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
const issueSourcesChildren = recentIssueSources.length
? recentIssueSources.map<MenuItem>((source) => ({
name: "issue",
label: `${source.owner.name}/${source.name}`,
icon: <PluginIcon id={source.service} />,
attrs: {
title: selectedText,
source: JSON.stringify(source),
},
}))
: undefined;
if (issueSourcesChildren) {
issueSourcesChildren.push({
name: "issue",
label: `${t("Other")}`,
attrs: {
title: selectedText,
},
});
}
return [
{
name: "placeholder",
@@ -259,6 +291,21 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
},
{
name: "separator",
visible: !isCode && !isEmpty,
},
{
name: "issue",
tooltip: dictionary.createIssue,
shortcut: `${metaDisplay}+⌥+I`,
icon: <PlusIcon />,
attrs: {
title: selectedText,
},
visible: !isCode && !!selectedText,
children: issueSourcesChildren,
},
];
}
+1
View File
@@ -27,6 +27,7 @@ export default function useDictionary() {
codeInline: t("Code"),
comment: t("Comment"),
copy: t("Copy"),
createIssue: t("Create issue from selection"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
+2 -18
View File
@@ -1,19 +1,3 @@
import * as React from "react";
import useIsMounted from "@shared/hooks/useIsMounted";
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
export default useIsMounted;
+3 -3
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import type {
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
type IntegrationSettings,
type IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import {
FileOperationFormat,
FileOperationState,
@@ -13,7 +14,6 @@ import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+90 -105
View File
@@ -1,19 +1,19 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef } from "react";
import AvatarEditor from "react-avatar-editor";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import ButtonLarge from "~/components/ButtonLarge";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import useStores from "~/hooks/useStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -24,41 +24,38 @@ export type Props = {
borderRadius?: number;
};
@observer
class ImageUpload extends React.Component<RootStore & Props> {
@observable
isUploading = false;
const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius = 150,
children,
}) => {
const { ui } = useStores();
const { t } = useTranslation();
submitText || t("Crop image");
@observable
isCropping = false;
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
const [zoom, setZoom] = useState(1);
const [file, setFile] = useState<File | null>(null);
@observable
zoom = 1;
const avatarEditorRef = useRef<AvatarEditor>(null);
@observable
file: File;
avatarEditorRef = React.createRef<AvatarEditor>();
static defaultProps = {
submitText: "Crop Image",
borderRadius: 150,
const onDropAccepted = async (files: File[]) => {
setIsCropping(true);
setFile(files[0]);
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = () => {
this.isUploading = true;
const handleCrop = () => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(this.uploadImage, 0);
setTimeout(uploadImage, 0);
};
uploadImage = async () => {
const canvas = this.avatarEditorRef.current?.getImage();
const uploadImage = async () => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
@@ -68,99 +65,87 @@ class ImageUpload extends React.Component<RootStore & Props> {
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: this.file.name,
name: file!.name,
preset: AttachmentPreset.Avatar,
});
void this.props.onSuccess(attachment.url);
void onSuccess(attachment.url);
} catch (err) {
this.props.onError(err.message);
onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
setIsUploading(false);
setIsCropping(false);
}
};
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
const handleClose = () => {
setIsUploading(false);
setIsCropping(false);
};
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
setZoom(parseFloat(target.value));
}
};
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal
onRequestClose={this.handleClose}
fullscreen={false}
title={<>&nbsp;</>}
isOpen
>
<Flex auto column align="center" justify="center">
{this.isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={this.avatarEditorRef}
image={this.file}
width={250}
height={250}
border={25}
borderRadius={this.props.borderRadius}
color={
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
} // RGBA
scale={this.zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
const renderCropping = () => (
<Modal
onRequestClose={handleClose}
fullscreen={false}
title={<>&nbsp;</>}
isOpen
>
<Flex auto column align="center" justify="center">
{isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={avatarEditorRef}
image={file!}
width={250}
height={250}
border={25}
borderRadius={borderRadius}
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
scale={zoom}
rotate={0}
/>
<br />
<ButtonLarge
fullwidth
onClick={this.handleCrop}
disabled={this.isUploading}
>
{this.isUploading ? "Uploading…" : submitText}
</ButtonLarge>
</Flex>
</Modal>
);
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleCrop} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
</Modal>
);
if (isCropping && file) {
return renderCropping();
}
render() {
if (this.isCropping) {
return this.renderCropping();
}
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{this.props.children}
</div>
)}
</Dropzone>
);
}
}
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
)}
</Dropzone>
);
};
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -191,4 +176,4 @@ const RangeInput = styled.input`
}
`;
export default withStores(ImageUpload);
export default observer(ImageUpload);
@@ -5,12 +5,12 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+1
View File
@@ -1,5 +1,6 @@
import commandScore from "command-score";
import invariant from "invariant";
// eslint-disable-next-line lodash/import-scope
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import filter from "lodash/filter";
+58
View File
@@ -0,0 +1,58 @@
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
MentionType,
} from "@shared/types";
import Integration from "~/models/Integration";
export const isURLMentionable = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
}
default:
return false;
}
};
export const determineMentionType = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): MentionType | undefined => {
const { pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const type = pathParts[3];
return type === "pull"
? MentionType.PullRequest
: type === "issues"
? MentionType.Issue
: undefined;
}
default:
return;
}
};
+3 -3
View File
@@ -248,7 +248,7 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.17",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -377,5 +377,5 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.82.0"
}
"version": "0.83.0"
}
+5
View File
@@ -13,4 +13,9 @@ PluginManager.add([
component: React.lazy(() => import("./Settings")),
},
},
{
...config,
type: Hook.Icon,
value: Icon,
},
]);
@@ -0,0 +1,81 @@
import { Endpoints } from "@octokit/types";
import Logger from "@server/logging/Logger";
import { Integration, User } from "@server/models";
import { CreateIssueResponse } from "@server/types";
import { BaseIssueProvider } from "@server/utils/IssueProvider";
import { IssueSource } from "@shared/schema";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import { GitHub } from "./github";
// This is needed to account for Octokit paginate response type mismatch.
type ReposForInstallation =
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
export class GitHubIssueProvider extends BaseIssueProvider {
constructor() {
super(IntegrationService.GitHub);
}
async fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]> {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const repos =
(await client.requestRepos()) as unknown as ReposForInstallation;
const sources = repos.map<IssueSource>((repo) => ({
id: String(repo.id),
name: repo.name,
owner: { id: String(repo.owner.id), name: repo.owner.login },
service: IntegrationService.GitHub,
}));
return sources;
}
async createIssue(
title: string,
source: IssueSource,
actor: User
): Promise<CreateIssueResponse | undefined> {
const integration = (await Integration.findOne({
where: {
service: IntegrationService.GitHub,
teamId: actor.teamId,
"settings.github.installation.account.name": source.owner.name,
},
})) as Integration<IntegrationType.Embed> | undefined;
if (!integration) {
return;
}
try {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const { data } = await client.createIssue({
owner: source.owner.name,
repo: source.name,
title,
});
return {
...data,
type: UnfurlResourceType.Issue,
cacheKey: data.html_url,
};
} catch (err) {
Logger.warn("Failed to create issue in GitHub", err);
return;
}
}
}
+18 -20
View File
@@ -2,6 +2,7 @@ import Router from "koa-router";
import find from "lodash/find";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
@@ -88,30 +89,27 @@ router.get(
},
{ transaction }
);
await Integration.create(
{
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
await Integration.createWithCtx(createContext({ user, transaction }), {
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
},
},
},
{ transaction }
);
});
ctx.redirect(GitHubUtils.url);
}
);
+26
View File
@@ -40,6 +40,32 @@ const requestPlugin = (octokit: Octokit) => ({
},
}),
requestRepos: () =>
octokit.paginate(octokit.rest.apps.listReposAccessibleToInstallation, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
createIssue: async ({
owner,
repo,
title,
}: {
owner: string;
repo: string;
title: string;
}) =>
octokit.request(`POST /repos/{owner}/{repo}/issues`, {
owner,
repo,
title,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
/**
* Fetches app installations accessible to the user
*
+6
View File
@@ -1,6 +1,8 @@
import { IntegrationService } from "@shared/types";
import { Minute } from "@shared/utils/time";
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import { GitHubIssueProvider } from "./GitHubIssueProvider";
import router from "./api/github";
import env from "./env";
import { GitHub } from "./github";
@@ -24,6 +26,10 @@ if (enabled) {
type: Hook.UnfurlProvider,
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
},
{
type: Hook.IssueProvider,
value: new GitHubIssueProvider(),
},
{
type: Hook.Uninstall,
value: uninstall,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
queryInterface.addColumn("integrations", "issueSources", {
type: Sequelize.JSONB,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
queryInterface.removeColumn("integrations", "issueSources");
},
};
+4
View File
@@ -13,6 +13,7 @@ import {
IsIn,
AfterDestroy,
} from "sequelize-typescript";
import { IssueSource } from "@shared/schema";
import { IntegrationType, IntegrationService } from "@shared/types";
import type { IntegrationSettings } from "@shared/types";
import Collection from "@server/models/Collection";
@@ -53,6 +54,9 @@ class Integration<T = unknown> extends ParanoidModel<
@Column(DataType.ARRAY(DataType.STRING))
events: string[];
@Column(DataType.JSONB)
issueSources: IssueSource[] | null;
// associations
@BelongsTo(() => User, "userId")
+5 -3
View File
@@ -1,7 +1,7 @@
import { JSDOM } from "jsdom";
import compact from "lodash/compact";
import flatten from "lodash/flatten";
import isEqual from "lodash/isEqual";
import isMatch from "lodash/isMatch";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
import * as React from "react";
@@ -12,7 +12,7 @@ import * as Y from "yjs";
import EditorContainer from "@shared/editor/components/Styles";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { MentionType, ProsemirrorData } from "@shared/types";
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
@@ -42,6 +42,8 @@ export type MentionAttrs = {
modelId: string;
actorId: string | undefined;
id: string;
href?: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
};
@trace()
@@ -194,7 +196,7 @@ export class ProsemirrorHelper {
node.descendants((childNode: Node) => {
if (
childNode.type.name === "mention" &&
isEqual(childNode.attrs, mention)
isMatch(childNode.attrs, mention)
) {
foundMention = true;
return false;
+2 -11
View File
@@ -2,7 +2,7 @@ import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow, can } from "./cancan";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
@@ -67,15 +67,6 @@ allow(
}
);
allow(User, "export", Collection, (actor, collection) =>
and(
//
can(actor, "read", collection),
!actor.isViewer,
!actor.isGuest
)
);
allow(User, "share", Collection, (user, collection) => {
if (
!collection ||
@@ -161,7 +152,7 @@ allow(
}
);
allow(User, ["update", "archive"], Collection, (user, collection) =>
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
and(
!!collection,
!!collection?.isActive,
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
export default class IntegrationCreatedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["integrations.create"];
@@ -18,6 +19,11 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
return;
}
// Store the available issue sources in the integration record.
await CacheIssueSourcesTask.schedule({
integrationId: integration.id,
});
// Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
@@ -0,0 +1,35 @@
import { Integration } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import BaseTask from "./BaseTask";
const plugins = PluginManager.getHooks(Hook.IssueProvider);
type Props = {
integrationId: string;
};
export default class CacheIssueSourcesTask extends BaseTask<Props> {
async perform({ integrationId }: Props) {
await sequelize.transaction(async (transaction) => {
const integration = await Integration.findByPk(integrationId, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!integration) {
return;
}
const plugin = plugins.find(
(p) => p.value.service === integration.service
);
if (!plugin) {
return;
}
const sources = await plugin.value.fetchSources(integration);
integration.issueSources = sources;
await integration.save({ transaction });
});
}
}
+2
View File
@@ -23,6 +23,7 @@ import groups from "./groups";
import imports from "./imports";
import installation from "./installation";
import integrations from "./integrations";
import issues from "./issues";
import apiErrorHandler from "./middlewares/apiErrorHandler";
import apiResponse from "./middlewares/apiResponse";
import apiTracer from "./middlewares/apiTracer";
@@ -99,6 +100,7 @@ router.use("/", urls.routes());
router.use("/", userMemberships.routes());
router.use("/", reactions.routes());
router.use("/", imports.routes());
router.use("/", issues.routes());
if (!env.isCloudHosted) {
router.use("/", installation.routes());
+1
View File
@@ -0,0 +1 @@
export { default } from "./issues";
+70
View File
@@ -0,0 +1,70 @@
import { InternalError, InvalidRequestError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Integration } from "@server/models";
import presentUnfurl from "@server/presenters/unfurl";
import { APIContext } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { IssueSource } from "@shared/schema";
import { UserRole } from "@shared/types";
import Router from "koa-router";
import * as T from "./schema";
const router = new Router();
const plugins = PluginManager.getHooks(Hook.IssueProvider);
router.post(
"issues.list_sources",
auth({ role: UserRole.Member }),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const integrations = await Integration.findAll({
attributes: ["issueSources"],
where: { teamId: user.teamId },
});
const sources = integrations
.flatMap((integration) => integration.issueSources)
.filter(Boolean) as IssueSource[];
ctx.body = {
data: sources,
};
}
);
router.post(
"issues.create",
auth({ role: UserRole.Member }),
validate(T.IssuesCreateSchema),
async (ctx: APIContext<T.IssuesCreateReq>) => {
const { title, source } = ctx.input.body;
const { user } = ctx.state.auth;
const plugin = plugins.find((p) => p.value.service === source.service);
if (!plugin) {
throw InvalidRequestError();
}
const issue = await plugin.value.createIssue(title, source, user);
if (!issue) {
throw InternalError();
}
await CacheHelper.setData(
CacheHelper.getUnfurlKey(user.teamId, issue.cacheKey),
issue,
plugin.value.cacheExpiry
);
ctx.body = {
data: await presentUnfurl(issue),
};
}
);
export default router;
+12
View File
@@ -0,0 +1,12 @@
import { IssueSource } from "@shared/schema";
import { z } from "zod";
import { BaseSchema } from "../schema";
export const IssuesCreateSchema = BaseSchema.extend({
body: z.object({
title: z.string().nonempty(),
source: IssueSource,
}),
});
export type IssuesCreateReq = z.infer<typeof IssuesCreateSchema>;
+15
View File
@@ -2,6 +2,7 @@ import { ParameterizedContext, DefaultContext } from "koa";
import { IRouterParamContext } from "koa-router";
import { InferAttributes, Model, Transaction } from "sequelize";
import { z } from "zod";
import { IssueSource } from "@shared/schema";
import {
CollectionSort,
NavigationNode,
@@ -10,6 +11,7 @@ import {
JSONValue,
UnfurlResourceType,
ProsemirrorData,
IntegrationType,
} from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
import { AccountProvisionerResult } from "./commands/accountProvisioner";
@@ -585,6 +587,19 @@ export type UnfurlSignature = (
export type UninstallSignature = (integration: Integration) => Promise<void>;
export type CreateIssueResponse = Unfurl & { cacheKey: string };
export type IssueProvider = {
listSources: (
integration: Integration<IntegrationType.Embed>
) => Promise<IssueSource[]>;
createIssue: (
title: string,
source: IssueSource,
actor: User
) => Promise<CreateIssueResponse | undefined>;
};
export type Replace<T, K extends keyof T, N extends string> = {
[P in keyof T as P extends K ? N : P]: T[P extends K ? K : P];
};
+28
View File
@@ -0,0 +1,28 @@
import { Integration, User } from "@server/models";
import { CreateIssueResponse } from "@server/types";
import { IssueSource } from "@shared/schema";
import {
IntegrationType,
IssueProviderIntegrationService,
} from "@shared/types";
import { Minute } from "@shared/utils/time";
export abstract class BaseIssueProvider {
service: IssueProviderIntegrationService;
cacheExpiry: number;
constructor(service: IssueProviderIntegrationService, cacheExpiry?: number) {
this.service = service;
this.cacheExpiry = cacheExpiry ?? Minute.seconds;
}
abstract fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]>;
abstract createIssue(
title: string,
source: IssueSource,
actor: User
): Promise<CreateIssueResponse | undefined>;
}
+3
View File
@@ -9,6 +9,7 @@ import Logger from "@server/logging/Logger";
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
import type BaseTask from "@server/queues/tasks/BaseTask";
import { UnfurlSignature, UninstallSignature } from "@server/types";
import { BaseIssueProvider } from "./IssueProvider";
export enum PluginPriority {
VeryHigh = 0,
@@ -25,6 +26,7 @@ export enum Hook {
API = "api",
AuthProvider = "authProvider",
EmailTemplate = "emailTemplate",
IssueProvider = "issueProvider",
Processor = "processor",
Task = "task",
UnfurlProvider = "unfurl",
@@ -39,6 +41,7 @@ type PluginValueMap = {
[Hook.API]: Router;
[Hook.AuthProvider]: { router: Router; id: string };
[Hook.EmailTemplate]: typeof BaseEmail;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.Task]: typeof BaseTask<any>;
[Hook.Uninstall]: UninstallSignature;
+73
View File
@@ -0,0 +1,73 @@
import styled, { css } from "styled-components";
import { ellipsis } from "../styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
export default Text;
+222 -8
View File
@@ -1,17 +1,49 @@
import { observer } from "mobx-react";
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
import {
DocumentIcon,
EmailIcon,
CollectionIcon,
WarningIcon,
} from "outline-icons";
import { Node } from "prosemirror-model";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Flex from "../../components/Flex";
import Icon from "../../components/Icon";
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
import { PullRequestIcon } from "../../components/PullRequestIcon";
import Spinner from "../../components/Spinner";
import Text from "../../components/Text";
import useIsMounted from "../../hooks/useIsMounted";
import useStores from "../../hooks/useStores";
import theme from "../../styles/theme";
import type {
JSONValue,
UnfurlResourceType,
UnfurlResponse,
} from "../../types";
import { cn } from "../styles/utils";
import { ComponentProps } from "../types";
const getAttributesFromNode = (node: Node) => {
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
const { class: className, ...attrs } = spec[1];
return { className, ...attrs };
type Attrs = {
className: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
} & Record<string, JSONValue>;
const getAttributesFromNode = (node: Node): Attrs => {
const spec = node.type.spec.toDOM?.(node) as any as Record<
string,
JSONValue
>[];
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
return {
className: className as Attrs["className"],
unfurl: unfurl ? (JSON.parse(unfurl as any) as Attrs["unfurl"]) : undefined,
...attrs,
};
};
export const MentionUser = observer(function MentionUser_(
@@ -20,7 +52,7 @@ export const MentionUser = observer(function MentionUser_(
const { isSelected, node } = props;
const { users } = useStores();
const user = users.get(node.attrs.modelId);
const { className, ...attrs } = getAttributesFromNode(node);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
return (
<span
@@ -42,7 +74,7 @@ export const MentionDocument = observer(function MentionDocument_(
const { documents } = useStores();
const doc = documents.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
@@ -75,7 +107,7 @@ export const MentionCollection = observer(function MentionCollection_(
const { collections } = useStores();
const collection = collections.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
@@ -100,3 +132,185 @@ export const MentionCollection = observer(function MentionCollection_(
</Link>
);
});
type IssuePrProps = ComponentProps & {
onChangeUnfurl: (
unfurl:
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR]
) => void;
};
export const MentionIssue = observer((props: IssuePrProps) => {
const { unfurls } = useStores();
const isMounted = useIsMounted();
const [loaded, setLoaded] = React.useState(false);
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
const { isSelected, node } = props;
const {
className,
unfurl: unfurlAttr,
...attrs
} = getAttributesFromNode(node);
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
React.useEffect(() => {
const fetchIssue = async () => {
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
if (!isMounted()) {
return;
}
if (unfurlModel) {
onChangeUnfurl({
...unfurlModel.data,
description: null,
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
}
setLoaded(true);
};
void fetchIssue();
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
if (!unfurl) {
return !loaded ? (
<MentionLoading className={className} />
) : (
<MentionError className={className} />
);
}
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
return (
<a
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
href={attrs.href as string}
target="_blank"
rel="noopener noreferrer nofollow"
>
<Flex align="center" gap={6}>
<IssueStatusIcon
size={14}
status={issue.state.name}
color={issue.state.color}
/>
<Flex align="center" gap={4}>
<Text>{issue.title}</Text>
<Text type="tertiary">{issue.id}</Text>
</Flex>
</Flex>
</a>
);
});
export const MentionPullRequest = observer((props: IssuePrProps) => {
const { unfurls } = useStores();
const isMounted = useIsMounted();
const [loaded, setLoaded] = React.useState(false);
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
const { isSelected, node } = props;
const {
className,
unfurl: unfurlAttr,
...attrs
} = getAttributesFromNode(node);
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
React.useEffect(() => {
const fetchPR = async () => {
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
if (!isMounted()) {
return;
}
if (unfurlModel) {
onChangeUnfurl({
...unfurlModel.data,
description: null,
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
}
setLoaded(true);
};
void fetchPR();
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
const sharedProps = {
className: cn(className, {
"ProseMirror-selectednode": isSelected,
}),
};
if (!unfurl) {
return !loaded ? (
<MentionLoading {...sharedProps} />
) : (
<MentionError {...sharedProps} />
);
}
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
return (
<a
{...attrs}
{...sharedProps}
href={attrs.href as string}
target="_blank"
rel="noopener noreferrer nofollow"
>
<Flex align="center" gap={6}>
<PullRequestIcon
size={14}
status={pullRequest.state.name}
color={pullRequest.state.color}
/>
<Flex align="center" gap={4}>
<Text>{pullRequest.title}</Text>
<Text type="tertiary">{pullRequest.id}</Text>
</Flex>
</Flex>
</a>
);
});
export const MentionPlaceholder = () => <MentionLoading className="mention" />;
const MentionLoading = ({ className }: { className: string }) => {
const { t } = useTranslation();
return (
<span className={className}>
<Spinner />
<Text type="tertiary">{`${t("Loading")}`}</Text>
</span>
);
};
const MentionError = ({ className }: { className: string }) => {
const { t } = useTranslation();
return (
<span className={className}>
<StyledWarningIcon size={20} color={theme.danger} />
<Text type="secondary">{`${t("Error loading data")}`}</Text>
</span>
);
};
const StyledWarningIcon = styled(WarningIcon)`
margin: 0 -2px;
`;
+7
View File
@@ -1335,6 +1335,13 @@ mark {
position: relative;
}
.code-block[data-language=none],
.code-block[data-language=markdown] {
pre code {
color: ${props.theme.text};
}
}
.code-block[data-language=mermaidjs] {
margin: 0.75em 0;
+2 -1
View File
@@ -1,3 +1,4 @@
import { BrowserIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { Primitive } from "utility-types";
@@ -665,7 +666,7 @@ const embeds: EmbedDescriptor[] = [
title: "Embed",
keywords: "iframe webpage",
placeholder: "Paste a URL to embed",
icon: <Img src="/images/embed.png" alt="Embed" />,
icon: <BrowserIcon />,
defaultHidden: false,
matchOnInput: false,
regexMatch: [new RegExp("^https?://(.*)$")],
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import refractor from "refractor/core";
import { getPrismLangForLanguage } from "../lib/code";
import { getRefractorLangForLanguage } from "../lib/code";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
@@ -14,6 +14,34 @@ type ParsedNode = {
};
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
const languagesToImport = new Set<string>();
async function loadLanguage(language: string) {
if (!language || refractor.registered(language)) {
return;
}
try {
// @ts-expect-error we are adding a module to the window object to work
// around the fact that refractor doesn't export ESM but import expects it.
// See the rules of dynamic imports:
// https://github.com/rollup/plugins/blob/e1a5ef99f1578eb38a8c87563cb9651db228f3bd/packages/dynamic-import-vars/README.md#limitations
window.module ??= {};
return import(`../../../node_modules/refractor/lang/${language}.js`).then(
() => {
refractor.register(window.module.exports);
return language;
}
);
} catch (err) {
// It will retry loading the language on the next render
// eslint-disable-next-line no-console
console.error(
`Failed to load language ${language} for code highlighting`,
err
);
}
return;
}
function getDecorations({
doc,
@@ -57,12 +85,7 @@ function getDecorations({
blocks.forEach((block) => {
let startPos = block.pos + 1;
const language = getPrismLangForLanguage(block.node.attrs.language);
if (!language || !refractor.registered(language)) {
return;
}
const language = getRefractorLangForLanguage(block.node.attrs.language);
const lineDecorations = [];
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
@@ -91,35 +114,48 @@ function getDecorations({
);
}
const nodes = refractor.highlight(block.node.textContent, language);
const newDecorations = parseNodes(nodes)
.map((node: ParsedNode) => {
const from = startPos;
const to = from + node.text.length;
startPos = to;
return {
...node,
from,
to,
};
})
.filter((node) => node.classes && node.classes.length)
.map((node) =>
Decoration.inline(node.from, node.to, {
class: node.classes.join(" "),
})
)
.concat(lineDecorations);
cache[block.pos] = {
node: block.node,
decorations: newDecorations,
decorations: lineDecorations,
};
if (!language) {
// do nothing
} else if (refractor.registered(language)) {
languagesToImport.delete(language);
const nodes = refractor.highlight(block.node.textContent, language);
const newDecorations = parseNodes(nodes)
.map((node: ParsedNode) => {
const from = startPos;
const to = from + node.text.length;
startPos = to;
return {
...node,
from,
to,
};
})
.filter((node) => node.classes && node.classes.length)
.map((node) =>
Decoration.inline(node.from, node.to, {
class: node.classes.join(" "),
})
)
.concat(lineDecorations);
cache[block.pos] = {
node: block.node,
decorations: newDecorations,
};
} else {
languagesToImport.add(language);
}
}
cache[block.pos].decorations.forEach((decoration) => {
cache[block.pos]?.decorations.forEach((decoration) => {
decorations.push(decoration);
});
});
@@ -133,7 +169,7 @@ function getDecorations({
return DecorationSet.create(doc, decorations);
}
export default function Prism({
export function CodeHighlighting({
name,
lineNumbers,
}: {
@@ -145,7 +181,7 @@ export default function Prism({
let highlighted = false;
return new Plugin({
key: new PluginKey("prism"),
key: new PluginKey("codeHighlighting"),
state: {
init: (_, { doc }) => DecorationSet.create(doc, []),
apply: (transaction: Transaction, decorationSet, oldState, state) => {
@@ -156,11 +192,13 @@ export default function Prism({
// @ts-expect-error accessing private field.
const isPaste = transaction.meta?.paste;
const langLoaded = transaction.getMeta("codeHighlighting")?.langLoaded;
if (
!highlighted ||
codeBlockChanged ||
isPaste ||
langLoaded ||
isRemoteTransaction(transaction)
) {
highlighted = true;
@@ -174,15 +212,34 @@ export default function Prism({
if (!highlighted) {
// we don't highlight code blocks on the first render as part of mounting
// as it's expensive (relative to the rest of the document). Instead let
// it render un-highlighted and then trigger a defered render of Prism
// it render un-highlighted and then trigger a defered render of highlighting
// by updating the plugins metadata
setTimeout(() => {
requestAnimationFrame(() => {
if (!view.isDestroyed) {
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
view.dispatch(
view.state.tr.setMeta("codeHighlighting", { loaded: true })
);
}
}, 10);
});
}
return {};
return {
update: () => {
if (!languagesToImport.size) {
return;
}
void Promise.all([...languagesToImport].map(loadLanguage)).then(
(language) =>
languagesToImport.size
? view.dispatch(
view.state.tr.setMeta("codeHighlighting", {
langLoaded: language,
})
)
: null
);
},
};
},
props: {
decorations(state) {
+8 -8
View File
@@ -1,12 +1,12 @@
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
import { getRefractorLangForLanguage, getLabelForLanguage } from "./code";
describe("getPrismLangForLanguage", () => {
it("should return the correct Prism language identifier for a given language", () => {
expect(getPrismLangForLanguage("javascript")).toBe("javascript");
expect(getPrismLangForLanguage("mermaidjs")).toBe("mermaid");
expect(getPrismLangForLanguage("xml")).toBe("markup");
expect(getPrismLangForLanguage("unknown")).toBeUndefined();
expect(getPrismLangForLanguage("")).toBeUndefined();
describe("getRefractorLangForLanguage", () => {
it("should return the correct lang identifier for a given language", () => {
expect(getRefractorLangForLanguage("javascript")).toBe("javascript");
expect(getRefractorLangForLanguage("mermaidjs")).toBe("mermaid");
expect(getRefractorLangForLanguage("xml")).toBe("markup");
expect(getRefractorLangForLanguage("unknown")).toBeUndefined();
expect(getRefractorLangForLanguage("")).toBeUndefined();
});
});
+20 -11
View File
@@ -1,6 +1,6 @@
import Storage from "../../utils/Storage";
const RecentStorageKey = "rme-code-language";
const RecentlyUsedStorageKey = "rme-code-language";
const StorageKey = "frequent-code-languages";
const frequentLanguagesToGet = 5;
const frequentLanguagesToTrack = 10;
@@ -9,7 +9,7 @@ const frequentLanguagesToTrack = 10;
* List of supported code languages.
*
* Object key is the language identifier used in the editor, lang is the
* language identifier used by Prism. Note mismatches such as `markup` and
* language identifier used by Refractor. Note mismatches such as `markup` and
* `mermaid`.
*/
export const codeLanguages = {
@@ -19,8 +19,10 @@ export const codeLanguages = {
cpp: { lang: "cpp", label: "C++" },
csharp: { lang: "csharp", label: "C#" },
css: { lang: "css", label: "CSS" },
csv: { lang: "csv", label: "CSV" },
docker: { lang: "docker", label: "Docker" },
elixir: { lang: "elixir", label: "Elixir" },
erb: { lang: "erb", label: "ERB" },
erlang: { lang: "erlang", label: "Erlang" },
go: { lang: "go", label: "Go" },
graphql: { lang: "graphql", label: "GraphQL" },
@@ -34,8 +36,11 @@ export const codeLanguages = {
json: { lang: "json", label: "JSON" },
jsx: { lang: "jsx", label: "JSX" },
kotlin: { lang: "kotlin", label: "Kotlin" },
kusto: { lang: "kusto", label: "Kusto" },
lisp: { lang: "lisp", label: "Lisp" },
lua: { lang: "lua", label: "Lua" },
makefile: { lang: "makefile", label: "Makefile" },
markdown: { lang: "markdown", label: "Markdown" },
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
nginx: { lang: "nginx", label: "Nginx" },
nix: { lang: "nix", label: "Nix" },
@@ -47,11 +52,13 @@ export const codeLanguages = {
protobuf: { lang: "protobuf", label: "Protobuf" },
python: { lang: "python", label: "Python" },
r: { lang: "r", label: "R" },
regex: { lang: "regex", label: "Regex" },
ruby: { lang: "ruby", label: "Ruby" },
rust: { lang: "rust", label: "Rust" },
scala: { lang: "scala", label: "Scala" },
sass: { lang: "sass", label: "Sass" },
scss: { lang: "scss", label: "SCSS" },
"splunk-spl": { lang: "splunk-spl", label: "Splunk SPL" },
sql: { lang: "sql", label: "SQL" },
solidity: { lang: "solidity", label: "Solidity" },
swift: { lang: "swift", label: "Swift" },
@@ -79,12 +86,14 @@ export const getLabelForLanguage = (language: string) => {
};
/**
* Get the Prism language identifier for a given language.
* Get the Refractor language identifier for a given language.
*
* @param language The language identifier.
* @returns The Prism language identifier for the language.
* @returns The Refractor language identifier for the language.
*/
export const getPrismLangForLanguage = (language: string): string | undefined =>
export const getRefractorLangForLanguage = (
language: string
): string | undefined =>
codeLanguages[language as keyof typeof codeLanguages]?.lang;
/**
@@ -92,14 +101,14 @@ export const getPrismLangForLanguage = (language: string): string | undefined =>
*
* @param language The language identifier.
*/
export const setRecentCodeLanguage = (language: string) => {
export const setRecentlyUsedCodeLanguage = (language: string) => {
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
string,
number
>;
if (Object.keys(frequentLangs).length === 0) {
const lastUsedLang = Storage.get(RecentStorageKey);
const lastUsedLang = Storage.get(RecentlyUsedStorageKey);
if (lastUsedLang) {
frequentLangs[lastUsedLang] = 1;
}
@@ -121,7 +130,7 @@ export const setRecentCodeLanguage = (language: string) => {
}
Storage.set(StorageKey, Object.fromEntries(frequentLangEntries));
Storage.set(RecentStorageKey, language);
Storage.set(RecentlyUsedStorageKey, language);
};
/**
@@ -129,8 +138,8 @@ export const setRecentCodeLanguage = (language: string) => {
*
* @returns The most recent code language used, or undefined if none is set.
*/
export const getRecentCodeLanguage = () =>
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
export const getRecentlyUsedCodeLanguage = () =>
Storage.get(RecentlyUsedStorageKey) as keyof typeof codeLanguages | undefined;
/**
* Get the most frequent code languages used.
@@ -138,7 +147,7 @@ export const getRecentCodeLanguage = () =>
* @returns An array of the most frequent code languages used.
*/
export const getFrequentCodeLanguages = () => {
const recentLang = Storage.get(RecentStorageKey);
const recentLang = Storage.get(RecentlyUsedStorageKey);
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
keyof typeof codeLanguages,
number
+9 -110
View File
@@ -9,58 +9,6 @@ import {
} from "prosemirror-model";
import { Command, Plugin, PluginKey, TextSelection } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import refractor from "refractor/core";
import bash from "refractor/lang/bash";
import clike from "refractor/lang/clike";
import cpp from "refractor/lang/cpp";
import csharp from "refractor/lang/csharp";
import css from "refractor/lang/css";
import docker from "refractor/lang/docker";
import elixir from "refractor/lang/elixir";
import erlang from "refractor/lang/erlang";
import go from "refractor/lang/go";
import graphql from "refractor/lang/graphql";
import groovy from "refractor/lang/groovy";
import haskell from "refractor/lang/haskell";
import hcl from "refractor/lang/hcl";
import ini from "refractor/lang/ini";
import java from "refractor/lang/java";
import javascript from "refractor/lang/javascript";
import json from "refractor/lang/json";
import jsx from "refractor/lang/jsx";
import kotlin from "refractor/lang/kotlin";
import lisp from "refractor/lang/lisp";
import lua from "refractor/lang/lua";
import markup from "refractor/lang/markup";
// @ts-expect-error type definition is missing, but package exists
import mermaid from "refractor/lang/mermaid";
import nginx from "refractor/lang/nginx";
import nix from "refractor/lang/nix";
import objectivec from "refractor/lang/objectivec";
import ocaml from "refractor/lang/ocaml";
import perl from "refractor/lang/perl";
import php from "refractor/lang/php";
import powershell from "refractor/lang/powershell";
import protobuf from "refractor/lang/protobuf";
import python from "refractor/lang/python";
import r from "refractor/lang/r";
import ruby from "refractor/lang/ruby";
import rust from "refractor/lang/rust";
import sass from "refractor/lang/sass";
import scala from "refractor/lang/scala";
import scss from "refractor/lang/scss";
import solidity from "refractor/lang/solidity";
import sql from "refractor/lang/sql";
import swift from "refractor/lang/swift";
import toml from "refractor/lang/toml";
import tsx from "refractor/lang/tsx";
import typescript from "refractor/lang/typescript";
import verilog from "refractor/lang/verilog";
import vhdl from "refractor/lang/vhdl";
import visualbasic from "refractor/lang/visual-basic";
import yaml from "refractor/lang/yaml";
import zig from "refractor/lang/zig";
import { toast } from "sonner";
import { Primitive } from "utility-types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -77,9 +25,12 @@ import {
} from "../commands/codeFence";
import { selectAll } from "../commands/selectAll";
import toggleBlockType from "../commands/toggleBlockType";
import { CodeHighlighting } from "../extensions/CodeHighlighting";
import Mermaid from "../extensions/Mermaid";
import Prism from "../extensions/Prism";
import { getRecentCodeLanguage, setRecentCodeLanguage } from "../lib/code";
import {
getRecentlyUsedCodeLanguage,
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import { isCode } from "../lib/isCode";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
@@ -90,58 +41,6 @@ import Node from "./Node";
const DEFAULT_LANGUAGE = "javascript";
[
bash,
cpp,
css,
clike,
csharp,
docker,
elixir,
erlang,
go,
graphql,
groovy,
haskell,
hcl,
ini,
java,
javascript,
jsx,
json,
kotlin,
lisp,
lua,
markup,
mermaid,
nginx,
nix,
objectivec,
ocaml,
perl,
php,
python,
powershell,
protobuf,
r,
ruby,
rust,
scala,
sql,
solidity,
sass,
scss,
swift,
toml,
typescript,
tsx,
verilog,
vhdl,
visualbasic,
yaml,
zig,
].forEach(refractor.register);
export default class CodeFence extends Node {
constructor(options: {
dictionary: Dictionary;
@@ -212,10 +111,10 @@ export default class CodeFence extends Node {
return {
code_block: (attrs: Record<string, Primitive>) => {
if (attrs?.language) {
setRecentCodeLanguage(attrs.language as string);
setRecentlyUsedCodeLanguage(attrs.language as string);
}
return toggleBlockType(type, schema.nodes.paragraph, {
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
...attrs,
});
},
@@ -286,7 +185,7 @@ export default class CodeFence extends Node {
get plugins() {
return [
Prism({
CodeHighlighting({
name: this.name,
lineNumbers: this.showLineNumbers,
}),
@@ -353,7 +252,7 @@ export default class CodeFence extends Node {
inputRules({ type }: { type: NodeType }) {
return [
textblockTypeInputRule(/^```$/, type, () => ({
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
})),
];
}
+96 -15
View File
@@ -1,3 +1,4 @@
import isMatch from "lodash/isMatch";
import { Token } from "markdown-it";
import {
NodeSpec,
@@ -15,10 +16,18 @@ import * as React from "react";
import { Primitive } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import { MentionType } from "../../types";
import {
MentionPlaceholder,
MentionType,
UnfurlResourceType,
UnfurlResponse,
} from "../../types";
import {
MentionCollection,
MentionDocument,
MentionIssue,
MentionPlaceholder as MentionPlaceholderComp,
MentionPullRequest,
MentionUser,
} from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
@@ -50,6 +59,12 @@ export default class Mention extends Node {
id: {
default: undefined,
},
href: {
default: undefined,
},
unfurl: {
default: undefined,
},
},
inline: true,
marks: "",
@@ -73,6 +88,10 @@ export default class Mention extends Node {
actorId: dom.dataset.actorid,
label: dom.innerText,
id: dom.id,
href: dom.getAttribute("href"),
unfurl: dom.dataset.unfurl
? JSON.parse(dom.dataset.unfurl)
: undefined,
};
},
},
@@ -87,11 +106,18 @@ export default class Mention extends Node {
? undefined
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
: `${env.URL}/collection/${node.attrs.modelId}`,
: node.attrs.type === MentionType.Collection
? `${env.URL}/collection/${node.attrs.modelId}`
: node.attrs.href,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorid": node.attrs.actorId,
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
"data-url":
node.attrs.type === MentionType.PullRequest ||
node.attrs.type === MentionType.Issue
? node.attrs.href
: `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
"data-unfurl": JSON.stringify(node.attrs.unfurl),
},
toPlainText(node),
],
@@ -107,6 +133,22 @@ export default class Mention extends Node {
return <MentionDocument {...props} />;
case MentionType.Collection:
return <MentionCollection {...props} />;
case MentionType.Issue:
return (
<MentionIssue
{...props}
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionType.PullRequest:
return (
<MentionPullRequest
{...props}
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionPlaceholder:
return <MentionPlaceholderComp />;
default:
return null;
}
@@ -149,29 +191,42 @@ export default class Mention extends Node {
}
keys(): Record<string, Command> {
const NavigableMention = [
MentionType.Collection,
MentionType.Document,
MentionType.Issue,
MentionType.PullRequest,
];
return {
Enter: (state) => {
const { selection } = state;
if (
selection instanceof NodeSelection &&
selection.node.type.name === this.name &&
(selection.node.attrs.type === MentionType.Document ||
selection.node.attrs.type === MentionType.Collection)
NavigableMention.includes(selection.node.attrs.type)
) {
const { modelId } = selection.node.attrs;
const mentionType = selection.node.attrs.type;
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: selection.node.attrs.type === MentionType.Collection
? "collection"
: undefined;
let link: string;
if (!linkType) {
return false;
if (
mentionType === MentionType.Issue ||
mentionType === MentionType.PullRequest
) {
link = selection.node.attrs.href;
} else {
const { modelId } = selection.node.attrs;
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: "collection";
link = `/${linkType}/${modelId}`;
}
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
this.editor.props.onClickLink?.(link);
return true;
}
return false;
@@ -218,4 +273,30 @@ export default class Mention extends Node {
}),
};
}
handleChangeUnfurl =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
const { view } = this.editor;
const { tr } = view.state;
const label =
unfurl.type === UnfurlResourceType.Issue ||
unfurl.type === UnfurlResourceType.PR
? unfurl.title
: undefined;
const overrides: Record<string, unknown> = label ? { label } : {};
overrides.unfurl = unfurl;
const pos = getPos();
if (!isMatch(node.attrs, overrides)) {
const transaction = tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...overrides,
});
view.dispatch(transaction);
}
};
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
+4 -1
View File
@@ -428,6 +428,7 @@
"Create a new doc": "Create a new doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Mention": "Mention",
"Embed": "Embed",
"Add column after": "Add column after",
"Add column before": "Add column before",
@@ -893,6 +894,8 @@
"No people left to add": "No people left to add",
"Date created": "Date created",
"Upload": "Upload",
"Crop image": "Crop image",
"Uploading": "Uploading",
"How does this work?": "How does this work?",
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
@@ -1154,5 +1157,5 @@
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
"Error loading data": "Error loading data"
}
+13
View File
@@ -3,6 +3,7 @@ import {
CollectionPermission,
type ImportableIntegrationService,
IntegrationService,
IssueProviderIntegrationService,
ProsemirrorDoc,
} from "./types";
import { PageType } from "plugins/notion/shared/types";
@@ -57,3 +58,15 @@ export type ImportTaskOutput = {
createdAt?: Date;
updatedAt?: Date;
}[];
export const IssueSource = z.object({
id: z.string().nonempty(),
name: z.string().nonempty(),
owner: z.object({
id: z.string().nonempty(),
name: z.string().nonempty(),
}),
service: z.nativeEnum(IssueProviderIntegrationService),
});
export type IssueSource = z.infer<typeof IssueSource>;
+15 -2
View File
@@ -75,8 +75,12 @@ export enum MentionType {
User = "user",
Document = "document",
Collection = "collection",
Issue = "issue",
PullRequest = "pull_request",
}
export const MentionPlaceholder = "mention_placeholder";
export type PublicEnv = {
ROOT_SHARE_ID?: string;
analytics: {
@@ -144,6 +148,15 @@ export const UserCreatableIntegrationService = {
Umami: IntegrationService.Umami,
} as const;
export type IssueProviderIntegrationService = Extract<
IntegrationService,
IntegrationService.GitHub
>;
export const IssueProviderIntegrationService = {
Notion: IntegrationService.GitHub,
} as const;
export enum CollectionPermission {
Read = "read",
ReadWrite = "read_write",
@@ -416,7 +429,7 @@ export type UnfurlResponse = {
/** Issue title */
title: string;
/** Issue description */
description: string;
description: string | null;
/** Issue's author */
author: { name: string; avatarUrl: string };
/** Issue's labels */
@@ -436,7 +449,7 @@ export type UnfurlResponse = {
/** Pull Request title */
title: string;
/** Pull Request description */
description: string;
description: string | null;
/** Pull Request author */
author: { name: string; avatarUrl: string };
/** Pull Request status */
+4 -4
View File
@@ -15605,10 +15605,10 @@ vite-plugin-static-copy@^0.17.0:
fs-extra "^11.1.0"
picocolors "^1.0.0"
vite@^5.4.17:
version "5.4.17"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.17.tgz#4bf61dd4cdbf64b0d6661f5dba76954cc81d5082"
integrity sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==
vite@^5.4.18:
version "5.4.18"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.18.tgz#b5af357f9d5ebb2e0c085779b7a37a77f09168a4"
integrity sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==
dependencies:
esbuild "^0.21.3"
postcss "^8.4.43"