Open m-paul opened 2 years ago
Here is a custom hook I made some time ago:
defines-react-components:: true
```jsx:component:useStoredState
const ctx = useContext(ReactComponentContext);
var ppctx = ctx.markdownPostProcessorContext;
const [propertyName, defaultValue] = props;
const dv = app.plugins.plugins["dataview"].api;
const page = dv.page(ppctx.sourcePath);
const dataPath = page["data-path"];
const propertyDataPath = `${dataPath}/${propertyName}.json`
const [val, setVal] = useState(defaultValue);
const [timeoutID, setTimeoutID] = useState(-1);
let otherTimeoutID = timeoutID;
useEffect(async ()=>{
if(!dataPath) {
new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
return null;
}
try {
const newVal = await app.vault.readJson(propertyDataPath);
if(val!=newVal && newVal!==null) {
setVal(newVal);
}
} catch(e){}
}, []);
const setStoredValue = async val => {
if(!dataPath) {
new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
return null;
}
try {
await app.vault.createFolder(dataPath);
} catch(e){}
setVal(val);
clearTimeout(timeoutID);
clearTimeout(otherTimeoutID);
const newTimeoutId = setTimeout(()=>{
app.vault.writeJson(propertyDataPath, val);
},2000);
otherTimeoutID=newTimeoutId;
setTimeoutID(newTimeoutId);
}
return [val, setStoredValue];
To use it, you need to have Dataview installed, and specify a location for the data like this:
````md
data-path:: data/my-button
```jsx:
function Counter(props) {
const [count, setCount] = useStoredState(["count",0])
return (
<div>
<p>You clicked me {count} times!!!</p>
<button onClick={() => setCount(count + 1)}>
{props.source}
</button>hello
</div>
)
}
<Counter />
I have not tested it extensively, so there may be some bugs, but this should hopefully get you started :)
Ah, I see. So instead of doing something in-memory, you're creating a json file that acts as a persisted data store.
Have a couple of questions:
~I believe obsidian recommends intervals created by setTimeout be registered with the plugin itself, so I'll try to see if I can wire that up too.~ (conflating setTimeout with setInterval)
When I implemented this I was experimenting with making textboxes that stored their state. I felt that it would be unnecessarily taxing on my hard drive to write a new file every time I pressed a character on my keyboard, and it could also potentially degrade performance, so I made it wait until no new change has been made for 2 seconds before updating the file.
Regarding the two timeout ids. When I made this I wasn't aware that you can get the current timeout state by calling setTimeOutId with a function, so I made this ugly workaround instead.
timeoutId represents the timeout id at the moment when the component was last rerendered.
otherTimeoutId represents the id of the timeout that this component most recently created.
I was too lazy to make any changes to the code. Sorry.
A less hacky version would look something like
defines-react-components:: true
```jsx:component:useStoredState
const ctx = useContext(ReactComponentContext);
var ppctx = ctx.markdownPostProcessorContext;
const [propertyName, defaultValue] = props;
const dv = app.plugins.plugins["dataview"].api;
const page = dv.page(ppctx.sourcePath);
const dataPath = page["data-path"];
const propertyDataPath = `${dataPath}/${propertyName}.json`
const [val, setVal] = useState(defaultValue);
const [timeoutId, setTimeoutId] = useState(-1);
useEffect(async ()=>{
if(!dataPath) {
new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
return null;
}
try {
const newVal = await app.vault.readJson(propertyDataPath);
if(val!=newVal && newVal!==null) {
setVal(newVal);
}
} catch(e){}
}, []);
const setStoredValue = async val => {
if(!dataPath) {
new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
return null;
}
try {
await app.vault.createFolder(dataPath);
} catch(e){}
setVal(val);
setTimeoutId(timeoutId=>{
clearTimeout(timeoutId);
return setTimeout(()=>{
app.vault.writeJson(propertyDataPath, val);
},2000);
});
}
return [val, setStoredValue];
I found a way to do this in-memory (since I don't care about persisting the data) by tacking on a variable to the plugin instance, though I'm not sure if it's a good idea.
const rxc = app.plugins.plugins["obsidian-react-components"];
if (!("cache" in rxc)) {
rxc.cache = {};
}
const [propertyName, defaultValue] = props;
const [val, updateCache] = useState(defaultValue);
var sourcePath = useContext(ReactComponentContext).markdownPostProcessorContext.sourcePath;
useEffect(()=>{
const newVal = rxc.cache?.[sourcePath]?.[propertyName];
if ( val != newVal && newVal !== undefined && newVal !== null) {
updateCache(newVal);
}
}, []);
return [
val,
val => {
if (!(sourcePath in rxc.cache)) {
rxc.cache[sourcePath] = {}
}
rxc.cache[sourcePath][propertyName] = val;
updateCache(val);
}
];
defines-react-components:: true
function Counter(props) {
const [count, setCount] = useStoredState(["count",0])
return (
<div>
<p>You clicked me {count} times!!! {JSON.stringify(props)}</p>
[<button onClick={() => setCount(count + 1)}>
{props.source}
</button>]
</div>
)
}
<Counter />
Yeah. On the off chance that I add a property called cache
to the plugin, this could interfere, so it might be better to just do window.reactCache
or something. But otherwise this solution should work fine if you don't care about things persisting when you restart obsidian.
I made something similar before , the only difference is that all components referencing the property will be re-rendered when the state is changed:
```jsx:component:useGlobalState
const [value, setValue] = useState(null);
const propertyName = props;
global.globalReactStateListeners =
global.globalReactStateListeners??new Map();
global.globalReactState = global.globalReactState ?? new Map();
useEffect(()=>{
if(global.globalReactState.has(propertyName)) {
setValue(global.globalReactState.get(propertyName))
} if(!global.globalReactStateListeners.has(propertyName)) {
global.globalReactStateListeners.set(propertyName, new Set());
}
global.globalReactStateListeners.get(propertyName).add(setValue);
return ()=>{global.globalReactStateListeners.get(propertyName).delete(setValue);}
})
function setGlobalValue(val) {
global.globalReactState.set(propertyName, val);
for(const setter of global.globalReactStateListeners.get(propertyName)) {
setter(val);
}
}
return [value, setGlobalValue];
Any thoughts on how I might inject a global provider? I tried using a react-query provider with context sharing enabled but each instance of my component across pages still needed up with their own provider.
I can't think of a way to do that on the top of my head, but it sounds like a reasonable feature request.
Do you have a proposal for how it could work?
Perhaps conceptually, but I'm still working my way through the code and piecing it all together. What I'm saying here might not make sense lol. I have three ideas, with three levels of complexity.
(1) Simpliest approach might be the best - just a single App-level wrapper for all pages. Maybe in the vein of how Header Components work but expanded so that you can specify header and footers and wrap all components on pages.
(2) A little more complicated approach would be to allow each page to specify which component it wants to wrap the page (and this component would live for the lifetime of the app) - what way you can have multiple types of wrappers depending on the page, or a default one to fall back on.
(3) Conceptually, an advanced option might look like this:
I can see this approach might lead to duplicative code or a question of what order page level components should be loaded if there are dependencies. One way of managing this would be for the page level components or app level components to call a common resolver behind the scenes (function defined in the plugin settings similar to how the javascript-init does), that is provided the component being requested, and have business logic to init dependent components if not already).
I found a way to use react-query, but it involves an intermediary component. An alternative implementation could make use of react portals (if I wanted to go the way of embedding a single page app into the page).
What I wish I could do is specify the name of a pre-processor function in the component page's front-matter such that, when reactToWebComponent
is called, the pre-processor is passed the react component, its name, and page information (name and front-matter), can programmatically wrap the component however it would like and return the component back for it to be rendered on the page. That would remove the need for a wrapper.
Just nice to have though. It all works as is.
`jsx:<Jira issue="ARC-550" title="AWS SSO" aria-label-position="top" aria-label="ARC-550" />`
```jsx:component:getCache
import { QueryClient } from 'https://cdn.skypack.dev/react-query';
const rxc = app.plugins.plugins["obsidian-react-components"];
if (!("m-paul" in rxc)) {
let queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 5, // 5 minutes
refetchOnMount: true, // only if stale
refetchOnWindowFocus: true, // only if stale
refetchOnReconnect: true, // only if stale
refetchInterval: 1000 * 60 * 60, // 1 hour
refetchIntervalInBackground: false, // refresh if app in focus
suspense: false, // don't suspend or raise errors
}
}
});
rxc["m-paul"]= {
queryClient,
refreshReactComponents: () => {
app.workspace.trigger('react-components:component-updated')
},
refreshQueries: (...params) => {
queryClient.invalidateQueries(...params);
}
};
}
return rxc["m-paul"];
---
````js
```jsx:component:Jira
import { QueryClientProvider } from 'https://cdn.skypack.dev/react-query';
return (
<QueryClientProvider client={getCache().queryClient} contextSharing={true}>
<_Jira {...props} />
</QueryClientProvider>
)
---
````js
```jsx:component:_Jira
import { useQuery } from 'https://cdn.skypack.dev/react-query';
import styled from 'https://cdn.skypack.dev/styled-components';
const API = "https://example.atlassian.net";
const TOKEN = new Buffer("username@email.net:REDACTED_CREDENTIALS").toString("base64);
const CACHE = getCache();
if (props?.refresh == true) {
const RefreshButton = styled.button`
color: black;
padding: 10px;
margin: 0px 5px 0px 5px;
box-shadow: 0 5px 5px rgba(9,30,66,0.25);
:hover { background: lightblue; }
`;
function refresh() {
CACHE.refreshQueries("jira");
}
return <RefreshButton onClick={refresh}>Refresh All</RefreshButton>;
}
const jiraDefaults = (() => {
let url = API + "/rest/api/2/universal_avatar/view/type/issuetype/avatar";
let grey;
let blue;
return {
types: {
default: { url: url + "/10320?size=medium" },
story: { url: url + "/10315?size=medium" },
bug: { url: url + "/10303?size=medium" },
epic: { url: url + "/10307?size=medium" },
task: { url: url + "/10318?size=medium" },
subtask: { url: url + "/10316?size=medium" },
},
statuses: {
default: (grey = {
color: "rgb(66, 82, 110)",
"background-color": "rgb(223, 225, 230)"
}),
backlog: grey,
ready: grey,
dev: (blue = {
color: "rgb(7, 71, 166)",
"background-color": "rgb(222, 235, 255)"
}),
qa: blue,
ready_for_acceptance: blue,
blocked: {
color: "#7D2828",
"background-color": "#F9DADA"
},
done: {
color: "rgb(0, 102, 68)",
"background-color": "rgb(227, 252, 239)"
},
}
}
})();
function makeIssue(id, attrs, data=null) {
// attributes take precedence
let title = attrs?.title;
let type = attrs?.type;
let icon = attrs?.icon;
let status = attrs?.status;
// show loading when no title provided
if (data == null) {
if (title == null) {
title = "Loading..."
} else {
status = "Loading..."
}
}
// fill in rest with server values
if (data != null) {
title = title ?? data?.fields?.summary;
type = type ?? data?.fields?.issuetype?.name;
status = status ?? data?.fields?.status?.name;
}
let t = (type ?? "default").toLowerCase().replaceAll("-","");
let s = (status ?? "default").toLowerCase().replaceAll(" ","_");
return {
id: id,
url: `${API}/browse/${id}`,
title: title,
type: {
name: type ?? "UNKNOWN",
url: icon ?? (jiraDefaults.types?.[t] ?? jiraDefaults.types.default).url
},
status: {
name: status ?? "UNKNOWN",
...(jiraDefaults.statuses?.[s] ?? jiraDefaults.statuses.default)
}
}
}
const { isFetching, isLoading, isError, data, error } = useQuery(
['jira', props.issue],
() => (
obsidian.request({
method: 'get',
url: `${API}/rest/api/latest/issue/${props.issue}`,
headers: {
'Content-Type': 'application/json',
"Authorization": `Basic ${TOKEN}`
}
})
.then(content => {
console.info(`fetched ${props.issue}`);
return JSON.parse(content);
})
.catch(err => { console.error(err) })
)
);
if (isError) {
throw error.message;
}
var issue = makeIssue(props.issue, props, (isLoading || isFetching) ? null : data);
// define styles
const Container = styled.span`.workspace > .workspace-split:not(.mod-root) .markdown-preview-view & { line-height: 2; }`;
const Anchor = styled.a`text-decoration: none; padding: 1px 0.24em 2px; display: inline; border-radius: 3px; color: rgb(0, 82, 204); box-shadow: 0 1px 1px rgba(9,30,66,0.25); cursor: pointer; transition: all 0.1s ease-in-out 0s;`;
const DetailWrapper = styled.span`hyphens: auto; white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word;`;
const IconWrapper = styled.span`margin-right: 4px; position: relative; display: inline-block;`;
const IconSpacer = styled.span`width: 14px; height: 100%; display: inline-block; opacity: 0;`;
const Icon = styled.img`height: 14px; width: 14px; margin-right: 4px; border-radius: 2px; user-select: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);`;
const StatusWrapper = styled.span`display: inline-block; vertical-align: 1px;`;
const StatusBlock = styled.span`
margin-left: 4px; background-color: ${issue.status["background-color"]}; color: ${issue.status.color}; display: inline-block; box-sizing: border-box; max-width: 100%;
padding: 2px 0px 3px; border-radius: 3px; font-weight: 700; font-size: 11px; line-height: 1; text-transform: uppercase; vertical-align: baseline;
.workspace > .workspace-split:not(.mod-root) .markdown-preview-view & { font-weight: 600; font-family: Tahoma; font-size: 8px; padding: 1px 0px 2px; }
`;
const Status = styled.span`display: inline-block; box-sizing: border-box; width: 100%; padding: 0px 4px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; white-space: nowrap;`;
const DebugBtn = styled.button`
line-height: 0px; color: white; padding: 5px 5px 5px 5px; border-radius: 50px; margin: 0px 5px 0px 5px; box-shadow: 0 2px 2px rgba(9,30,66,0.25); :hover { background: lightblue; } display:none;
${Container}:hover & { display:inline; }
.workspace > .workspace-split:not(.mod-root) .markdown-preview-view & { font-weight: 600; font-family: Tahoma; font-size: 8px; }
`;
const RefreshBtn = <DebugBtn
onClick={() => {
CACHE.refreshQueries(['jira', props.issue]);
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="10" height="10">
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M5.463 4.433A9.961 9.961 0 0 1 12 2c5.523 0 10 4.477 10 10 0 2.136-.67 4.116-1.81 5.74L17 12h3A8 8 0 0 0 6.46 6.228l-.997-1.795zm13.074 15.134A9.961 9.961 0 0 1 12 22C6.477 22 2 17.523 2 12c0-2.136.67-4.116 1.81-5.74L7 12H4a8 8 0 0 0 13.54 5.772l.997 1.795z" fill="rgba(0,0,0,1)"/>
</svg>
</DebugBtn>
const title = (props?.title == null) ? `${issue.id}: ${issue.title}` : props.title;
return (
<Container {...props}>
<Anchor href={issue.url}>
<DetailWrapper>
<IconWrapper>
<IconSpacer/>
<Icon src={issue.type.url}/>
</IconWrapper>
{title}
</DetailWrapper>
<StatusWrapper>
<StatusBlock>
<Status>
{issue.status?.name}
</Status>
</StatusBlock>
</StatusWrapper>
</Anchor>
{RefreshBtn}
{props.debug ? <span>{JSON.stringify(issue)}</span>: null}
</Container>
)
Nice use case :)
I have been busy with other things recently, but adding a wrapper using the frontmatter shouldn't be too difficult. I'll try to look into it soon.
Hey, I'm running into an issue in Obsidian where the screen periodically becomes unresponsive with this plugin - eventually it starts to take input, but if I use the component above, walk away for a bit and come back, the screen doesn't respond to clicking or the cursor for about a minute or so. It's not happening during the fetching of data (I have logs for that). Not sure what the issue is. It doesn't occur when I disable the plugin.
I updated my code to using "register" against your plugin to remove the cached object, but that's doesn't solve for the issue. Not sure what's happening.
I'm relatively new to React, so I might not be asking this question correctly, but is there a way to cache data at a more global level within Obsidian and access that data from a component?
For example, I created a test.md file with the following content:
The component renders, and I can click the button incrementing the count, but if I click the button multiple times, sometimes the counter resets to 0, as if the component was re-rendered/reset. And, when I navigate away from the page and back, the counter is reset to 0. What I'd like to do is store the counter in a state that is persisted between reloads.
This is a basic use-case, but solving for this one would help me solve for a more complicated use-case. I am trying to build a component that will pull back data via an API, but only do so if the data isn't already cached - and where the cache persists even when navigating between pages or when the page is modified or re-rendered (but not necessarily when the app is restarted).