mirror of
https://github.com/maradotwebp/obsidian-avatar.git
synced 2026-07-22 07:30:24 +00:00
implement AvatarView.svelte
This commit is contained in:
parent
6631d953c2
commit
6e0b8362fe
9 changed files with 257 additions and 141 deletions
138
main.ts
138
main.ts
|
|
@ -1,137 +1,3 @@
|
||||||
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
|
import {AvatarPlugin} from "./src/plugin";
|
||||||
|
|
||||||
// Remember to rename these classes and interfaces!
|
export default AvatarPlugin;
|
||||||
|
|
||||||
interface MyPluginSettings {
|
|
||||||
mySetting: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: MyPluginSettings = {
|
|
||||||
mySetting: 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class MyPlugin extends Plugin {
|
|
||||||
settings: MyPluginSettings;
|
|
||||||
|
|
||||||
async onload() {
|
|
||||||
await this.loadSettings();
|
|
||||||
|
|
||||||
// This creates an icon in the left ribbon.
|
|
||||||
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
|
|
||||||
// Called when the user clicks the icon.
|
|
||||||
new Notice('This is a notice!');
|
|
||||||
});
|
|
||||||
// Perform additional things with the ribbon
|
|
||||||
ribbonIconEl.addClass('my-plugin-ribbon-class');
|
|
||||||
|
|
||||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
|
||||||
const statusBarItemEl = this.addStatusBarItem();
|
|
||||||
statusBarItemEl.setText('Status Bar Text');
|
|
||||||
|
|
||||||
// This adds a simple command that can be triggered anywhere
|
|
||||||
this.addCommand({
|
|
||||||
id: 'open-sample-modal-simple',
|
|
||||||
name: 'Open sample modal (simple)',
|
|
||||||
callback: () => {
|
|
||||||
new SampleModal(this.app).open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// This adds an editor command that can perform some operation on the current editor instance
|
|
||||||
this.addCommand({
|
|
||||||
id: 'sample-editor-command',
|
|
||||||
name: 'Sample editor command',
|
|
||||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
|
||||||
console.log(editor.getSelection());
|
|
||||||
editor.replaceSelection('Sample Editor Command');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
|
||||||
this.addCommand({
|
|
||||||
id: 'open-sample-modal-complex',
|
|
||||||
name: 'Open sample modal (complex)',
|
|
||||||
checkCallback: (checking: boolean) => {
|
|
||||||
// Conditions to check
|
|
||||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
||||||
if (markdownView) {
|
|
||||||
// If checking is true, we're simply "checking" if the command can be run.
|
|
||||||
// If checking is false, then we want to actually perform the operation.
|
|
||||||
if (!checking) {
|
|
||||||
new SampleModal(this.app).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
// This command will only show up in Command Palette when the check function returns true
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
|
||||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
|
||||||
|
|
||||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
|
||||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
|
||||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
|
||||||
console.log('click', evt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
|
||||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
onunload() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSettings() {
|
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveSettings() {
|
|
||||||
await this.saveData(this.settings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SampleModal extends Modal {
|
|
||||||
constructor(app: App) {
|
|
||||||
super(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpen() {
|
|
||||||
const {contentEl} = this;
|
|
||||||
contentEl.setText('Woah!');
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
const {contentEl} = this;
|
|
||||||
contentEl.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SampleSettingTab extends PluginSettingTab {
|
|
||||||
plugin: MyPlugin;
|
|
||||||
|
|
||||||
constructor(app: App, plugin: MyPlugin) {
|
|
||||||
super(app, plugin);
|
|
||||||
this.plugin = plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
display(): void {
|
|
||||||
const {containerEl} = this;
|
|
||||||
|
|
||||||
containerEl.empty();
|
|
||||||
|
|
||||||
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName('Setting #1')
|
|
||||||
.setDesc('It\'s a secret')
|
|
||||||
.addText(text => text
|
|
||||||
.setPlaceholder('Enter your secret')
|
|
||||||
.setValue(this.plugin.settings.mySetting)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
console.log('Secret: ' + value);
|
|
||||||
this.plugin.settings.mySetting = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
107
src/components/AvatarView.svelte
Normal file
107
src/components/AvatarView.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {App, MarkdownPostProcessorContext, MarkdownView} from "obsidian";
|
||||||
|
import {AvatarPlugin} from "../plugin";
|
||||||
|
import Fab from "./Fab.svelte";
|
||||||
|
import ObsidianIcon from "./ObsidianIcon.svelte";
|
||||||
|
import type {SetState, State} from "../core/stateProviders";
|
||||||
|
|
||||||
|
interface AvatarViewState {
|
||||||
|
image: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let app: App;
|
||||||
|
export let plugin: AvatarPlugin;
|
||||||
|
export let ctx: MarkdownPostProcessorContext;
|
||||||
|
export let state: State<AvatarViewState>;
|
||||||
|
export let setState: SetState<AvatarViewState>;
|
||||||
|
|
||||||
|
let initialDescription = state?.description;
|
||||||
|
let hoverOnImage: boolean = false;
|
||||||
|
|
||||||
|
$: inPreviewMode = isPreviewMode(app);
|
||||||
|
$: fallbackImage = `https://ui-avatars.com/api/?name=${ctx?.sourcePath.split("/").at(-1) ?? "::"}&size=240`;
|
||||||
|
|
||||||
|
function isPreviewMode(app: App) {
|
||||||
|
const view = app.workspace.getMostRecentLeaf()?.view as MarkdownView;
|
||||||
|
return view?.getMode() === "preview";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDescription() {
|
||||||
|
initialDescription = state.description;
|
||||||
|
setState((fm) => {
|
||||||
|
fm.description = state.description;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectImage() {
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<div
|
||||||
|
class="avatar relative"
|
||||||
|
on:click={selectImage}
|
||||||
|
on:mouseenter={() => hoverOnImage = true}
|
||||||
|
on:mouseleave={() => hoverOnImage = false}
|
||||||
|
>
|
||||||
|
<img class="avatar" alt="Avatar" src={state?.image ?? fallbackImage} />
|
||||||
|
{#if hoverOnImage}
|
||||||
|
<Fab>
|
||||||
|
<ObsidianIcon id="edit"></ObsidianIcon>
|
||||||
|
</Fab>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
{#if inPreviewMode}
|
||||||
|
<i data-placeholder="Write your story...">{state.description}</i>
|
||||||
|
{:else}
|
||||||
|
<i contenteditable="true" data-placeholder="Write your story..." bind:textContent={state.description}></i>
|
||||||
|
{/if}
|
||||||
|
{#if (initialDescription !== "" || state?.description !== "") && state?.description !== initialDescription}
|
||||||
|
<Fab on:click={updateDescription}>
|
||||||
|
<ObsidianIcon id="save"></ObsidianIcon>
|
||||||
|
</Fab>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.4em;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.flex {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[contenteditable=true]:empty:not(:focus):before{
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/components/Fab.svelte
Normal file
12
src/components/Fab.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<button class="fab" {...$$props} on:click>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fab {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1em;
|
||||||
|
right: 1em;
|
||||||
|
padding: var(--size-2-2) var(--size-2-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
src/components/ObsidianIcon.svelte
Normal file
10
src/components/ObsidianIcon.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {setIcon} from "obsidian";
|
||||||
|
|
||||||
|
export let id: string;
|
||||||
|
let el: HTMLElement;
|
||||||
|
|
||||||
|
$: if(el && id) setIcon(el, id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span bind:this={el}></span>
|
||||||
32
src/core/renderCodeBlockProcessor.ts
Normal file
32
src/core/renderCodeBlockProcessor.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type {App, MarkdownPostProcessorContext, Plugin} from "obsidian";
|
||||||
|
import type {SvelteComponent} from "svelte";
|
||||||
|
import type {StateProvider} from "./stateProviders";
|
||||||
|
|
||||||
|
export interface CodeBlockProcessorProps extends Record<string, any> {
|
||||||
|
app: App;
|
||||||
|
plugin: Plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a svelte component as a code block processor.
|
||||||
|
* @param component the svelte component to render.
|
||||||
|
* @param props properties forwarded to the component.
|
||||||
|
* @param stateProvider an optional provider that handles state & state updates of the code block processor.
|
||||||
|
*/
|
||||||
|
export function renderCodeBlockProcessor<S>(
|
||||||
|
component: typeof SvelteComponent,
|
||||||
|
props: CodeBlockProcessorProps,
|
||||||
|
stateProvider?: StateProvider<S>
|
||||||
|
) {
|
||||||
|
return (source: string, containerEl: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
||||||
|
const node = containerEl.createEl("div");
|
||||||
|
new component({
|
||||||
|
target: containerEl,
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
...stateProvider?.(props, source, node, ctx),
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/core/stateProviders.ts
Normal file
40
src/core/stateProviders.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type {MarkdownPostProcessorContext} from "obsidian";
|
||||||
|
import type {CodeBlockProcessorProps} from "./renderCodeBlockProcessor";
|
||||||
|
import {parseYaml, stringifyYaml} from "obsidian";
|
||||||
|
|
||||||
|
export type State<T> = Partial<T>;
|
||||||
|
export type SetState<T> = (setter: (state: Partial<T>) => void) => void;
|
||||||
|
|
||||||
|
export type StateProvider<T> = (props: CodeBlockProcessorProps, source: string, node: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
||||||
|
state: State<T>,
|
||||||
|
setState: SetState<T>
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withCodeblockState<T>(): StateProvider<T> {
|
||||||
|
return (props, source, node, ctx) => {
|
||||||
|
let state: State<T> = {};
|
||||||
|
try {
|
||||||
|
state = parseYaml(source) ?? {};
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const setState: SetState<T> = (stateSetter) => {
|
||||||
|
let newState = { ...state };
|
||||||
|
stateSetter(newState);
|
||||||
|
const newStateStr: string = stringifyYaml(newState);
|
||||||
|
|
||||||
|
const info = ctx.getSectionInfo(node);
|
||||||
|
if(info) {
|
||||||
|
app.workspace.activeEditor?.editor?.replaceRange(
|
||||||
|
newStateStr + "```",
|
||||||
|
{ line: info.lineStart + 1, ch: 0 },
|
||||||
|
{ line: info.lineEnd, ch: 3 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
setState
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/plugin.ts
Normal file
35
src/plugin.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import {Plugin} from 'obsidian';
|
||||||
|
import type {AvatarPluginSettings} from "./settings";
|
||||||
|
import {DEFAULT_SETTINGS} from "./settings";
|
||||||
|
import {renderCodeBlockProcessor} from "./core/renderCodeBlockProcessor";
|
||||||
|
import AvatarView from "./components/AvatarView.svelte";
|
||||||
|
import {withCodeblockState} from "./core/stateProviders";
|
||||||
|
|
||||||
|
export class AvatarPlugin extends Plugin {
|
||||||
|
settings: AvatarPluginSettings;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
await this.loadSettings();
|
||||||
|
|
||||||
|
this.registerMarkdownCodeBlockProcessor("avatar", renderCodeBlockProcessor(
|
||||||
|
AvatarView,
|
||||||
|
{ app: this.app, plugin: this },
|
||||||
|
withCodeblockState()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
onunload() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
this.settings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...(await this.loadData())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/settings.ts
Normal file
7
src/settings.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface AvatarPluginSettings {
|
||||||
|
mySetting: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: AvatarPluginSettings = {
|
||||||
|
mySetting: 'default'
|
||||||
|
}
|
||||||
17
styles.css
17
styles.css
|
|
@ -1,8 +1,15 @@
|
||||||
/*
|
.show-in-source-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
This CSS file will be included with your plugin, and
|
.markdown-source-view > .show-in-source-view {
|
||||||
available in the app when your plugin is enabled.
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
If your plugin does not need CSS, delete this file.
|
.show-in-reading-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
*/
|
.markdown-reading-view > .show-in-reading-view {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue