Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[migrate] Git Pager #10

Merged
merged 5 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

- uses: pnpm/action-setup@v2
with:
version: 8
version: 9

- uses: actions/setup-node@v3
if: ${{ !env.VERCEL_TOKEN || !env.VERCEL_ORG_ID || !env.VERCEL_PROJECT_ID }}
Expand Down
30 changes: 30 additions & 0 deletions components/Form/JSONEditor/AddBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Icon } from 'idea-react';
import { FC } from 'react';
import { Button } from 'react-bootstrap';

const type_map = {
string: { title: 'Inline text', icon: 'input-cursor' },
text: { title: 'Rows text', icon: 'text-left' },
object: { title: 'Key-value list', icon: 'list-ul' },
array: { title: 'Ordered list', icon: 'list-ol' },
};

export interface AddBarProps {
onSelect: (type: string) => void;
}

export const AddBar: FC<AddBarProps> = ({ onSelect }) => (
<nav className="d-flex gap-1">
{Object.entries(type_map).map(([key, { title, icon }]) => (
<Button
key={key}
size="sm"
variant="success"
title={title}
onClick={onSelect.bind(null, key)}
>
<Icon name={icon} />
</Button>
))}
</nav>
);
186 changes: 186 additions & 0 deletions components/Form/JSONEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { DataObject } from 'mobx-restful';
import { ChangeEvent, Component, ReactNode } from 'react';
import { Form } from 'react-bootstrap';

import { AddBar } from './AddBar';

export interface DataMeta {
type: string;
key?: string | number;
value: any;
children?: DataMeta[];
}

export interface FieldProps {
value: DataObject | any[] | null;
onChange?: (event: FieldChangeEvent) => void;
}
export type FieldChangeEvent = ChangeEvent<{ value: FieldProps['value'] }>;

@observer
export class ListField extends Component<FieldProps> {
@observable
accessor innerValue = {} as DataMeta;

componentDidMount() {
this.innerValue = ListField.metaOf(this.props.value);
}

componentDidUpdate({ value }: Readonly<FieldProps>) {
if (value !== this.props.value) this.componentDidMount();
}

static metaOf(value: any): DataMeta {
if (value instanceof Array)
return {
type: 'array',
value,
children: Array.from(value, (value, key) => ({
...this.metaOf(value),
key,
})),
};

if (value instanceof Object)
return {
type: 'object',
value,
children: Object.entries(value).map(([key, value]) => ({
...this.metaOf(value),
key,
})),
};

return {
type: /[\r\n]/.test(value) ? 'text' : 'string',
value,
};
}

addItem = (type: string) => {
var item: DataMeta = { type, value: [] },
{ innerValue } = this;

switch (type) {
case 'string':
item = ListField.metaOf('');
break;
case 'text':
item = ListField.metaOf('\n');
break;
case 'object':
item = ListField.metaOf({});
break;
case 'array':
item = ListField.metaOf([]);
}

this.innerValue = {
...innerValue,
children: [...(innerValue.children || []), item],
};
};

protected dataChange =
(method: (item: DataMeta, newKey: string) => any) =>
(index: number, { currentTarget: { value: data } }: ChangeEvent<any>) => {
const { children = [] } = this.innerValue;

const item = children[index];

if (!item) return;

method.call(this, item, data);

this.props.onChange?.({
currentTarget: { value: this.innerValue.value },
} as FieldChangeEvent);
};

setKey = this.dataChange((item: DataMeta, newKey: string) => {
const { value, children = [] } = this.innerValue;

item.key = newKey;

for (let oldKey in value)
if (!children.some(({ key }) => key === oldKey)) {
value[newKey] = value[oldKey];

delete value[oldKey];
return;
}

value[newKey] = item.value;
});

setValue = this.dataChange((item: DataMeta, newValue: any) => {
const { value } = this.innerValue;

if (newValue instanceof Array) newValue = [...newValue];
else if (typeof newValue === 'object') newValue = { ...newValue };

item.value = newValue;

if (item.key != null) value[item.key + ''] = newValue;
else if (value instanceof Array) item.key = value.push(newValue) - 1;
});

fieldOf(index: number, type: string, value: any) {
switch (type) {
case 'string':
return (
<Form.Control
defaultValue={value}
placeholder="Value"
onBlur={this.setValue.bind(this, index)}
/>
);
case 'text':
return (
<Form.Control
as="textarea"
defaultValue={value}
placeholder="Value"
onBlur={this.setValue.bind(this, index)}
/>
);
default:
return (
<ListField value={value} onChange={this.setValue.bind(this, index)} />
);
}
}

wrapper(slot: ReactNode) {
const Tag = this.innerValue.type === 'array' ? 'ol' : 'ul';

return <Tag className="list-unstyled d-flex flex-column gap-3">{slot}</Tag>;
}

render() {
const { type: field_type, children = [] } = this.innerValue;

return this.wrapper(
<>
<li>
<AddBar onSelect={this.addItem} />
</li>
{children.map(({ type, key, value }, index) => (
<li className="d-flex align-items-center gap-3" key={key}>
{field_type === 'object' && (
<Form.Control
defaultValue={key}
required
placeholder="Key"
onBlur={this.setKey.bind(this, index)}
/>
)}
{this.fieldOf(index, type, value)}
</li>
))}
</>,
);
}
}
67 changes: 67 additions & 0 deletions components/Form/MarkdownEditor/TurnDown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import TurnDown from 'turndown';
// @ts-ignore
import { gfm, strikethrough, tables, taskListItems } from 'turndown-plugin-gfm';

