mirror of
https://github.com/thorvg/thorvg.git
synced 2025-06-07 21:23:32 +00:00
Merge fc8501b63c
into 0fa5d41c8d
This commit is contained in:
commit
cfa1ff75b2
7 changed files with 232 additions and 6 deletions
|
@ -397,7 +397,7 @@ static void _repeat(LottieGroup* parent, Shape* path, RenderContext* ctx)
|
||||||
|
|
||||||
void LottieBuilder::appendRect(Shape* shape, Point& pos, Point& size, float r, bool clockwise, RenderContext* ctx)
|
void LottieBuilder::appendRect(Shape* shape, Point& pos, Point& size, float r, bool clockwise, RenderContext* ctx)
|
||||||
{
|
{
|
||||||
auto temp = (ctx->offset) ? Shape::gen() : shape;
|
auto temp = (ctx->offset || ctx->puckerBloat) ? Shape::gen() : shape;
|
||||||
auto cnt = SHAPE(temp)->rs.path.pts.count;
|
auto cnt = SHAPE(temp)->rs.path.pts.count;
|
||||||
|
|
||||||
temp->appendRect(pos.x, pos.y, size.x, size.y, r, r, clockwise);
|
temp->appendRect(pos.x, pos.y, size.x, size.y, r, r, clockwise);
|
||||||
|
@ -410,6 +410,12 @@ void LottieBuilder::appendRect(Shape* shape, Point& pos, Point& size, float r, b
|
||||||
|
|
||||||
if (ctx->offset) {
|
if (ctx->offset) {
|
||||||
ctx->offset->modifyRect(SHAPE(temp)->rs.path, SHAPE(shape)->rs.path);
|
ctx->offset->modifyRect(SHAPE(temp)->rs.path, SHAPE(shape)->rs.path);
|
||||||
|
if (ctx->puckerBloat) {
|
||||||
|
//TODO: properly applied chaining
|
||||||
|
}
|
||||||
|
delete(temp);
|
||||||
|
} else if (ctx->puckerBloat) {
|
||||||
|
ctx->puckerBloat->modifyRect(SHAPE(temp)->rs.path, SHAPE(shape)->rs.path);
|
||||||
delete(temp);
|
delete(temp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -453,6 +459,8 @@ static void _appendCircle(Shape* shape, Point& center, Point& radius, bool clock
|
||||||
SHAPE(shape)->rs.path.pts[i] *= *ctx->transform;
|
SHAPE(shape)->rs.path.pts[i] *= *ctx->transform;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx->puckerBloat) ctx->puckerBloat->modifyEllipse(SHAPE(shape)->rs.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -514,7 +522,7 @@ void LottieBuilder::updateStar(LottiePolyStar* star, float frameNo, Matrix* tran
|
||||||
bool roundedCorner = ctx->roundness && (tvg::zero(innerRoundness) || tvg::zero(outerRoundness));
|
bool roundedCorner = ctx->roundness && (tvg::zero(innerRoundness) || tvg::zero(outerRoundness));
|
||||||
|
|
||||||
Shape* shape;
|
Shape* shape;
|
||||||
if (roundedCorner || ctx->offset) {
|
if (roundedCorner || ctx->offset || ctx->puckerBloat) {
|
||||||
shape = star->pooling();
|
shape = star->pooling();
|
||||||
shape->reset();
|
shape->reset();
|
||||||
} else {
|
} else {
|
||||||
|
@ -624,7 +632,7 @@ void LottieBuilder::updatePolygon(LottieGroup* parent, LottiePolyStar* star, flo
|
||||||
angle += anglePerPoint * direction;
|
angle += anglePerPoint * direction;
|
||||||
|
|
||||||
Shape* shape;
|
Shape* shape;
|
||||||
if (roundedCorner || ctx->offset) {
|
if (roundedCorner || ctx->offset || ctx->puckerBloat) {
|
||||||
shape = star->pooling();
|
shape = star->pooling();
|
||||||
shape->reset();
|
shape->reset();
|
||||||
} else {
|
} else {
|
||||||
|
@ -724,6 +732,15 @@ void LottieBuilder::updateOffsetPath(TVG_UNUSED LottieGroup* parent, LottieObjec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void LottieBuilder::updatePuckerBloat(TVG_UNUSED LottieGroup* parent, LottieObject** child, float frameNo, TVG_UNUSED Inlist<RenderContext>& contexts, RenderContext* ctx)
|
||||||
|
{
|
||||||
|
auto puckerBloat = static_cast<LottiePuckerBloat*>(*child);
|
||||||
|
if (!ctx->puckerBloat) ctx->puckerBloat = new LottiePuckerBloatModifier(puckerBloat->amount(frameNo, tween, exps));
|
||||||
|
|
||||||
|
ctx->update(ctx->puckerBloat);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void LottieBuilder::updateRepeater(TVG_UNUSED LottieGroup* parent, LottieObject** child, float frameNo, TVG_UNUSED Inlist<RenderContext>& contexts, RenderContext* ctx)
|
void LottieBuilder::updateRepeater(TVG_UNUSED LottieGroup* parent, LottieObject** child, float frameNo, TVG_UNUSED Inlist<RenderContext>& contexts, RenderContext* ctx)
|
||||||
{
|
{
|
||||||
auto repeater = static_cast<LottieRepeater*>(*child);
|
auto repeater = static_cast<LottieRepeater*>(*child);
|
||||||
|
@ -833,6 +850,10 @@ void LottieBuilder::updateChildren(LottieGroup* parent, float frameNo, Inlist<Re
|
||||||
updateOffsetPath(parent, child, frameNo, contexts, ctx);
|
updateOffsetPath(parent, child, frameNo, contexts, ctx);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case LottieObject::PuckerBloat: {
|
||||||
|
updatePuckerBloat(parent, child, frameNo, contexts, ctx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,7 @@ struct RenderContext
|
||||||
Matrix* transform = nullptr;
|
Matrix* transform = nullptr;
|
||||||
LottieRoundnessModifier* roundness = nullptr;
|
LottieRoundnessModifier* roundness = nullptr;
|
||||||
LottieOffsetModifier* offset = nullptr;
|
LottieOffsetModifier* offset = nullptr;
|
||||||
|
LottiePuckerBloatModifier* puckerBloat = nullptr;
|
||||||
LottieModifier* modifier = nullptr;
|
LottieModifier* modifier = nullptr;
|
||||||
RenderFragment fragment = ByNone; //render context has been fragmented
|
RenderFragment fragment = ByNone; //render context has been fragmented
|
||||||
bool reqFragment = false; //requirement to fragment the render context
|
bool reqFragment = false; //requirement to fragment the render context
|
||||||
|
@ -76,6 +77,7 @@ struct RenderContext
|
||||||
delete(transform);
|
delete(transform);
|
||||||
delete(roundness);
|
delete(roundness);
|
||||||
delete(offset);
|
delete(offset);
|
||||||
|
delete(puckerBloat);
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderContext(const RenderContext& rhs, Shape* propagator, bool mergeable = false) : propagator(propagator)
|
RenderContext(const RenderContext& rhs, Shape* propagator, bool mergeable = false) : propagator(propagator)
|
||||||
|
@ -92,6 +94,10 @@ struct RenderContext
|
||||||
offset = new LottieOffsetModifier(rhs.offset->offset, rhs.offset->miterLimit, rhs.offset->join);
|
offset = new LottieOffsetModifier(rhs.offset->offset, rhs.offset->miterLimit, rhs.offset->join);
|
||||||
update(offset);
|
update(offset);
|
||||||
}
|
}
|
||||||
|
if (rhs.puckerBloat) {
|
||||||
|
puckerBloat = new LottiePuckerBloatModifier(rhs.puckerBloat->amount);
|
||||||
|
update(puckerBloat);
|
||||||
|
}
|
||||||
if (rhs.transform) {
|
if (rhs.transform) {
|
||||||
transform = new Matrix;
|
transform = new Matrix;
|
||||||
*transform = *rhs.transform;
|
*transform = *rhs.transform;
|
||||||
|
@ -174,6 +180,7 @@ private:
|
||||||
void updateRepeater(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
void updateRepeater(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
||||||
void updateRoundedCorner(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
void updateRoundedCorner(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
||||||
void updateOffsetPath(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
void updateOffsetPath(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
||||||
|
void updatePuckerBloat(LottieGroup* parent, LottieObject** child, float frameNo, Inlist<RenderContext>& contexts, RenderContext* ctx);
|
||||||
|
|
||||||
RenderPath buffer; //resusable path
|
RenderPath buffer; //resusable path
|
||||||
LottieExpressions* exps;
|
LottieExpressions* exps;
|
||||||
|
|
|
@ -269,7 +269,8 @@ struct LottieObject
|
||||||
Text,
|
Text,
|
||||||
Repeater,
|
Repeater,
|
||||||
RoundedCorner,
|
RoundedCorner,
|
||||||
OffsetPath
|
OffsetPath,
|
||||||
|
PuckerBloat
|
||||||
};
|
};
|
||||||
|
|
||||||
virtual ~LottieObject()
|
virtual ~LottieObject()
|
||||||
|
@ -554,6 +555,24 @@ struct LottieRoundedCorner : LottieObject
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct LottiePuckerBloat : LottieObject
|
||||||
|
{
|
||||||
|
LottiePuckerBloat()
|
||||||
|
{
|
||||||
|
LottieObject::type = LottieObject::PuckerBloat;
|
||||||
|
}
|
||||||
|
|
||||||
|
LottieProperty* property(uint16_t ix) override
|
||||||
|
{
|
||||||
|
if (amount.ix == ix) return &amount;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
LottieFloat amount = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
struct LottiePath : LottieShape
|
struct LottiePath : LottieShape
|
||||||
{
|
{
|
||||||
LottiePath() : LottieShape(LottieObject::Path) {}
|
LottiePath() : LottieShape(LottieObject::Path) {}
|
||||||
|
|
|
@ -168,6 +168,40 @@ void LottieOffsetModifier::line(RenderPath& out, PathCommand* inCmds, uint32_t i
|
||||||
++curPt;
|
++curPt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static Point _center(const PathCommand* cmds, uint32_t cmdsCount, const Point* pts, TVG_UNUSED uint32_t ptsCount)
|
||||||
|
{
|
||||||
|
Point center{};
|
||||||
|
auto count = 0;
|
||||||
|
auto p = (Point*)pts;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < cmdsCount; ++i) {
|
||||||
|
switch (cmds[i]) {
|
||||||
|
case PathCommand::MoveTo: {
|
||||||
|
++p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::CubicTo: {
|
||||||
|
center = center + *(p - 1) + *p + *(p + 1) + *(p + 2);
|
||||||
|
p += 3;
|
||||||
|
count += 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::LineTo: {
|
||||||
|
center = center + *(p - 1) + *p;
|
||||||
|
++p;
|
||||||
|
count += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::Close: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0 ? center / (float)count : Point{0, 0};
|
||||||
|
}
|
||||||
|
|
||||||
/************************************************************************/
|
/************************************************************************/
|
||||||
/* External Class Implementation */
|
/* External Class Implementation */
|
||||||
/************************************************************************/
|
/************************************************************************/
|
||||||
|
@ -409,4 +443,117 @@ bool LottieOffsetModifier::modifyEllipse(Point& radius)
|
||||||
radius.x += offset;
|
radius.x += offset;
|
||||||
radius.y += offset;
|
radius.y += offset;
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool LottiePuckerBloatModifier::modifyPath(PathCommand* inCmds, uint32_t inCmdsCnt, Point* inPts, TVG_UNUSED uint32_t inPtsCnt, TVG_UNUSED Matrix* transform, RenderPath& out)
|
||||||
|
{
|
||||||
|
if (next) TVGERR("LOTTIE", "Pucker/Bloat has a next modifier?");
|
||||||
|
|
||||||
|
out.cmds.reserve(inCmdsCnt);
|
||||||
|
out.pts.reserve(inPtsCnt);
|
||||||
|
|
||||||
|
auto center = _center(inCmds, inCmdsCnt, inPts, inPtsCnt);
|
||||||
|
auto a = amount * 0.01f;
|
||||||
|
auto pts = inPts;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < inCmdsCnt; ++i) {
|
||||||
|
switch (inCmds[i]) {
|
||||||
|
case PathCommand::MoveTo: {
|
||||||
|
out.pts.push(*pts + (center - *pts) * a);
|
||||||
|
out.cmds.push(PathCommand::MoveTo);
|
||||||
|
++pts;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::CubicTo: {
|
||||||
|
out.pts.push(*pts - (center - *pts) * a);
|
||||||
|
out.pts.push(*(pts + 1) - (center - *(pts + 1)) * a);
|
||||||
|
out.pts.push(*(pts + 2) + (center - *(pts + 2)) * a);
|
||||||
|
pts += 3;
|
||||||
|
out.cmds.push(PathCommand::CubicTo);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::LineTo: {
|
||||||
|
out.pts.push(*(pts - 1) - (center - *(pts - 1)) * a);
|
||||||
|
out.pts.push(*pts - (center - *pts) * a);
|
||||||
|
out.pts.push(*pts + (center - *pts) * a);
|
||||||
|
out.cmds.push(PathCommand::CubicTo);
|
||||||
|
++pts;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::Close: {
|
||||||
|
out.cmds.push(PathCommand::Close);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool LottiePuckerBloatModifier::modifyPolystar(RenderPath& in, RenderPath& out, TVG_UNUSED float, TVG_UNUSED bool)
|
||||||
|
{
|
||||||
|
return modifyPath(in.cmds.data, in.cmds.count, in.pts.data, in.pts.count, nullptr, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool LottiePuckerBloatModifier::modifyEllipse(RenderPath& path)
|
||||||
|
{
|
||||||
|
auto center = _center(path.cmds.data, path.cmds.count, path.pts.data, path.pts.count);
|
||||||
|
auto a = amount * 0.01f;
|
||||||
|
auto pts = path.pts.data;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < path.cmds.count; ++i) {
|
||||||
|
switch (path.cmds[i]) {
|
||||||
|
case PathCommand::MoveTo: {
|
||||||
|
*pts = *pts + (center - *pts) * a;
|
||||||
|
++pts;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PathCommand::CubicTo: {
|
||||||
|
*pts = *pts - (center - *pts) * a;
|
||||||
|
*(pts + 1) = *(pts + 1) - (center - *(pts + 1)) * a;
|
||||||
|
*(pts + 2) = *(pts + 2) + (center - *(pts + 2)) * a;
|
||||||
|
pts += 3;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool LottiePuckerBloatModifier::modifyRect(const RenderPath& in, RenderPath& out)
|
||||||
|
{
|
||||||
|
//sharp rectangle (5 cmds and 4 pts) – the only case where the close command actually closes the shape
|
||||||
|
if (in.cmds.count == 5) {
|
||||||
|
auto center = (in.pts[0] + in.pts[1] + in.pts[2] + in.pts[3]) * 0.25f;
|
||||||
|
auto a = amount * 0.01f;
|
||||||
|
|
||||||
|
out.cmds.grow(6);
|
||||||
|
out.pts.grow(13);
|
||||||
|
auto cmds = out.cmds.end();
|
||||||
|
auto pts = out.pts.end();
|
||||||
|
|
||||||
|
cmds[0] = PathCommand::MoveTo;
|
||||||
|
cmds[1] = cmds[2] = cmds[3] = cmds[4] = PathCommand::CubicTo;
|
||||||
|
cmds[5] = PathCommand::Close;
|
||||||
|
|
||||||
|
for (int i = 0, j = 0; i < 4; ++i) {
|
||||||
|
pts[j++] = in.pts[i] + (center - in.pts[i]) * a;
|
||||||
|
pts[j++] = in.pts[i] - (center - in.pts[i]) * a;
|
||||||
|
pts[j++] = in.pts[(i + 1) % 4] - (center - in.pts[(i + 1) % 4]) * a;
|
||||||
|
}
|
||||||
|
pts[12] = in.pts[0] + (center - in.pts[0]) * a;
|
||||||
|
|
||||||
|
out.cmds.count += 6;
|
||||||
|
out.pts.count += 13;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifyPath(in.cmds.data, in.cmds.count, in.pts.data, in.pts.count, nullptr, out);
|
||||||
}
|
}
|
|
@ -31,7 +31,7 @@
|
||||||
|
|
||||||
struct LottieModifier
|
struct LottieModifier
|
||||||
{
|
{
|
||||||
enum Type : uint8_t {Roundness = 0, Offset};
|
enum Type : uint8_t {Roundness = 0, Offset, PuckerBloat};
|
||||||
|
|
||||||
LottieModifier* next = nullptr;
|
LottieModifier* next = nullptr;
|
||||||
Type type;
|
Type type;
|
||||||
|
@ -106,4 +106,20 @@ private:
|
||||||
void corner(RenderPath& out, Line& line, Line& nextLine, uint32_t movetoIndex, bool nextClose);
|
void corner(RenderPath& out, Line& line, Line& nextLine, uint32_t movetoIndex, bool nextClose);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
struct LottiePuckerBloatModifier : LottieModifier
|
||||||
|
{
|
||||||
|
float amount;
|
||||||
|
|
||||||
|
LottiePuckerBloatModifier(float a) : amount(a)
|
||||||
|
{
|
||||||
|
type = PuckerBloat;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool modifyPath(PathCommand* inCmds, uint32_t inCmdsCnt, Point* inPts, uint32_t inPtsCnt, Matrix* transform, RenderPath& out) override;
|
||||||
|
bool modifyPolystar(RenderPath& in, RenderPath& out, float outerRoundness, bool hasRoundness) override;
|
||||||
|
bool modifyEllipse(RenderPath& path);
|
||||||
|
bool modifyRect(const RenderPath& in, RenderPath& out);
|
||||||
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
|
@ -723,6 +723,21 @@ LottieRoundedCorner* LottieParser::parseRoundedCorner()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LottiePuckerBloat* LottieParser::parsePuckerBloat()
|
||||||
|
{
|
||||||
|
auto puckerBloat = new LottiePuckerBloat;
|
||||||
|
|
||||||
|
context.parent = puckerBloat;
|
||||||
|
|
||||||
|
while (auto key = nextObjectKey()) {
|
||||||
|
if (parseCommon(puckerBloat, key)) continue;
|
||||||
|
if (KEY_AS("a")) parseProperty(puckerBloat->amount);
|
||||||
|
else skip();
|
||||||
|
}
|
||||||
|
return puckerBloat;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void LottieParser::parseColorStop(LottieGradient* gradient)
|
void LottieParser::parseColorStop(LottieGradient* gradient)
|
||||||
{
|
{
|
||||||
enterObject();
|
enterObject();
|
||||||
|
@ -871,7 +886,7 @@ LottieObject* LottieParser::parseObject()
|
||||||
else if (!strcmp(type, "tm")) return parseTrimpath();
|
else if (!strcmp(type, "tm")) return parseTrimpath();
|
||||||
else if (!strcmp(type, "rp")) return parseRepeater();
|
else if (!strcmp(type, "rp")) return parseRepeater();
|
||||||
else if (!strcmp(type, "mm")) TVGLOG("LOTTIE", "MergePath(mm) is not supported yet");
|
else if (!strcmp(type, "mm")) TVGLOG("LOTTIE", "MergePath(mm) is not supported yet");
|
||||||
else if (!strcmp(type, "pb")) TVGLOG("LOTTIE", "Puker/Bloat(pb) is not supported yet");
|
else if (!strcmp(type, "pb")) return parsePuckerBloat();
|
||||||
else if (!strcmp(type, "tw")) TVGLOG("LOTTIE", "Twist(tw) is not supported yet");
|
else if (!strcmp(type, "tw")) TVGLOG("LOTTIE", "Twist(tw) is not supported yet");
|
||||||
else if (!strcmp(type, "op")) return parseOffsetPath();
|
else if (!strcmp(type, "op")) return parseOffsetPath();
|
||||||
else if (!strcmp(type, "zz")) TVGLOG("LOTTIE", "ZigZag(zz) is not supported yet");
|
else if (!strcmp(type, "zz")) TVGLOG("LOTTIE", "ZigZag(zz) is not supported yet");
|
||||||
|
|
|
@ -89,6 +89,7 @@ private:
|
||||||
LottiePath* parsePath();
|
LottiePath* parsePath();
|
||||||
LottiePolyStar* parsePolyStar();
|
LottiePolyStar* parsePolyStar();
|
||||||
LottieRoundedCorner* parseRoundedCorner();
|
LottieRoundedCorner* parseRoundedCorner();
|
||||||
|
LottiePuckerBloat* parsePuckerBloat();
|
||||||
LottieGradientFill* parseGradientFill();
|
LottieGradientFill* parseGradientFill();
|
||||||
LottieLayer* parseLayers(LottieLayer* root);
|
LottieLayer* parseLayers(LottieLayer* root);
|
||||||
LottieMask* parseMask();
|
LottieMask* parseMask();
|
||||||
|
|
Loading…
Add table
Reference in a new issue