完成页面列表预览组件

master
落雨楓 1 year ago
parent fb8e78e630
commit 6e162edd86

@ -2,13 +2,13 @@
"name": "IsekaiWidgets",
"namemsg": "isekai-widgets",
"author": "Hyperzlib",
"version": "1.0.3",
"version": "1.0.4",
"url": "https://github.com/Isekai-Project/mediawiki-extension-IsekaiWidgets",
"descriptionmsg": "isekai-widgets-desc",
"license-name": "GPL-2.0-or-later",
"type": "parserhook",
"requires": {
"MediaWiki": ">= 1.35.0"
"MediaWiki": ">= 1.39.0"
},
"MessagesDirs": {
"IsekaiWidgets": [
@ -101,6 +101,30 @@
"mobile"
]
},
"ext.isekai.previewPageList": {
"styles": [
"previewPageList/ext.isekai.previewPageList.less"
],
"packageFiles": [
{ "name": "isekaipreviewPageList", "file": "previewPageList/ext.isekai.previewPageList.js", "main": true },
{ "name": "PreviewPageList.vue", "file": "previewPageList/PreviewPageList.vue" }
],
"es6": true,
"dependencies": [
"oojs-ui-core",
"oojs-ui.styles.icons-movement",
"oojs-ui.styles.icons-interactions",
"vue"
],
"targets": [
"desktop",
"mobile"
],
"messages": [
"isekai-preview-page-list-preview-placeholder",
"isekai-preview-page-list-readmore-btn"
]
},
"ext.isekai.information.infobox": {
"styles": [
"information/ext.isekai.information.infobox.less"

@ -34,5 +34,10 @@
"isekai-fab-hide-fab-button-success": "浮动按钮已隐藏,刷新页面可重新显示。",
"isekai-offcanvastoc-menubutton": "目录",
"isekai-offcanvastoc-description-item": "简介"
"isekai-offcanvastoc-description-item": "简介",
"isekai-preview-page-list-error-invalid-category": "未指定分类或分类名无效",
"isekai-preview-page-list-error-invalid-loader": "未知的页面列表加载器:\"$1\"",
"isekai-preview-page-list-preview-placeholder": "请选择一个页面进行查看",
"isekai-preview-page-list-readmore-btn": "查看"
}

@ -0,0 +1,71 @@
<?php
namespace Isekai\Widgets;
use Html;
use Title;
class PreviewPageListWidget {
public const CONTAINER_CLASS_NAME = 'isekai-card isekai-preview-page-list-card';
public static function create($text, $params, \Parser $parser, \PPFrame $frame) {
$parser->getOutput()->addModules(['ext.isekai.previewPageList']);
$loader = $params['loader'] ?? 'unknown';
if ($loader === 'unknown' && !empty(trim($text))) {
$loader = 'pages';
}
$autoFocus = $params['autofocus'] ?? false;
if ($autoFocus === 'false' || $autoFocus === '0') {
$autoFocus = false;
} else {
$autoFocus = true;
}
$lazyLoad = $params['lazyload'] ?? false;
if ($lazyLoad === 'false' || $lazyLoad === '0') {
$lazyLoad = false;
} else {
$lazyLoad = true;
}
switch ($loader) {
case 'pages':
$pageList = explode("\n", trim($text));
$pageList = array_map(function ($page) {
return trim($page);
}, $pageList);
return Html::openElement('div', [
'class' => self::CONTAINER_CLASS_NAME,
'data-loader' => $loader,
'data-autofocus' => $autoFocus,
'data-lazyload' => $lazyLoad,
]) . Html::element('script', [
'type' => 'application/json',
'data-pages' => true,
], json_encode($pageList)) . Html::closeElement('div');
case 'category':
$category = $params['category'] ?? null;
if (!$category) {
return Html::element('span', [
'class' => 'error'
], wfMessage('isekai-preview-page-list-error-invalid-category')->parse());
}
$categoryTitle = new Title($category, NS_CATEGORY);
$categoryTitleText = $categoryTitle->getPrefixedText();
return Html::element('div', [
'class' => self::CONTAINER_CLASS_NAME,
'data-category' => $categoryTitleText,
'data-autofocus' => $autoFocus,
'data-lazyload' => $lazyLoad,
]);
}
return Html::element('span', [
'class' => 'error'
], wfMessage('isekai-preview-page-list-error-invalid-loader', $loader)->parse());
}
}

@ -18,6 +18,7 @@ class Widgets {
$parser->setHook('discoverbox', [DiscoverWidget::class, 'create']);
$parser->setHook('feedlist', [FeedListWidget::class, 'create']);
$parser->setHook('previewcard', [PreviewCardWidget::class, 'create']);
$parser->setHook('previewpagelist', [PreviewPageListWidget::class, 'create']);
$parser->setHook('buttonlink', [ButtonLinkWidget::class, 'create']);
$parser->setHook('tile', [TileWidget::class, 'create']);

@ -56,6 +56,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
border-top-left-radius: @isekai-card-border-radius;
border-top-right-radius: @isekai-card-border-radius;
@ -64,6 +65,7 @@
.card-header-text {
font-size: 1.25rem;
flex: 1 1;
}
@media(max-width: 360px){
@ -132,9 +134,9 @@
background-color: rgba(0,0,0,.08);
}
&:last-of-type {
border-bottom: none;
}
// &:last-of-type {
// border-bottom: none;
// }
&.active {
box-shadow: inset 4px 0 0 0 #3366cc;
@ -175,6 +177,11 @@
}
}
.isekai-list-item-subtitle {
opacity: 0.6;
font-size: 0.9rem;
}
.isekai-list-item-content {
-webkit-box-flex: 1;
-ms-flex-positive: 1;

@ -1,4 +1,6 @@
<script>
const { ref, onMounted } = require('vue');
module.exports = {
compatConfig: {
MODE: 3
@ -9,9 +11,9 @@ module.exports = {
setup() {
const bbsUrl = 'https://bbs.isekai.cn';
const mounted = Vue.ref(false);
const loading = Vue.ref(true);
const feedList = Vue.ref([]);
const mounted = ref(false);
const loading = ref(true);
const feedList = ref([]);
const api = new mw.Api();
let recentData = {
@ -187,7 +189,7 @@ module.exports = {
}
}
Vue.onMounted(() => {
onMounted(() => {
mounted.value = true;
loadData();
});

@ -0,0 +1,280 @@
<script>
const { ref, watch, computed, onMounted, nextTick } = require('vue');
function throttle(fn, delay) {
let timer = null;
return function () {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, arguments);
timer = null;
}, delay);
}
}
}
function isMobile() {
return window.innerWidth <= 576;
}
module.exports = {
compatConfig: {
MODE: 3
},
compilerOptions: {
whitespace: 'condense'
},
props: {
pageListLoader: {
type: Object,
default: null,
},
autoFocus: {
type: Boolean,
default: false,
},
lazyLoad: {
type: Boolean,
default: false
}
},
setup(props) {
const containerRef = ref();
let initLoaded = false;
const pageListLoading = ref(true);
const contentLoading = ref(false);
const pageList = ref([]);
const selectedPageIdx = ref(null);
const previewPageContent = ref('');
const selectedPageUrl = ref('#');
const bookletClassList = ref([]);
const onSelectPage = (idx) => {
selectedPageIdx.value = idx;
}
const selectedPageInfo = computed(() => {
if (selectedPageIdx.value !== null && selectedPageIdx.value < pageList.value.length) {
return pageList.value[selectedPageIdx.value];
} else {
return '';
}
});
const loadPageList = () => {
props.pageListLoader.loadPageList().then((loadedPageList) => {
pageListLoading.value = false;
pageList.value = loadedPageList;
initLoaded = true;
//
if (!isMobile() && selectedPageIdx.value === null && props.autoFocus && pageList.value.length > 0) {
selectedPageIdx.value = 0;
}
}).catch((err) => {
console.error(err);
});
}
const loadPageContent = () => {
contentLoading.value = true;
selectedPageUrl.value = '#';
props.pageListLoader.loadPageContent(selectedPageInfo.value).then((pageContent) => {
contentLoading.value = false;
previewPageContent.value = pageContent;
props.pageListLoader.getPageUrl(selectedPageInfo.value).then((pageUrl) => {
selectedPageUrl.value = pageUrl;
}).catch((err) => {
console.error(err);
});
}).catch((err) => {
contentLoading.value = false;
console.error(err);
});
}
// Lazy Load
const onWindowScroll = throttle(() => {
if (initLoaded) {
window.removeEventListener('scroll', onWindowScroll, { passive: true });
return;
}
let container = containerRef.value;
let containerRect = container.getBoundingClientRect();
let containerTop = containerRect.top;
let containerBottom = containerRect.bottom;
let windowHeight = window.innerHeight;
if (containerTop < windowHeight && containerBottom > 0) {
loadPageList();
window.removeEventListener('scroll', onWindowScroll, { passive: true });
}
}, 100);
const onClickBack = () => {
focusMenuTransition().then(() => {
selectedPageIdx.value = null;
});
}
const focusContentTransition = () => {
return new Promise((resolve, reject) => {
//
bookletClassList.value = ['animating', 'focus-menu-transition'];
nextTick(() => {
setTimeout(() => {
bookletClassList.value = ['animating', 'focus-content-transition'];
}, 10);
setTimeout(() => {
bookletClassList.value = ['focus-content'];
resolve();
}, 200);
});
});
}
const focusMenuTransition = () => {
return new Promise((resolve, reject) => {
// 退
bookletClassList.value = ['animating', 'focus-content-transition'];
nextTick(() => {
setTimeout(() => {
bookletClassList.value = ['animating', 'focus-menu-transition'];
}, 10);
setTimeout(() => {
bookletClassList.value = ['focus-menu'];
resolve();
}, 200);
});
});
}
watch(() => selectedPageIdx.value, (newValue, oldValue) => {
//
if (newValue !== null) {
loadPageContent();
}
//
if (oldValue === null && newValue !== null) {
focusContentTransition();
}
});
onMounted(() => {
if (!props.pageListLoader) {
console.error('pageListLoader is not set');
return;
}
if (!props.lazyLoad) {
loadPageList();
} else {
window.addEventListener('scroll', onWindowScroll, { passive: true });
nextTick(() => {
onWindowScroll(); // init
});
}
});
return {
// global mw object
mw,
// states
containerRef,
pageListLoading,
contentLoading,
pageList,
selectedPageIdx,
previewPageContent,
selectedPageInfo,
selectedPageUrl,
bookletAnimateClassList: bookletClassList,
// methods
onSelectPage,
onClickBack
};
}
};
</script>
<template>
<div class="isekai-preview-page-list-container" ref="containerRef">
<div v-if="pageListLoading" class="loading">
<div class="spinner">
<div class="oo-ui-widget oo-ui-widget-enabled oo-ui-progressBarWidget-indeterminate oo-ui-progressBarWidget"
aria-disabled="false" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div class="oo-ui-progressBarWidget-bar"></div>
</div>
</div>
</div>
<div v-else class="isekai-booklet-layout" :class="bookletAnimateClassList">
<div class="isekai-booklet-menu">
<ul class="isekai-list isekai-preview-page-list isekai-thin-scrollbar">
<a class="isekai-list-item" v-for="(pageInfo, idx) in pageList" :key="idx" href="javascript:;"
:class="{ active: idx === selectedPageIdx }" @click.prevent="onSelectPage(idx)">
<div class="isekai-list-item-content">
<div class="isekai-list-item-subtitle" v-if="pageInfo.subTitle">{{ pageInfo.subTitle }}</div>
<div class="isekai-list-item-title">
<div>{{ pageInfo.title }}</div>
</div>
</div>
<div class="isekai-list-item-icon">
<span
class="oo-ui-widget oo-ui-widget-enabled oo-ui-iconElement oo-ui-iconElement-icon oo-ui-icon-next oo-ui-labelElement-invisible oo-ui-iconWidget"
:class="{ 'oo-ui-image-invert': idx === selectedPageIdx, 'oo-ui-image-progressive': idx !== selectedPageIdx }"
aria-disabled="false"></span>
</div>
</a>
</ul>
</div>
<div class="isekai-booklet-content" v-if="selectedPageIdx === null">
<div class="isekai-preview-page-list-placeholder">
{{ mw.msg('isekai-preview-page-list-preview-placeholder') }}
</div>
</div>
<div class="isekai-booklet-content" v-else>
<div class="card-header">
<div class="card-header-buttons back-button-container">
<span class="oo-ui-widget oo-ui-widget-enabled oo-ui-buttonElement oo-ui-buttonElement-frameless oo-ui-iconElement oo-ui-buttonWidget">
<a class="oo-ui-buttonElement-button" role="button" tabindex="0" href="javascript:;" @click.prevent="onClickBack">
<span class="oo-ui-iconElement-icon oo-ui-icon-previous"></span>
<span class="oo-ui-labelElement-label oo-ui-labelElement-invisible">{{ mw.msg('isekai-preview-page-list-back-btn') }}</span>
<span class="oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator"></span>
</a>
</span>
</div>
<div class="card-header-text">{{ selectedPageInfo && selectedPageInfo.title }}</div>
<div class="card-header-buttons">
<span class="oo-ui-widget oo-ui-widget-enabled oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-iconElement oo-ui-labelElement oo-ui-flaggedElement-primary oo-ui-flaggedElement-progressive oo-ui-buttonWidget">
<a class="oo-ui-buttonElement-button" role="button" tabindex="0" :href="selectedPageUrl" target="_blank">
<span class="oo-ui-iconElement-icon oo-ui-icon-ellipsis oo-ui-image-invert"></span>
<span class="oo-ui-labelElement-label">{{ mw.msg('isekai-preview-page-list-readmore-btn') }}</span>
<span class="oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator oo-ui-image-invert"></span>
</a>
</span>
</div>
</div>
<div class="isekai-preview-container">
<div v-if="contentLoading" class="loading">
<div class="spinner">
<div class="oo-ui-widget oo-ui-widget-enabled oo-ui-progressBarWidget-indeterminate oo-ui-progressBarWidget"
aria-disabled="false" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div class="oo-ui-progressBarWidget-bar"></div>
</div>
</div>
</div>
<div v-else class="isekai-preview-content" v-html="previewPageContent"></div>
</div>
</div>
</div>
</div>
</template>

@ -0,0 +1,174 @@
const Vue = require("vue");
class BasePageListLoader {
loadPageList() {
return Promise.reject(new Error('Not implemented'));
}
parseHTMLString(txt) {
try {
let parser = new DOMParser();
let xmlDoc = parser.parseFromString(txt, "text/html");
return xmlDoc;
} catch(e) {
console.error(e.message);
}
return null;
}
loadPageContent(pageInfo) {
return new Promise((resolve, reject) => {
if (!pageInfo || !pageInfo.fullTitle) {
return reject(new Error('pageInfo.fullTitle is null'));
}
let url = mw.util.getUrl(pageInfo.fullTitle);
if (url.indexOf('?') >= 0) {
url += '&';
} else {
url += '?'
}
url += 'action=render';
$.get(url, (str) => {
let $dom = $(this.parseHTMLString(str));
let $content = $dom.find('.mw-parser-output');
if ($content.length > 0) {
//删除目录
$content.find('.toc').remove();
resolve($content.html());
}
}, 'html');
});
}
getPageUrl(pageInfo) {
return new Promise((resolve, reject) => {
if (!pageInfo || !pageInfo.fullTitle) {
return reject(new Error('pageInfo.fullTitle is null'));
}
let url = mw.util.getUrl(pageInfo.fullTitle);
resolve(url);
});
}
/**
* 根据标题生成页面信息
* @param {string} title
* @returns
*/
makePageInfoFromTitle(title) {
let pageInfo = {
fullTitle: title,
title: title,
subTitle: null
};
if (title.indexOf('/') !== 0) {
let splitPos = title.lastIndexOf('/');
pageInfo.title = title.substring(splitPos + 1);
pageInfo.subTitle = title.substring(0, splitPos);
}
return pageInfo;
}
}
class PresetPageListLoader extends BasePageListLoader {
/**
* 从页面列表加载页面
* @param {string[]} pageList
*/
constructor(pageList) {
super();
this.pageList = pageList.map((pageTitle) => {
return this.makePageInfoFromTitle(pageTitle);
});
}
/**
* 加载页面列表
* @returns {Promise<*>}
*/
loadPageList() {
return Promise.resolve(this.pageList);
}
}
class CategoryPageListLoader extends BasePageListLoader {
/**
* 从分类加载页面
* @param {string} category
*/
constructor(category) {
super();
this.category = category;
this.api = new mw.Api();
}
/**
* 加载页面列表
* @returns {Promise<*>}
*/
loadPageList() {
return new Promise((resolve, reject) => {
this.api.get({
action: 'query',
list: 'categorymembers',
cmtitle: this.category,
cmtype: page,
cmnamespace: 0,
cmlimit: 200,
cmsort: 'sortkey',
}).done((data) => {
if(data.query && data.query.categorymembers){
let pageList = data.query.categorymembers.map((pageInfo) => {
return this.makePageInfoFromTitle(pageInfo.title);
});
resolve(pageList);
} else if(data.error){
reject(new Error(data.error.info));
} else {
reject(new Error(mw.message('isekai-discover-error-cannotload').parse()));
}
}).fail((data) => {
reject(new Error(data.error.info));
});
});
}
}
let previewPageList = document.querySelectorAll('.isekai-preview-page-list-card');
if (previewPageList.length > 0) {
const App = require("./PreviewPageList.vue");
previewPageList.forEach((previewPageListDom) => {
try {
let props = {};
let dataset = previewPageListDom.dataset;
props.autoFocus = (dataset.autofocus == '1');
props.lazyLoad = (dataset.lazyload == '1');
switch (dataset.loader) {
case 'pages':
let pagesJsonEl = document.querySelector('script[type="application/json"][data-pages]');
let pageList = [];
if (pagesJsonEl) {
pageList = JSON.parse(pagesJsonEl.innerHTML);
}
props.pageListLoader = new PresetPageListLoader(pageList);
break;
case 'category':
let category = dataset.category;
props.pageListLoader = new CategoryPageListLoader(category);
break;
default:
console.error('Unknown loader: ' + dataset.loader);
}
Vue.createMwApp(App, props).mount(previewPageListDom);
} catch (e) {
console.error(e);
}
});
}

@ -0,0 +1,173 @@
@preview-page-list-height: 70vh;
.isekai-preview-page-list-card {
height: @preview-page-list-height;
}
.isekai-preview-page-list-card > .card-header {
height: 2.2rem;
}
.isekai-preview-page-list-container {
height: 100%;
margin: 0;
.loading {
width: 100%;
height: 99.5%;
height: calc(100% - 2px); // fix: overflow because of border
margin-top: 1px;
display: flex;
.spinner {
margin: auto;
padding: 2rem;
width: 100%;
display: flex;
justify-content: center;
.oo-ui-progressBarWidget {
width: 100%;
}
}
}
.isekai-booklet-layout {
display: flex;
height: @preview-page-list-height;
}
// 页面列表
.isekai-booklet-menu {
flex: 0 0;
width: 280px;
flex-basis: 280px;
padding-bottom: 0 !important;
height: @preview-page-list-height;
overflow-y: auto;
border-right: 1px solid rgba(0,0,0,.12);
}
.isekai-preview-page-list {
width: 100%;
.isekai-list-item {
&.active {
box-shadow: none;
background-color: #36c;
color: #fff !important;
}
}
}
// 页面预览
.isekai-booklet-content {
flex: 1 1;
width: 0;
height: @preview-page-list-height;
display: flex;
flex-direction: column;
.card-header {
flex: 0 0;
}
.back-button-container {
display: none;
}
.isekai-preview-container {
box-sizing: border-box;
width: 100%;
flex: 1 1;
overflow-y: auto;
padding: 12px 20px;
}
// 提示选择页面
.isekai-preview-page-list-placeholder {
width: 100%;
height: @preview-page-list-height;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2rem;
}
}
@media (max-width: 576px) {
.isekai-booklet-layout {
position: relative;
&.animating {
.isekai-booklet-menu,
.isekai-booklet-content {
transition: transform 150ms ease-in-out, opacity 150ms ease-in-out;
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.isekai-booklet-menu {
display: block;
z-index: 3;
background: white;
}
.isekai-booklet-content {
display: flex;
z-index: 2;
}
&.focus-menu-transition {
.isekai-booklet-menu {
transform: scale(100%);
opacity: 1;
}
.isekai-booklet-content {
transform: scale(80%);
opacity: 0;
}
}
&.focus-content-transition {
.isekai-booklet-menu {
transform: scale(120%);
opacity: 0;
}
.isekai-booklet-content {
transform: scale(100%);
opacity: 1;
}
}
}
.isekai-booklet-menu {
width: 100%;
flex-basis: 100%;
border-right: none;
}
.isekai-booklet-content {
display: none;
}
&.focus-content {
.isekai-booklet-menu {
display: none;
}
.isekai-booklet-content {
display: flex;
}
}
.back-button-container {
display: flex;
}
}
}
}
Loading…
Cancel
Save