const Empty_HREF = /^(#|javascript:\s*void\(0\);?\s*)$/;

export default new TurnDown({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
linkStyle: 'referenced',
})
.use(strikethrough)
.use(tables)
.use(taskListItems)
.use(gfm)
.addRule('non_url', {
filter: node =>
['a', 'area'].includes(node.nodeName.toLowerCase()) &&
Empty_HREF.test(node.getAttribute('href') || ''),
replacement: (content, node) =>
content.trim() || (node instanceof HTMLElement ? node.title.trim() : ''),
})
.addRule('img-srcset', {
filter: ['img'],
replacement(_, node) {
const { alt, title, src, srcset } = node as HTMLImageElement;
const [firstSet] = srcset.split(',')[0]?.split(/\s+/) || [];

const content = [src || firstSet, title && JSON.stringify(title)].filter(
Boolean,
);
return `![${alt}](${content.join(' ')})`;
},
})
.addRule('source-srcset', {
filter: ['picture'],
replacement(_, node) {
const { src, alt, title } = node.querySelector('img') || {};

const sourceList = Array.from(
node.querySelectorAll('source'),
({ sizes, srcset }) => {
const size = Math.max(
...sizes
.split(/,|\)/)
.map(pixel => parseFloat(pixel.trim()))
.filter(Boolean),
);
const [src] = srcset.split(',')[0]?.split(/\s+/) || [];

return { size, src };
},
);
const sources = sourceList.sort(({ size: a }, { size: b }) => b - a);

const content = [
src || sources[0]?.src,
title && JSON.stringify(title),
].filter(Boolean);

return `![${alt}](${content.join(' ')})`;
},
})
.remove(node => node.matches('style, script, aside, form, [class*="ads" i]'))
.keep('iframe');
20 changes: 20 additions & 0 deletions components/Form/MarkdownEditor/index.module.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.editor {
position: relative;
min-height: 2.35rem;
&::before {
position: absolute;
right: 1px;
top: 1px;
padding: 0.3rem 0.5rem;
background: white;
content: 'count: ' attr(data-count) !important;
}
&:focus::before {
content: none;
}
img {
display: block;
margin: 1rem auto;
max-height: 70vh;
}
}
Loading