kind of working in live preview, just need to make sure we're not constantly re-rendering

This commit is contained in:
Nathan Smith 2024-01-08 19:15:59 +00:00
parent 697db638ea
commit e21a45754a
11 changed files with 602 additions and 417 deletions

View file

@ -1,23 +1,32 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": ["@typescript-eslint", "unused-imports"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"@typescript-eslint/no-this-alias": "warn",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/consistent-type-imports": "error",
// replaced by 'unused-imports/no-unused-vars'
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}

673
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,15 +12,19 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@codemirror/view": "^6.23.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"esbuild": "0.19.11",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-unused-imports": "^3.0.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4",
"octokit": "^3.1.2"
"octokit": "^3.1.2",
"prettier": "^3.1.1",
"tslib": "2.6.2",
"typescript": "5.3.3"
},
"dependencies": {
"@octokit/auth-oauth-device": "^6.0.1",

View file

@ -1,9 +1,13 @@
import { App, Modal, setIcon } from "obsidian";
import { Modal, setIcon } from "obsidian";
import { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import type { App } from "obsidian";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
export class AuthModal extends Modal {
constructor(app: App, private readonly verification: Verification) {
constructor(
app: App,
private readonly verification: Verification,
) {
super(app);
}
onOpen(): void {

View file

@ -1,9 +1,9 @@
import { RequestUrlParam, requestUrl } from "obsidian";
import { OnVerificationCallback } from "@octokit/auth-oauth-device/dist-types/types";
import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import type { OnVerificationCallback } from "@octokit/auth-oauth-device/dist-types/types";
import type { RequestUrlParam } from "obsidian";
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { request } from "@octokit/request";
import { requestUrl } from "obsidian";
const baseApi = "https://api.github.com";
@ -51,7 +51,7 @@ export async function getPullRequest(org: string, repo: string, pr: number, toke
{
url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`,
},
token
token,
);
return result.json as RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
}
@ -61,7 +61,7 @@ export async function getCode(org: string, repo: string, path: string, branch: s
{
url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
},
token
token,
);
return result.json as RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];
}

View file

@ -1,74 +0,0 @@
import { MarkdownPostProcessorContext, setIcon } from "obsidian";
import { getIssue, getPullRequest, parseUrl } from "./github";
import { PluginSettings } from "./plugin";
// import { PluginSettings } from "./plugin";
export async function InlineRenderer(el: HTMLElement, ctx: MarkdownPostProcessorContext) {
const githubLinks = el.querySelectorAll<HTMLAnchorElement>(`a.external-link[href^="https://github.com"]`);
for (const anchor of Array.from(githubLinks)) {
const url = anchor.href;
const parsedUrl = parseUrl(url);
let token: string | undefined;
// Try and find the matching token
if (parsedUrl.org) {
let account = PluginSettings.accounts.find((acc) => acc.orgs.some((org) => org === parsedUrl.org));
// Fall back to default token if available
if (!account && PluginSettings.defaultAccount) {
account = PluginSettings.accounts.find((acc) => acc.id === PluginSettings.defaultAccount);
}
token = account?.token;
}
const container = createEl("a", { cls: "gh-link-inline", href: url });
const icon = container.createSpan({ cls: "gh-link-inline-icon" });
setIcon(icon, "github"); // TODO: Set correct icon
if (parsedUrl.repo) {
container.createSpan({
cls: "gh-link-inline-repo",
text: parsedUrl.repo,
});
} else if (parsedUrl.org) {
container.createSpan({
cls: "gh-link-inline-org",
text: parsedUrl.org,
});
}
if (parsedUrl.repo && parsedUrl.org) {
if (parsedUrl.issue !== undefined) {
const issue = await getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue, token);
if (issue.title) {
setIcon(icon, "square-dot");
if (issue.pull_request?.merged_at) {
icon.dataset.status = "done";
} else {
icon.dataset.status = issue.state;
}
container.createSpan({
cls: "gh-link-inline-issue-title",
text: issue.title,
});
}
}
if (parsedUrl.pr !== undefined) {
const pull = await getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr, token);
if (pull.title) {
setIcon(icon, "git-pull-request-arrow");
if (pull.merged) {
icon.dataset.status = "done";
} else {
icon.dataset.status = pull.state;
}
container.createSpan({
cls: "gh-link-inline-pr-title",
text: pull.title,
});
}
}
// TODO: add support for other stuff here
}
anchor.replaceWith(container);
}
}

77
src/inline/inline.ts Normal file
View file

@ -0,0 +1,77 @@
import { getIssue, getPullRequest, parseUrl } from "../github";
import { PluginSettings } from "../plugin";
import { setIcon } from "obsidian";
// TODO: Split some of this out, there's no reason I should be getting the token when creating this element
export async function createTag(href: string) {
const parsedUrl = parseUrl(href);
let token: string | undefined;
// Try and find the matching token
if (parsedUrl.org) {
let account = PluginSettings.accounts.find((acc) => acc.orgs.some((org) => org === parsedUrl.org));
// Fall back to default token if available
if (!account && PluginSettings.defaultAccount) {
account = PluginSettings.accounts.find((acc) => acc.id === PluginSettings.defaultAccount);
}
token = account?.token;
}
const container = createEl("a", { cls: "gh-link-inline", href });
const icon = container.createSpan({ cls: "gh-link-inline-icon" });
setIcon(icon, "github"); // TODO: Set correct icon
if (parsedUrl.repo) {
container.createSpan({
cls: "gh-link-inline-repo",
text: parsedUrl.repo,
});
} else if (parsedUrl.org) {
container.createSpan({
cls: "gh-link-inline-org",
text: parsedUrl.org,
});
}
if (parsedUrl.repo && parsedUrl.org) {
if (parsedUrl.issue !== undefined) {
const issue = await getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue, token);
if (issue.title) {
setIcon(icon, "square-dot");
if (issue.pull_request?.merged_at) {
icon.dataset.status = "done";
} else {
icon.dataset.status = issue.state;
}
container.createSpan({
cls: "gh-link-inline-issue-title",
text: issue.title,
});
}
}
if (parsedUrl.pr !== undefined) {
const pull = await getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr, token);
if (pull.title) {
setIcon(icon, "git-pull-request-arrow");
if (pull.merged) {
icon.dataset.status = "done";
} else {
icon.dataset.status = pull.state;
}
container.createSpan({
cls: "gh-link-inline-pr-title",
text: pull.title,
});
}
}
// TODO: add support for other stuff here
}
return container;
}
export async function InlineRenderer(el: HTMLElement) {
const githubLinks = el.querySelectorAll<HTMLAnchorElement>(`a.external-link[href^="https://github.com"]`);
for (const anchor of Array.from(githubLinks)) {
const container = await createTag(anchor.href);
anchor.replaceWith(container);
}
}

72
src/inline/view-plugin.ts Normal file
View file

@ -0,0 +1,72 @@
import { Component, editorLivePreviewField } from "obsidian";
import { Decoration, MatchDecorator, ViewPlugin, WidgetType } from "@codemirror/view";
import type { DecorationSet, EditorView, PluginSpec, PluginValue, ViewUpdate } from "@codemirror/view";
import type { GithubLinkPlugin } from "../plugin";
import { createTag } from "./inline";
import { valueWithin } from "src/util";
class InlineTagWidget extends WidgetType {
private container: HTMLElement = createSpan();
constructor(href: string) {
super();
createTag(href).then((tag) => {
this.container.appendChild(tag);
});
}
toDOM(): HTMLElement {
return this.container;
}
}
export function createInlineViewPlugin(plugin: GithubLinkPlugin) {
class InlineViewPluginValue implements PluginValue {
private readonly component = new Component();
private readonly plugin: GithubLinkPlugin;
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.plugin = plugin;
this.component.load();
this.updateDecorations(view);
}
update(update: ViewUpdate): void {
this.updateDecorations(update.view);
}
destroy(): void {
this.component.unload();
this.decorations = Decoration.none;
}
updateDecorations(view: EditorView) {
this.decorations = new MatchDecorator({
regexp: /(https:\/\/)?github\.com[\S]+/g,
decoration: (match, view, pos) => {
const shouldRender = this.shouldRender(view, pos);
if (!shouldRender) {
return null;
}
return Decoration.replace({ widget: new InlineTagWidget(match[0]) });
},
}).createDeco(view);
}
isLivePreview(state: EditorView["state"]): boolean {
return state.field(editorLivePreviewField);
}
shouldRender(view: EditorView, pos: number) {
const selection = view.state.selection;
const isLivePreview = this.isLivePreview(view.state);
const cursorOverlap = selection.ranges.some((range) => valueWithin(pos, range.from, range.to));
return isLivePreview && !cursorOverlap;
}
}
const InlineViewPluginSpec: PluginSpec<InlineViewPluginValue> = {
decorations: (plugin) => plugin.decorations,
};
return ViewPlugin.fromClass(InlineViewPluginValue, InlineViewPluginSpec);
}

View file

@ -1,7 +1,8 @@
import { GithubLinkPluginSettings, GithubLinkPluginSettingsTab } from "./settings";
import { InlineRenderer } from "./inline";
import type { GithubLinkPluginSettings } from "./settings";
import { GithubLinkPluginSettingsTab } from "./settings";
import { InlineRenderer } from "./inline/inline";
import { Plugin } from "obsidian";
import { createInlineViewPlugin } from "./inline/view-plugin";
import { getIssue } from "./github";
export let PluginSettings: GithubLinkPluginSettings = { accounts: [] };
@ -11,6 +12,7 @@ export class GithubLinkPlugin extends Plugin {
PluginSettings = Object.assign({}, await this.loadData());
this.addSettingTab(new GithubLinkPluginSettingsTab(this.app, this));
this.registerMarkdownPostProcessor(InlineRenderer);
this.registerEditorExtension(createInlineViewPlugin(this));
this.addCommand({
id: "get-issue",
name: "Get GitHub issue",

View file

@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { App, PluginSettingTab, Setting } from "obsidian";
import { GithubLinkPlugin, PluginSettings } from "./plugin";
import { PluginSettingTab, Setting } from "obsidian";
import type { App } from "obsidian";
import { AuthModal } from "./auth-modal";
import { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import type { GithubLinkPlugin } from "./plugin";
import { PluginSettings } from "./plugin";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { auth } from "./github";
export interface GithubAccount {
@ -22,7 +24,10 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
authModal: AuthModal | null = null;
newAccount: GithubAccount | null = null;
constructor(public app: App, private readonly plugin: GithubLinkPlugin) {
constructor(
public app: App,
private readonly plugin: GithubLinkPlugin,
) {
super(app, plugin);
}
@ -89,7 +94,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for. Optional."
"A comma separated list of the GitHub organizations and users this account should be used for. Optional.",
)
.addTextArea((text) => {
text.setValue(this.newAccount!.orgs.join(", "));
@ -100,7 +105,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required."
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
@ -160,7 +165,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for."
"A comma separated list of the GitHub organizations and users this account should be used for.",
)
.addTextArea((text) => {
text.setValue(account.orgs.join(", "));
@ -172,7 +177,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens)."
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens).",
)
.addButton((button) => {
button.setButtonText("Generate Token");

3
src/util.ts Normal file
View file

@ -0,0 +1,3 @@
export function valueWithin(value: number, min: number, max: number) {
return value >= min && value <= max;
}