implement AvatarView.svelte

This commit is contained in:
froehlichA 2023-03-18 18:01:29 +01:00
parent 6631d953c2
commit 6e0b8362fe
9 changed files with 257 additions and 141 deletions

138
main.ts
View file

@ -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!
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();
}));
}
}
export default AvatarPlugin;

View 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
View 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>

View 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>

View 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
}
});
}
}

View 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
View 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
View file

@ -0,0 +1,7 @@
export interface AvatarPluginSettings {
mySetting: string;
}
export const DEFAULT_SETTINGS: AvatarPluginSettings = {
mySetting: 'default'
}

View file

@ -1,8 +1,15 @@
/*
.show-in-source-view {
display: none;
}
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.markdown-source-view > .show-in-source-view {
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;
}